
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.
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.
| Scope | Grants |
|---|---|
chat:write | Posting messages. The bot must be in the channel. |
chat:write.public | Posting to public channels without being invited |
im:write | Opening a direct message conversation |
users:read | Reading the workspace user directory |
users:read.email | Looking 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.
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.
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.
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.
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:
| Effect | Syntax |
|---|---|
| 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.
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.
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.
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.
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.
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:
| Error | Cause | Fix |
|---|---|---|
channel_not_found | A name was used where an ID was expected, or the bot can't see the channel | Use the ID from the channel's Slack URL |
not_in_channel | The bot isn't a member | Invite it, or add chat:write.public |
user_not_found | The email matches no account in the workspace | Check it against their Slack account, not only your records |
invalid_auth | Token is wrong, revoked, or from another workspace | Reinstall and store the new token |
missing_scope | A scope was added after installation | Reinstall 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.
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 field | Send API equivalent |
|---|---|
| Send to, Channel | channel |
| Send to, Direct message | user_id or email |
| Access Token | access_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.
chat.postMessage is a small amount of code. The system around it is not:
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.
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.
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.
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.
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.
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.
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.
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.
Sign up free and send to Slack, email, SMS, and push from one template.

Clinical alert and notification systems: how escalation works
A clinical alert is easy to send and hard to close. This is how alerting works inside a hospital: where alerts come from, how routing by role and shift works, and how the acknowledge, timeout, escalate loop is actually built, including what happens when nobody answers.

How Product Teams Build, Test and Ship Multichannel Notifications in Design Studio
Product teams need to build, test, and ship notifications across multiple channels without filing an engineering ticket every time. Courier's Design Studio is the workspace for that: a template builder, visual channel routing, omnichannel testing, and publishing in one place. This post walks through the traditional template designer paradigm, how it splits effort across too many tools, and outlines a path for product and growth teams to ship transactional, product, and marketing notifications from a single workspace.

EU Data Residency for Notifications: What Engineering Teams Need to Know
Courier supports EU data residency through a dedicated datacenter in AWS EU-West-1 (Ireland), with full API feature parity, same-workspace dual-region access, built-in GDPR deletion endpoints, and localization support for multilingual notifications. Engineering teams can switch to EU hosting by changing a single base URL with no workspace migration or downtime required.