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 addressconst 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 OAuthapp.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 →