Skip to Content

Slack is Astrelo’s outbound channel. When a deal regresses, a competitor enters, or a daily digest is ready, the alert can be pushed to a Slack channel where the sales team already lives. No one needs to open Astrelo — the intelligence comes to them.

OAuth: Same Dance, Third Partner

Slack OAuth follows the same three-legged pattern as HubSpot (Chapter 11):

// src/infrastructure/slack/slackService.ts, lines 45-83 export async function exchangeCodeForToken(code: string) { const body = new URLSearchParams({ client_id: process.env.SLACK_CLIENT_ID!, client_secret: process.env.SLACK_CLIENT_SECRET!, code, }); const response = await fetch('https://slack.com/api/oauth.v2.access', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const data = await response.json(); if (!data.ok) throw new Error(`Slack OAuth failed: ${data.error}`); return { bot_token: data.access_token, team_id: data.team?.id, team_name: data.team?.name, }; }

The result is a bot token — a permanent token that lets Astrelo post messages as “Cosmo” in the user’s Slack workspace. Unlike HubSpot tokens (6-hour expiry), Slack bot tokens don’t expire. They’re stored in slack_connections.bot_token.

Connection Storage

// src/infrastructure/slack/slackService.ts, lines 91-112 export async function saveConnection(userId, teamId, teamName, botToken) { await pool.query( `INSERT INTO slack_connections (user_id, team_id, team_name, bot_token) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET team_id = EXCLUDED.team_id, team_name = EXCLUDED.team_name, bot_token = EXCLUDED.bot_token, is_active = true, connected_at = NOW() RETURNING *`, [userId, teamId, teamName, botToken] ); }

The ON CONFLICT (user_id) upsert means reconnecting Slack (after revoking and re-authorizing) simply updates the existing row rather than creating a duplicate.

Channel Selection

After connecting, the user picks which Slack channel receives alerts. The channel list is fetched by paginating through Slack’s conversations.list API:

// src/infrastructure/slack/slackService.ts, lines 229-275 export async function listChannels(botToken: string): Promise<SlackChannel[]> { const allChannels: SlackChannel[] = []; let cursor: string | undefined; do { const params = new URLSearchParams({ types: 'public_channel,private_channel', exclude_archived: 'true', limit: '200', }); if (cursor) params.set('cursor', cursor); const response = await fetch( `https://slack.com/api/conversations.list?${params.toString()}`, { headers: { 'Authorization': `Bearer ${botToken}` } } ); const data = await response.json(); for (const ch of data.channels || []) { allChannels.push({ id: ch.id, name: ch.name, is_private: ch.is_private }); } cursor = data.response_metadata?.next_cursor || undefined; } while (cursor); return allChannels.sort((a, b) => a.name.localeCompare(b.name)); }

Event Subscriptions

Not every alert type goes to Slack. The user configures which events they want via checkboxes in Settings. The selected events are stored in slack_connections.events as a PostgreSQL text array:

events TEXT[] DEFAULT '{tasks.daily_digest,revenue.at_risk,rep.recommendations,...}'

When an alert fires, the delivery service checks if the user has subscribed to that event type:

// src/infrastructure/slack/slackDeliveryService.ts export async function deliverToSlack(eventType: string, userId: string, payload: any) { const connection = await getConnection(userId); if (!connection?.bot_token || !connection?.channel_id) return; // Skip events check for realtime alerts (pre-filtered by trigger rules) if (!eventType.startsWith('realtime.') && !connection.events.includes(eventType)) return; // Build and send Block Kit message const blocks = buildBlocks(eventType, payload); await postMessage(connection.bot_token, connection.channel_id, blocks); }

The realtime.* bypass is important — realtime alerts are already filtered by trigger rules before reaching the delivery service. The events array on the connection controls scheduled notifications (daily digests, weekly reports), not real-time alerts.

Posting Messages with Rate Limit Handling

Slack has strict rate limits (1 message per second per channel). The posting function handles 429 responses:

// src/infrastructure/slack/slackService.ts, lines 283-339 export async function postMessage(botToken, channelId, blocks) { const body = JSON.stringify({ channel: channelId, blocks, icon_url: `${appBaseUrl}/cosmo.png`, username: 'Cosmo', }); const response = await fetch('https://slack.com/api/chat.postMessage', { method: 'POST', headers: { 'Authorization': `Bearer ${botToken}`, 'Content-Type': 'application/json', }, body, }); // Handle 429 with single retry if (response.status === 429) { const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10); await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); // Retry once const retryResponse = await fetch('https://slack.com/api/chat.postMessage', { method: 'POST', headers: { 'Authorization': `Bearer ${botToken}`, 'Content-Type': 'application/json' }, body, }); const retryData = await retryResponse.json(); return { ok: retryData.ok, error: retryData.ok ? undefined : retryData.error }; } const data = await response.json(); return { ok: data.ok, error: data.ok ? undefined : data.error }; }

The Retry-After header tells us exactly how long to wait. One retry is enough — if it fails twice, the message is logged as failed and the user will still see the alert in-app.

Block Kit: Slack’s Component System

Slack messages aren’t plain text — they use Block Kit, a JSON-based layout system:

[ { "type": "header", "text": { "type": "plain_text", "text": "🚨 Deal Regression: Acme Corp" } }, { "type": "section", "text": { "type": "mrkdwn", "text": "Deal moved backward from *Decision* to *Qualification*.\nDeal value: $85,000" } }, { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "View in Astrelo" }, "url": "https://app.astrelo.ai/app?alert=abc-123" } ] } ]

Block Kit supports headers, sections (with markdown), images, buttons, and more. The delivery service builds these blocks dynamically based on the alert type and context data.

Key Takeaways

  1. Bot tokens don’t expire. Unlike HubSpot’s 6-hour tokens, Slack tokens are permanent until revoked.
  2. Event subscriptions control which notifications go to Slack. Real-time alerts bypass this check (they’re filtered by trigger rules).
  3. Rate limit handling uses Retry-After header with a single retry. Failed deliveries don’t block the user’s in-app experience.
  4. Block Kit provides rich message formatting. Each alert type maps to a specific block layout.

Next chapter: Groq — how LLM calls are made, cached, and rate-limited.

Last updated on