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
- Bot tokens don’t expire. Unlike HubSpot’s 6-hour tokens, Slack tokens are permanent until revoked.
- Event subscriptions control which notifications go to Slack. Real-time alerts bypass this check (they’re filtered by trigger rules).
- Rate limit handling uses
Retry-Afterheader with a single retry. Failed deliveries don’t block the user’s in-app experience. - 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.