Export Slack messages to Google Sheets with the API, including the channel invite that causes not_in_channel and the 2025 rate limits that throttle history to 15 messages a minute.
Getting Slack messages into a spreadsheet is more constrained than most integrations, and it is worth knowing why before you start. A bot must be invited to every channel it reads, regardless of its scopes, and in 2025 Slack sharply reduced the rate limits on message history for apps that are not approved Marketplace apps. This guide covers the workspace export, the free API method with both constraints handled, and when each is the right choice.
The admin option: workspace export
Workspace owners can export public channel history from Settings & administration → Workspace settings → Import/Export Data. The standard export is available on every plan and covers public channels; exporting private channels or DMs requires a paid plan and an application to Slack, since it exposes private content.
The output is a zip of JSON files, one per channel per day, not a CSV. Getting that into Sheets means processing it first. For a bulk archive this is still the fastest route and it sidesteps rate limits entirely.
Slack to Google Sheets for free (Apps Script + Web API)
Create an app at api.slack.com/apps, add the bot token scopes channels:history, channels:read and users:read (add groups:history for private channels), install it to your workspace, and copy the bot token beginning xoxb-.
Then invite the bot to each channel you want to read by typing /invite @yourbotname in that channel. Scopes grant the ability to read channels in principle; membership grants it in practice. Without the invite every call returns not_in_channel.
function importSlackChannel() { const TOKEN = "xoxb-XXXXXXXXXXXX"; const CHANNEL = "C01ABC2DEFG"; // channel ID, from the channel's About tab const headers = { Authorization: "Bearer " + TOKEN }; // Resolve user IDs to names once, rather than per message. const names = {}; let userCursor = null; do { let u = "https://slack.com/api/users.list?limit=200"; if (userCursor) u += "&cursor=" + userCursor; const r = JSON.parse(UrlFetchApp.fetch(u, { headers }).getContentText()); if (!r.ok) throw new Error(r.error); r.members.forEach((m) => { names[m.id] = m.profile.display_name || m.real_name || m.name; }); userCursor = (r.response_metadata || {}).next_cursor || null; } while (userCursor); const rows = []; let cursor = null; do { let url = "https://slack.com/api/conversations.history" + "?channel=" + CHANNEL + "&limit=200"; if (cursor) url += "&cursor=" + cursor; const res = JSON.parse(UrlFetchApp.fetch(url, { headers }).getContentText()); // Slack returns HTTP 200 with ok:false for logical errors, so checking // the status code alone will happily process an error response. if (!res.ok) { if (res.error === "ratelimited") { Utilities.sleep(60000); continue; } throw new Error(res.error); // not_in_channel, missing_scope, … } res.messages.forEach((m) => { rows.push([ new Date(parseFloat(m.ts) * 1000), // ts is epoch seconds names[m.user] || m.user || m.bot_id || "", expandRefs(m.text || "", names), m.thread_ts || "", (m.reply_count || 0), ]); }); cursor = (res.response_metadata || {}).next_cursor || null; if (cursor) Utilities.sleep(1200); } while (cursor); const header = ["Timestamp", "User", "Message", "Thread", "Replies"]; const ss = SpreadsheetApp.getActiveSpreadsheet(); const sheet = ss.getSheetByName("Slack") || ss.insertSheet("Slack"); sheet.clearContents(); sheet.getRange(1, 1, 1, header.length).setValues([header]); if (rows.length) { sheet.getRange(2, 1, rows.length, header.length).setValues(rows); }} // Message text embeds raw IDs: "<@U01ABC2DEF> see <#C01XYZ|general>".function expandRefs(text, names) { return text .replace(/<@([A-Z0-9]+)>/g, (m, id) => "@" + (names[id] || id)) .replace(/<#[A-Z0-9]+\|([^>]+)>/g, (m, name) => "#" + name);}The most important habit in that code: Slack returns HTTP 200 even when a call fails, signalling problems through an ok: false body with an error string. Code that only checks the status code will treat not_in_channel as a successful empty response and quietly write nothing.
Limits: the 2025 rate limits on conversations.history for non-Marketplace apps, around one request a minute returning up to 15 messages, make full history pulls of busy channels impractical. Apps Script's six-minute ceiling compounds this. For bulk history, use the workspace export; for ongoing capture, pull incrementally with the oldest parameter so each run fetches only what is new.
The Brooked method
Brooked handles cursor pagination, resolves user IDs to names, expands inline channel and user references, and imports incrementally so each scheduled run only fetches messages newer than the last, which keeps you well inside the rate limits rather than re-reading history every time.
Step 1: Install Brooked
Install Brooked from the Google Workspace Marketplace. Free tier includes 100 imports per month, no credit card required.
Step 2: Connect Slack and invite the bot
Choose Slack in Brooked and authorise the workspace. Then invite the app to each channel you want to read with /invite @brooked. This step is not optional and is the cause of nearly every "the channel list is empty" report.
Step 3: Schedule the refresh
Set the import to re-run hourly or daily. Incremental imports append only new messages, so a long-running channel log builds up over time without repeatedly hammering the history endpoint.
Method comparison
| Method | Cost | Setup | Refresh | Two-way | Best for |
|---|---|---|---|---|---|
| Slack workspace export | Free (public channels) | 5 min · admin only | Manual | No | Archival dumps of public channels |
| Apps Script + Web API | Free | 30 to 45 min | Time-driven trigger | No | A few channels, full control |
| Zapier / Workflow Builder | Free tier limited · paid for volume | 10 min | On new message | Post back to Slack | Logging matching messages as they arrive |
| Brooked | Free tier · Pro $29/user/mo | ~3 min | Scheduled (hourly, daily, weekly) | No | Scheduled channel reporting |
Common use cases
- Support metrics: measure volume and time-to-first-reply in a shared help channel
- Incident timelines: pull an incident channel into a sheet to build a chronology for the post-mortem
- Standup logs: archive daily updates into a searchable, filterable table
- Compliance retention: keep an auditable record of a channel beyond your Slack retention window
- Sentiment and topic tracking: categorise recurring themes across customer-facing channels
Troubleshooting common issues
Related Articles
- How to Connect Pipedrive to Google Sheets
- How to Connect Monday.com to Google Sheets
- Connect Google Search Console to Google Sheets
Frequently asked questions
Why does the Slack API return not_in_channel?
Having the right OAuth scopes is not enough: a bot can only read a channel it has actually joined. Invite it from the channel itself with /invite @yourbotname, or add it through the channel's integrations settings. This applies per channel, so a bot reading ten channels needs inviting to all ten.
Can I export Slack messages to Google Sheets for free?
Yes, with limits. Workspace owners can run a standard export covering public channels from the workspace settings, free on all plans, which produces JSON files you would then process. The Apps Script method here is also free and works per channel. Exporting private channels and DMs requires a paid plan plus approval, since it covers private content.
Why is conversations.history so slow now?
Slack tightened rate limits on conversations.history and conversations.replies for non-Marketplace apps in 2025. Newly created apps that are not approved Slack Marketplace apps are limited to roughly one request per minute returning at most 15 messages. Pulling a busy channel's full history under that ceiling is impractical, which is why exports and approved connectors matter more than they used to.
What is the ts field and why does it look like a decimal?
The ts value is both a Unix timestamp in seconds and the message's unique ID, with microseconds after the decimal point, for example 1721476800.123456. Take the integer part and multiply by 1000 for a JavaScript date. Keep the full string when you need to reference a message, since threads identify their parent by exact ts.
Why do messages show U01ABC2DEF instead of names?
Slack returns user IDs, not display names. Call users.list once, build an ID-to-name map, and translate as you write rows. Message text also contains inline references such as <@U01ABC2DEF> and <#C01XYZ|general> that need the same substitution to read naturally.
Can I post from Google Sheets into Slack?
Yes, though that is a separate direction from reading history. Slack's chat.postMessage or an incoming webhook can post a message from Apps Script, and Brooked's automations can send scheduled Slack alerts when a sheet meets a condition, which is the more common reason people want the two connected.
Connect Slack to Google Sheets today.
Free tier: 100 imports per month, no credit card required. Or see the full Slack integration details.
Install Brooked free →

