BLOGS
Multi-Tenant Slack Messaging API Guide (Node.js, 2026)
Learn Slack API send message patterns for SaaS apps: post to channels, users, and private channels with Node.js using chat.postMessage, OAuth, and Slack Conversations API.
Building a SaaS product? Your customers want Slack notifications in their workspaces, not yours. This guide shows how to do Slack API send message in a multi-tenant setup: each customer connects Slack, then your app posts alerts, updates, and interactive messages to their destinations.
You have two options: use Pingram and ship in minutes, or build it yourself with the Slack message API.
Quick Comparison
| Aspect | Pingram | Slack APIs (DIY) |
|---|---|---|
| OAuth Flow | Pre-built React component | Build from scratch |
| Token Storage | Managed & encrypted | Your database + encryption |
| Rate Limits | Handled automatically | Track per workspace yourself |
| Time to Ship | Minutes | Days to weeks |
| Maintenance | None | Ongoing |
| Costs | Free tier, then pay per message | Database + compute + engineering time |
| Best For | Ship fast, multi-tenant scenarios | Full control, custom requirements |
Option 1: Pingram (The Easy Way)
Here’s all you need with Pingram.
1. Pre-Built Connect Slack Button (React)
<PingramProvider clientId="YOUR_CLIENT_ID" userId={customerId}>
<SlackConnect />
</PingramProvider>
That’s it. The component handles the entire OAuth flow—authorization, token exchange, secure storage. Your customer clicks “Connect Slack,” authorizes, and they’re done.
2. Send Messages
const pingram = new Pingram({ apiKey: 'pingram_sk_...' });
await pingram.send({
type: 'new_order',
to: { id: customerId },
slack: {
// plain text:
text: 'New order received',
// or using block kit:
blocks: [
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Order:*\n#${order.id}` },
{ type: 'mrkdwn', text: `*Amount:*\n$${order.total}` }
]
}
]
}
});
That’s It
Two code snippets. No OAuth callback endpoints. No encrypted token database. No rate limit queues. Try Pingram →
Option 2: Slack APIs (DIY)
If you need full control or have specific requirements Pingram doesn’t cover yet (like inbound messages or interactive modals), here’s how to build it yourself.
The Steps
- Create a Slack App
- OAuth Flow
- Destination Picker (which channel/user should receive the messages)
- Sending Messages
- Rate Limits & Error Handling
Step 1: Create a Slack App
- Go to api.slack.com/apps and create a new app
- Add your Redirect URL (e.g.,
https://yourapp.com/slack/callback) - Add Bot Token Scopes:
chat:write— required forchat.postMessagechat:write.public— optional, allows posting in public channels without joining firstchannels:readandgroups:read— optional, used if you build a channel pickerim:readandmpim:read— optional, used if you list DM/MPIM destinationschannels:join— optional, only if you want to auto-join public channels viaconversations.joinim:write— optional, used if you open DMs withconversations.openusers:read— optional, only if you need user profiles for display
- Note your Client ID and Client Secret
Step 2: OAuth Flow
Redirect customers to Slack’s authorization URL with your scopes and a CSRF-safe state parameter. When they approve, Slack redirects back with a code. Exchange that code for an access token and store it securely.
If token rotation is enabled for your Slack app, you’ll also receive refresh_token and expires_in and must rotate tokens before expiry.
const SCOPES =
'chat:write,chat:write.public,channels:read,groups:read,im:read,mpim:read,users:read';
// redirect the user to this address
const authUrl =
`https://slack.com/oauth/v2/authorize?` +
`client_id=${SLACK_CLIENT_ID}` +
`&scope=${SCOPES}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&state=${generateSecureState(customerId)}`;
At this stage, users enter Slack OAuth and install your app into their workspace. After approval, Slack redirects to your callback URL.
// user is redirected here after Slack OAuth
app.get('/slack/callback', async (req, res) => {
const { code, state } = req.query;
const customerId = verifyState(state);
if (!customerId) return res.status(400).send('Invalid state');
const response = await fetch('https://slack.com/api/oauth.v2.access', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI
})
});
const data = await response.json();
if (!data.ok) return res.redirect('/settings?slack=error');
await storeSlackTokens(customerId, {
teamId: data.team.id,
teamName: data.team.name,
accessToken: data.access_token,
scope: data.scope,
// present when token rotation is enabled
refreshToken: data.refresh_token ?? null,
expiresIn: data.expires_in ?? null,
// present if app requests incoming webhook support
incomingWebhookUrl: data.incoming_webhook?.url ?? null
});
res.redirect('/settings?slack=connected');
});
Step 3: Destination Picker
After OAuth, fetch available destinations so customers can choose where notifications go:
async function getSlackDestinations(customerId) {
const connection = await getSlackConnection(customerId);
const client = new WebClient(connection.accessToken);
const page = await client.conversations.list({
exclude_archived: true,
types: 'public_channel,private_channel',
limit: 200
});
const channels = (page.channels ?? []).map((conv) => ({
id: conv.id,
name: conv.name,
isPrivate: conv.is_private
}));
return { channels };
}
Store the user’s selection:
await updateCustomer(customerId, { slackChannel: selectedDestinationId });
If you support Slack API send message to user, store a user destination separately and open a DM with conversations.open before posting.
Step 4: Sending Messages
Use the customer’s stored destination preference. This is a practical Slack API post message example with chat.postMessage:
async function notifyCustomer(customerId, notification) {
const customer = await getCustomer(customerId);
const connection = await getSlackConnection(customerId);
const client = new WebClient(connection.accessToken);
const result = await client.chat.postMessage({
channel: customer.slackChannel,
text: notification.fallbackText,
blocks: [
// block kit
]
});
return { sent: true, ts: result.ts };
}
For Slack schedule message flows, use chat.scheduleMessage with a Unix timestamp:
await client.chat.scheduleMessage({
channel: customer.slackChannel,
text: notification.fallbackText,
post_at: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now
});
Step 5: Rate Limits & Error Handling
chat.postMessage uses Slack’s special rate limit tier. A safe baseline is about 1 message/second/channel, plus a workspace-wide cap. In multi-tenant systems, enforce per-workspace queues and respect Retry-After on HTTP 429 responses.
Customers can revoke your app’s access anytime from Slack settings. When you hit token_revoked or account_inactive, mark the connection as disconnected and prompt reconnect. Treat invalid_auth as a bad token/config error and avoid blind retries.
Pitfalls
Public channels: With chat:write.public, apps can usually post to public channels without joining. If your flow does require joining, call conversations.join and request the channels:join scope.
Private channels: For Slack API send message to private channel, your app must be invited first (/invite @yourbot). Bots cannot self-join private channels.
Sending to users: Don’t send by username (deprecated). For Slack API send message to specific user, either post to their App Home/Slackbot thread by user ID or open a DM with conversations.open and send to the returned D... channel ID.
Destination picker pagination: conversations.list is paginated. If a workspace has many channels, follow response_metadata.next_cursor until empty, or users won’t see all channels.
Token revocation: Customers can disconnect your app anytime from Slack settings. Always handle token_revoked and invalid_auth errors gracefully.
Common errors:
| Error | Cause | Fix |
|---|---|---|
channel_not_found | Invalid ID or no access | Verify channel, check bot membership |
not_in_channel | App is not a member of target conversation | Invite app or join channel (public) |
token_revoked | Customer disconnected | Mark invalid, prompt reconnect |
invalid_auth | Invalid token or bad config | Verify token/config, reconnect if needed |
Slack Webhook URL vs Slack API Post Message
If you’re comparing a Slack webhook URL to the Web API:
- Incoming Webhooks are great for simple one-channel posting and quick setup.
chat.postMessage+ OAuth is better for multi-tenant SaaS where each customer chooses their own workspace/channel, and where you need richer controls (threading, scheduling, per-tenant auth, destination settings).
For customer-facing SaaS products, the OAuth + Slack Conversations API route is usually the right long-term architecture.
Which Should You Choose?
Choose Pingram if:
- You want to ship fast
- You don’t need inbound messages or interactive modals (coming soon)
- You’d rather not maintain OAuth infrastructure
Choose DIY if:
- You need features Pingram doesn’t support yet
- You want complete control over the implementation
Most teams start with Pingram and only build custom when they hit a specific limitation. Try Pingram for free →