Skip to content

02 Get your data in /Integrations

Slack

Send Slack messages from shared customer and community channels to Dimension Labs with the universal API.

  1. S SlackTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

This brings in the messages of the Slack channels where you talk with customers: Slack Connect channels shared with customer companies, and channels in a community workspace. Each thread is one conversation, and a message with no replies is a conversation of one. What customers wrote is incoming; what your own staff and bots wrote is outgoing. You run it on your side: a small script pulls from the Slack Web API and posts each message to the universal API.

  • A Slack user who can create and install apps in your workspace.
  • Your own internal Slack app, created in your workspace (step 1), and its bot token (xoxb-...). Don’t use an app someone else distributes: since May 29, 2025, new installs of commercially distributed apps that aren’t in the Slack Marketplace can call conversations.history and conversations.replies only once a minute, 15 messages at a time. Internal apps a customer builds for its own workspace keep the higher limits (rate limit changes for non-Marketplace apps).
  • The Slack member IDs of your staff who answer in those channels, so their messages count as outgoing.
  • A Dimension Labs API key for a new Universal API source. See Your API key.

Slack’s API Terms of Service cover how data read through its APIs may be stored, used and shared. Have your Slack admin review them before you start.

Slack’s own steps are in Creating an app from app settings.

  1. Go to api.slack.com/apps, select Blank app and click Continue.
  2. Enter an app name, for example “Dimension Labs sync”, select your workspace and click Create.
  3. In OAuth & Permissions, under Bot Token Scopes, add channels:read, channels:history, groups:read and groups:history. The groups: scopes are only needed for private channels.
  4. At the top of OAuth & Permissions, click Install to Workspace, then Allow.
  5. Copy the bot token from OAuth Tokens.
  6. In each channel you want to bring in, /invite the app. The code reads every channel the app is a member of, and nothing else.

Send the token as Authorization: Bearer xoxb-....

To find a staff member’s ID, open their profile in Slack, click More and choose Copy member ID.

List the channels the app can see, and keep those where is_member is true:

Terminal window
curl -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
"https://slack.com/api/conversations.list?types=public_channel,private_channel&exclude_archived=true&limit=200"

For each channel, read its history from a point in time:

Terminal window
curl -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
"https://slack.com/api/conversations.history?channel=C123ABC456&oldest=1760313600&limit=200"
  • oldest and latest are Unix timestamps in seconds. Every message has a ts, a string such as "1512085950.000216", which is also its id within the channel.
  • Page with cursor, using response_metadata.next_cursor. Slack recommends no more than 200 results per page.
  • History returns top-level messages. A thread’s parent carries thread_ts, reply_count and latest_reply. Get the whole thread with conversations.replies, passing the parent’s ts.
  • A message from a bot has a bot_id. Messages with a subtype such as channel_join are events, not conversation.
  • Slack reports errors as "ok": false with an error code, so check ok on every response.
  • On free workspaces that reached the message limit, older messages are hidden, and the response includes is_limited.

Rate limits, per Slack: conversations.history and conversations.replies are Tier 3 (50+ per minute) for internal apps, and conversations.list is Tier 2 (20+ per minute). Going over returns 429 with a Retry-After header (rate limits).

Dimension LabsSlackNotes
textmessage textKeep messages with no subtype, or with bot_message, thread_broadcast or file_share. Mentions come through as <@U123ABC456>.
userIdthe thread parent’s userThe member who started the thread, on every message of it, unless that is your staff or a bot. Fall back to the sessionId. Each message’s own author goes in platformJson.
incomingmessage userfalse when the message has a bot_id or the user is one of your staff; true for everyone else.
sessionIdchannel id and the thread’s tsJoin them, for example slack-C123ABC456-1512085950.000216.
conversationIdchannel id and the thread’s tsSame value as sessionId.
dimensionlabs_timestampmessage tsSeconds with a fraction. toMillis turns it into milliseconds.
platformJsonchannel id, name, is_ext_shared; message author, reply_countis_ext_shared is true for Slack Connect channels.

Personal data. Don’t send names or emails from user profiles; the code never fetches them. Messages can hold names, emails and phone numbers: redact them before you send. See PII redaction.

// slack.mjs. Environment: SLACK_BOT_TOKEN, SLACK_STAFF_IDS (comma-separated member IDs), DL_API_KEY.
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
const HOUR = 60 * 60 * 1000;
const LOOKBACK = 30 * 24 * HOUR; // new replies are picked up in threads started up to 30 days before `since`
const KEEP = new Set([undefined, 'bot_message', 'thread_broadcast', 'file_share']);
const staff = new Set((process.env.SLACK_STAFF_IDS ?? '').split(',').map((s) => s.trim()).filter(Boolean));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const headers = { Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` };
// Retries Slack's rate limits (429) and server errors (5xx), honoring Retry-After.
async function call(method, params) {
const url = `https://slack.com/api/${method}?${new URLSearchParams(params)}`;
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, { headers });
if (res.ok) {
const body = await res.json();
if (!body.ok) throw new Error(`Slack ${method} returned ${body.error}`);
return body;
}
if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Slack returned ${res.status}: ${await res.text()}`);
await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
}
}
// Reads every page of a cursor-paginated method.
async function all(method, params, key) {
const items = [];
for (let cursor = ''; ; ) {
const page = await call(method, { ...params, limit: 200, ...(cursor && { cursor }) });
items.push(...page[key]);
cursor = page.response_metadata?.next_cursor;
if (!cursor) return items;
}
}
const isOurs = (m) => Boolean(m.bot_id) || staff.has(m.user);
// Sends every message posted at or after `since` (Unix ms), one conversation per thread. Returns the new `since`.
export async function syncSlack({ since, historical = false }) {
const runStart = Date.now();
const channels = (await all('conversations.list', { types: 'public_channel,private_channel', exclude_archived: true }, 'channels')).filter((c) => c.is_member);
for (const channel of channels) {
const oldest = String(Math.floor((since - LOOKBACK) / 1000));
for (const parent of await all('conversations.history', { channel: channel.id, oldest }, 'messages')) {
const hasNewReplies = parent.reply_count > 0 && parent.latest_reply && toMillis(parent.latest_reply) >= since;
if (!hasNewReplies && toMillis(parent.ts) < since) continue;
const thread = parent.reply_count > 0 ? await all('conversations.replies', { channel: channel.id, ts: parent.ts }, 'messages') : [parent];
const id = `slack-${channel.id}-${parent.ts}`;
for (const m of thread) {
const ts = toMillis(m.ts);
if (!KEEP.has(m.subtype) || ts < since || !m.text?.trim()) continue;
await sendToDimensionLabs({
text: m.text,
userId: parent.user && !isOurs(parent) ? parent.user : id,
incoming: !isOurs(m),
sessionId: id,
conversationId: id,
dimensionlabs_timestamp: ts,
platformJson: { channel_id: channel.id, channel_name: channel.name, is_ext_shared: channel.is_ext_shared, author: m.user ?? m.bot_id, reply_count: parent.reply_count ?? 0 },
}, { historical: historical || ts < Date.now() - HOUR });
}
}
}
return Math.max(since, runStart - 5 * 60 * 1000); // a small overlap: the duplicates are removed
}

Each run looks back 30 days for threads that got new replies since the last run, and sends only the new messages. A reply to a thread older than that isn’t picked up; raise LOOKBACK if your threads stay active for longer.

Save the code as slack.mjs in a project set up as in Set up your project, and run it with the scheduled sync runner: import { syncSlack as sync } from './slack.mjs';.

  • History first: node run.mjs 2025-01-01 sends every message since that date to /trackhistorical. Then start a backfill from the Backfill tab.
  • Then every 15 minutes: the scheduled job sends new messages to /track, and they are enriched automatically every day. A message that is more than an hour old when the job sees it goes to /trackhistorical, and needs a backfill.
  • Re-runs are safe: duplicates are removed automatically.

When you add a channel, /invite the app and run a history load for it: the scheduled job only looks back from its cursor. The code waits whenever Slack returns 429. You can also upload a file of exported messages with one row per message (check it with the file checker), then run a backfill.

Checked against Slack’s documentation on 2026-10-26.