# Slack

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

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](/api/tracker).

## What you need

- 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](https://docs.slack.dev/changelog/2025/05/29/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](/api/tracker#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.

## 1. Get API access in Slack

Slack's own steps are in [Creating an app from app settings](https://docs.slack.dev/app-management/quickstart-app-settings).

1. Go to [api.slack.com/apps](https://api.slack.com/apps?new_app=1), 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**.

## 2. Pull new messages

[List the channels](https://docs.slack.dev/reference/methods/conversations.list) the app can see, and keep those where `is_member` is `true`:

```bash
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](https://docs.slack.dev/reference/methods/conversations.history) from a point in time:

```bash
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](https://docs.slack.dev/reference/methods/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](https://docs.slack.dev/apis/web-api/rate-limits)).

## 3. Map the fields

| Dimension Labs | Slack | Notes |
|---|---|---|
| `text` | message `text` | Keep messages with no `subtype`, or with `bot_message`, `thread_broadcast` or `file_share`. Mentions come through as `<@U123ABC456>`. |
| `userId` | the thread parent's `user` | The 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`. |
| `incoming` | message `user` | `false` when the message has a `bot_id` or the `user` is one of your staff; `true` for everyone else. |
| `sessionId` | channel `id` and the thread's `ts` | Join them, for example `slack-C123ABC456-1512085950.000216`. |
| `conversationId` | channel `id` and the thread's `ts` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | message `ts` | Seconds with a fraction. `toMillis` turns it into milliseconds. |
| `platformJson` | channel `id`, `name`, `is_ext_shared`; message author, `reply_count` | `is_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](/data/pii-redaction).

## 4. Send it

```js
// slack.mjs. Environment: SLACK_BOT_TOKEN, SLACK_STAFF_IDS (comma-separated member IDs), DL_API_KEY.


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.

## 5. Run it

Save the code as `slack.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/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](/dimensions/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](/data/upload-a-file) of exported messages with one row per message (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Slack reference

- [Creating an app from app settings](https://docs.slack.dev/app-management/quickstart-app-settings)
- [conversations.list](https://docs.slack.dev/reference/methods/conversations.list)
- [conversations.history](https://docs.slack.dev/reference/methods/conversations.history)
- [conversations.replies](https://docs.slack.dev/reference/methods/conversations.replies)
- [Retrieving messages](https://docs.slack.dev/messaging/retrieving-messages)
- [Message events and subtypes](https://docs.slack.dev/reference/events/message)
- [Rate limits](https://docs.slack.dev/apis/web-api/rate-limits)
- [Rate limit changes for non-Marketplace apps](https://docs.slack.dev/changelog/2025/05/29/rate-limit-changes-for-non-marketplace-apps)

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