Blog

How to send Slack notifications from your app

Emily LaneEmily LaneSeptember 11, 2026
Courier Live Header

Slack is where your customers already work, which makes it a product surface rather than only an internal alerting channel. An approval request or a status change posted into a customer's own Slack lands where they already are, instead of behind an email they'll read later.

That means posting into workspaces you don't own, addressing people by the email address you already store rather than a Slack ID they don't know, and holding a separate credential for every customer.

Creating the Slack app

Create an app at api.slack.com/apps, choose From scratch, add bot token scopes under OAuth & Permissions, then install it and copy the Bot User OAuth Access Token.

ScopeGrants
chat:writePosting messages. The bot must be in the channel.
chat:write.publicPosting to public channels without being invited
im:writeOpening a direct message conversation
users:readReading the workspace user directory
users:read.emailLooking a user up by email

Two things to know. Scopes are granted when the app is installed, not when you save them in the configuration, so adding one later means the workspace has to reinstall before it takes effect. And use the bot token, which starts with xoxb-, not a user token starting with xoxp-. A user token acts as the person who authorized it, so messages appear to come from them and delivery stops when they deactivate their account.

Choosing a destination

A message needs a token and exactly one destination. Slack accepts three forms, and which one you reach for usually depends on what your own database already knows about the person:

{ "to": { "slack": { "access_token": "xoxb-xxxxx", "channel": "CL2MR6HEX" } } }
{ "to": { "slack": { "access_token": "xoxb-xxxxx", "user_id": "UEFNTF6QL" } } }
{ "to": { "slack": { "access_token": "xoxb-xxxxx", "email": "user@example.com" } } }

Channel IDs start with C and are not channel names. Copy the ID from the channel's Slack URL. A name resolves only for channels the bot can already see, and when it doesn't resolve you get channel_not_found at delivery rather than an error at send time.

Email is the useful one. Your database has email addresses and almost certainly doesn't have Slack user IDs, and collecting them means asking users for something they don't know. Pass an email and Courier runs the directory lookup against Slack, which is what users:read and users:read.email are for.

If several destinations are present, Courier picks channel, then user_id, then email. A stored profile carrying an email loses silently to a channel supplied on the send.

Sending the message

With the app installed and a destination picked, the send is one call:

import Courier from "@trycourier/courier";
const courier = new Courier({ apiKey: process.env.COURIER_API_KEY });
await courier.send.message({
message: {
to: {
slack: {
access_token: process.env.SLACK_BOT_TOKEN,
email: "user@example.com",
},
},
template: "INVOICE_APPROVAL",
data: { vendor: "Northwind Traders", amount: "$4,820", requested_by: "Dana Ruiz" },
},
});

template is the ID of a template you've built in Courier, and data fills its variables. Swap email for channel or user_id to change the destination without touching anything else.

Storing tokens for more than one workspace

The send above passes the token inline, which is fine while you're wiring things up. Each install produces one token, valid only in that workspace, so where it lives matters as soon as there's more than one. For a single workspace, put it on the recipient's profile and address them by user ID:

await courier.profiles.replace("user_123", {
profile: {
slack: {
access_token: process.env.SLACK_BOT_TOKEN,
email: "user@example.com",
},
},
});

Once you're installing into customer workspaces, that duplicates one company's token across every user who works there. Put it on the tenant instead. A tenant's user_profile merges into the profile of anyone sent with that tenant's context:

{
"user_profile": {
"slack": { "access_token": "xoxb-..." }
}
}

Any send carrying that tenant_id then routes through the right workspace, and rotating a token is one update instead of a migration. Microsoft Teams credentials live in the same place.

Formatting Slack messages with Block Kit

Block Kit is Slack's current layout system; the older attachments field is documented as legacy. Raw Block Kit goes in a provider override, which replaces the payload Courier would otherwise build:

{
"providers": {
"slack": {
"override": {
"body": {
"blocks": [
{ "type": "header", "text": { "type": "plain_text", "text": "Invoice needs approval" } },
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Vendor:*\nNorthwind Traders" },
{ "type": "mrkdwn", "text": "*Amount:*\n$4,820" }
]
}
],
"text": "Northwind Traders invoice for $4,820 needs approval."
}
}
}
}
}

Always include a top-level text with your blocks. Slack builds desktop notification text from the blocks when it can, but mobile notifications use text and nothing else. A blocks-only message looks right in the channel and arrives as an empty push on a phone, which you won't catch from a desktop client.

Four block types cover most of it: header for large plain text, section for a paragraph of mrkdwn or up to ten two-column fields, context for small secondary text like timestamps, and actions for buttons. Buttons need an interactivity endpoint in your own app to receive the clicks, which is a larger topic than sending.

Slack's inline formatting is its own dialect, not Markdown:

EffectSyntax
Bold*bold*
Italic_italic_
Strikethrough~strike~
Inline code`code`
User mention<@U0123ABC>
Channel link<#C0123ABC>

Links use the same angle-bracket form, with the URL and the label separated by a pipe.

A section block caps at 3,000 characters and Courier truncates past that, so split long content across blocks or link out to it.

Preventing unintended mentions

Slack turns @name patterns into real mentions. Relay a comment your customer typed, say @jennifer should sign off on this, and Slack pages whoever @jennifer resolves to in their workspace. Set verbatim: true on the text object to stop that:

{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ">>> @jennifer should sign off on this",
"verbatim": true
}
}

Courier's standard template blocks don't expose the flag, so use a raw block override or a Jsonnet block whenever the body carries text you didn't write.

Threading, updating, and scheduling messages

Slack identifies a message by ts, a timestamp string returned when it posts. Anything you want to do to a message afterward needs that value.

Reply in a thread by passing the parent's ts as thread_ts in the same override slot:

{
"providers": {
"slack": {
"override": { "body": { "thread_ts": "1630512468.000700" } }
}
}
}

To edit a message instead of posting a new one, set a replacement key, normally ts, in the template's Slack channel settings. Courier updates the original in place, which is how an approval request becomes "Approved by Dana" rather than sitting in the channel contradicting what already happened.

For scheduling, use Courier's delay rather than a provider override, so the timing applies whatever channel the template ends up using:

await courier.send.message({
message: {
to: { user_id: "user_123" },
template: "RENEWAL_REMINDER",
delay: { until: "2026-09-12T16:00:00Z" },
},
});

delay.until also takes an opening-hours expression with a timezone, so "next weekday morning, their time" doesn't need computing on your side.

Handling Slack's rate limits and retries

Slack allows about one message per second per channel across every posting method. Short bursts of a few dozen are tolerated, sustained rates aren't. A bulk import that posts one message per row into a customer's channel will hit it, and the fix is one message with multiple blocks, not faster retries.

Don't add your own retry layer. The SDK already retries 429s from Courier with backoff and honors retry-after, and Slack-side limits are absorbed by Courier's delivery retries, which run for up to 24 hours. A second backoff schedule on top competes with those.

Rate limits don't govern how often a person hears from you. Courier doesn't throttle plain send.message calls server side, so a frequency cap means putting the send inside a journey with a throttle node:

{
"id": "throttle-per-user",
"type": "throttle",
"scope": "user",
"max_allowed": 5,
"period": "PT1H"
}

When the cadence should be the recipient's choice rather than yours, a digest schedule on a subscription topic is the better tool.

Tracking delivery

Slack returns an identifier for every message it accepts, and you need it for anything that happens afterward: replying in a thread, editing the original, or linking someone back to the conversation. Courier captures it and puts it on the message record, available both on GET /messages/{id} and in message:updated webhook events:

{
"providers": [
{
"provider": "slack",
"status": "DELIVERED",
"reference": {
"channel": "C04EXAMPLE",
"ts": "1630512468.000700"
},
"sent": 1630512468691,
"delivered": 1630512468700
}
]
}

reference.ts is what you need for threading and updates. reference.channel is the conversation it landed in, which matters when you addressed someone by email and never knew the conversation ID.

Slack confirms synchronously, so messages go to DELIVERED immediately and there's no later bounce to wait for.

Fixing common errors

Most Slack failures come from configuration rather than code, and the error string usually tells you exactly what to change. These five cover almost all of them:

ErrorCauseFix
channel_not_foundA name was used where an ID was expected, or the bot can't see the channelUse the ID from the channel's Slack URL
not_in_channelThe bot isn't a memberInvite it, or add chat:write.public
user_not_foundThe email matches no account in the workspaceCheck it against their Slack account, not only your records
invalid_authToken is wrong, revoked, or from another workspaceReinstall and store the new token
missing_scopeA scope was added after installationReinstall so the workspace grants it

missing_scope is the one that looks like a regression: nothing in your code changed, the scope is visibly present in the app config, and sends still fail, because the installation predates it.

Sending Slack notifications from a journey

Slack is a channel in Journeys, where multi-step sequences live. A Slack send node takes the same two inputs as form fields: Send to (Channel or Direct message) and Access Token. Each takes a fixed value or a path into the journey's data, so destination and credential can vary per user or tenant.

Journey fieldSend API equivalent
Send to, Channelchannel
Send to, Direct messageuser_id or email
Access Tokenaccess_token

One rule differs: access_token has to be a runtime reference like {{data.slack_token}}, and a literal xoxb- string is rejected. Leave it empty and it falls back to the stored profile or tenant token.

That's how "post to the account's Slack channel, wait a day, escalate to email if nobody responded" gets built without writing the orchestration yourself.

What you still have to build yourself

chat.postMessage is a small amount of code. The system around it is not:

  • One bot token per customer workspace, stored, rotated, and revoked on uninstall
  • Email to user ID resolution, cached, with defined behavior when the person isn't in the workspace
  • Backoff for the per-channel limit, and a queue that doesn't drop messages while it waits
  • Per-user preferences, so Slack can be turned off without losing the email version
  • A delivery record of what was sent, where, and whether it arrived
  • A fallback for people who aren't in Slack at all

Courier handles the token storage, the lookup, the retries, the preferences, and the logs, and the same template can go out over email, SMS, push, or in-app when Slack isn't the right destination.

Frequently asked questions

Do I need a Slack app to send notifications, or is a webhook enough?

Use a bot token. An incoming webhook is a single URL bound to a channel the installing user picks, and Slack's own docs are clear that you cannot override that channel per message. Webhooks also return no ts, so threading or updating means fetching the timestamp separately, and a message posted through one cannot be deleted. They suit a product whose entire Slack integration is "send our alerts to one channel," and nothing beyond that.

Which OAuth scopes does sending require?

chat:write is the minimum. Add chat:write.public to post to public channels the bot hasn't been invited to, im:write for direct messages, and users:read with users:read.email to address people by email. Scopes are granted at installation, so adding one later requires reinstalling the app.

Can I send a Slack message to someone by their email address?

Yes. Pass email in the slack object and Courier resolves it to a Slack user ID, provided the app holds users:read and users:read.email. If several destinations are present, channel takes precedence, then user_id, then email.

How do I update or reply to a Slack message I already sent?

You need the ts Slack returns when the message posts. Courier exposes it as providers[].reference.ts on GET /messages/{id} and in message:updated webhook events. Pass it as thread_ts in a provider override to reply in a thread, or set a replacement key in the template's Slack channel settings to edit the original. Webhook sends return no ts and cannot be updated.

What are Slack's rate limits for posting messages?

About one message per second per channel, counted across every posting method. Short bursts of a few dozen are tolerated, sustained rates are not. When many alerts need to reach one channel, combine them into a single message with multiple blocks.

Why does my Slack message look blank in notifications?

Slack builds desktop notification text from the blocks when it can, but mobile notifications use the top-level text field and nothing else. A message built only from blocks renders correctly in the channel and arrives as an empty push on a phone. Always send text alongside blocks.

Where should I store bot tokens for multiple customer workspaces?

On the tenant representing each customer, in its user_profile. That profile merges into the recipient's at send time, so any message carrying the tenant_id routes through the right workspace without duplicating the token on every user. Rotating a token is then one update.

Next steps

Sign up free and send to Slack, email, SMS, and push from one template.