bolt-events-ts
purpose
Events API patterns for Slack Bolt.js — app.event() registration, event type payloads, retry handling, built-in event middleware (ignoreSelf, directMention), and event-vs-message handler selection.
rules
- Use
app.event(eventType, handler)for all non-message events. Register handlers with the event type string (e.g.,"reaction_added","member_joined_channel","app_home_opened"). Bolt routes events by matchingevent.type. slack.dev/bolt-js/concepts/event-listening - Use
app.message()for message events, notapp.event("message").app.message()provides text pattern matching (string/RegExp),say(), and subtype-aware filtering.app.event("message")works but lacks these conveniences. Bolt throws an error if you pass"message"with subtype patterns toapp.event(). slack.dev/bolt-js/concepts/message-listening - Events do NOT have
ack(). Unlike commands, actions, and views, event handlers receive noackfunction. Bolt automatically acknowledges event webhooks with HTTP 200 before your handler runs. slack.dev/bolt-js/concepts/event-listening say()is only available for events with channel context. Events likeapp_mention,message, andmember_joined_channelinclude a channel, sosay()works. Events liketeam_joinorapp_home_openedmay not — useclient.chat.postMessage()with an explicit channel. slack.dev/bolt-js/concepts/event-listeningignoreSelfis enabled by default. Bolt filters out events generated by your own bot (matchingbot_idfor messages,userfor other events). This prevents infinite loops. Disable withignoreSelf: falsein App constructor if you need to process your own events. slack.dev/bolt-js/concepts/event-listening- Use
context.retryNumandcontext.retryReasonto detect retries. Slack retries event delivery if your server doesn't respond with 200 in time. Checkcontext.retryNumto skip duplicate processing or log retry attempts. api.slack.com/events-api#retries - Event type can be matched with RegExp. Pass a RegExp to
app.event()for pattern matching:app.event(/^member_/, handler)matches bothmember_joined_channelandmember_left_channel. Matches are stored incontext.matches. slack.dev/bolt-js/concepts/event-listening - Subscribe to events in the Slack app dashboard.
app.event()in code does nothing if the event type isn't enabled in "Event Subscriptions" in your app's configuration. Both code and dashboard must agree. api.slack.com/events-api#subscriptions - The
bodyproperty contains the full event envelope. It includesteam_id,api_app_id,event_id,event_time, andauthorizations. Theeventproperty is just the inner event payload. Usebodywhen you need workspace-level metadata. api.slack.com/events-api#event_type_structure - URL verification is handled automatically by Bolt receivers. Both
HTTPReceiverandExpressReceiverrespond to{ type: "url_verification" }challenges without any handler code. You don't need to implement this yourself. slack.dev/bolt-js/concepts/event-listening
patterns
Common event handlers
import { App } from "@slack/bolt";
const app = new App({
token: process.env.SLACK_BOT_TOKEN!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
// React to app mentions
app.event("app_mention", async ({ event, say }) => {
await say(`Hey <@${event.user}>, you mentioned me!`);
});
// Welcome new team members
app.event("team_join", async ({ event, client }) => {
// team_join has no channel context — use client directly
await client.chat.postMessage({
channel: "#general",
text: `Welcome to the team, <@${event.user.id}>! :wave:`,
});
});
// Track reactions
app.event("reaction_added", async ({ event, client }) => {
if (event.reaction === "white_check_mark" && event.item.type === "message") {
await client.chat.postMessage({
channel: event.item.channel,
thread_ts: event.item.ts,
text: `<@${event.user}> marked this as done :white_check_mark:`,
});
}
});
// Update App Home when opened
app.event("app_home_opened", async ({ event, client }) => {
if (event.tab !== "home") return;
await client.views.publish({
user_id: event.user,
view: {
type: "home",
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: `*Welcome home, <@${event.user}>!*` },
},
{ type: "divider" },
{
type: "section",
text: { type: "mrkdwn", text: "Here's what you can do..." },
},
],
},
});
});
// Channel membership changes (RegExp pattern)
app.event(/^member_(joined|left)_channel$/, async ({ event, say, context }) => {
const action = context.matches?.[1]; // "joined" or "left"
await say(`<@${event.user}> ${action} the channel.`);
});Retry-aware event handler
app.event("app_mention", async ({ event, say, context }) => {
// Skip retries to avoid duplicate processing
if (context.retryNum !== undefined) {
console.log(`Skipping retry #${context.retryNum}: ${context.retryReason}`);
return;
}
// Process normally
await say(`Got your mention, <@${event.user}>!`);
});Event handler with listener middleware
import { App, directMention } from "@slack/bolt";
const app = new App({
token: process.env.SLACK_BOT_TOKEN!,
signingSecret: process.env.SLACK_SIGNING_SECRET!,
});
// Only fires when message starts with @bot mention
app.message(directMention(), async ({ message, say }) => {
await say(`You said: ${(message as any).text}`);
});
// Custom listener middleware: only process DMs
const onlyDMs = async ({ event, next }: { event: any; next: () => Promise<void> }) => {
if (event.channel_type === "im") {
await next();
}
};
app.event("message", onlyDMs, async ({ event, say }) => {
await say?.("Got your DM!");
});pitfalls
- Not subscribing in the dashboard:
app.event("reaction_added")in code does nothing ifreaction_addedisn't enabled in "Event Subscriptions" in your Slack app settings. You'll see no errors — events just don't arrive. - Using
say()in events without channel context:team_join,tokens_revoked,app_uninstalled, and some other events don't have a channel. Callingsay()throws "channel not found". Useclient.chat.postMessage()with an explicit channel. - Infinite loops from bot's own events: If you disable
ignoreSelfand your handler posts a message that triggers the same event, you get an infinite loop. Always checkevent.bot_idorevent.useragainst your bot's identity whenignoreSelfis off. - Processing retries as new events: Slack retries after ~10 seconds, ~1 minute, and ~5 minutes if your server doesn't respond with 200 quickly. Without checking
context.retryNum, you'll process the same event multiple times. Use idempotency keys or skip retries. - Confusing
eventandbody: Theeventproperty is the inner event payload. Thebodyproperty wraps it with envelope metadata (team_id,event_id, etc.). Useeventfor the event data andbodywhen you need workspace context. - Missing scopes for events: Each event type requires specific OAuth scopes. For example,
channels:historyfor public channel messages,im:historyfor DMs,reactions:readfor reactions. Missing scopes cause silent failure — no events arrive.
references
- https://api.slack.com/events-api
- https://api.slack.com/events
- https://slack.dev/bolt-js/concepts/event-listening
- https://slack.dev/bolt-js/concepts/message-listening
- https://api.slack.com/scopes
- https://github.com/slackapi/bolt-js
instructions
This expert covers the Slack Events API integration in Bolt.js TypeScript. Use it when you need to: handle non-message events with app.event(); understand event payloads and the event envelope structure; implement retry-aware handlers; use built-in middleware like ignoreSelf and directMention; build App Home views with app_home_opened; track reactions, team joins, and channel membership changes; and understand the relationship between event subscriptions in the dashboard and handler registration in code. Pair with runtime.bolt-foundations-ts.md for general handler context and runtime.ack-rules-ts.md to understand why events don't have ack().
research
Deep Research prompt:
"Write a micro expert on the Slack Events API in Bolt.js TypeScript. Cover: app.event() registration (string and RegExp), common event types (app_mention, reaction_added, team_join, app_home_opened, member_joined/left_channel), event payload shapes, retry handling (context.retryNum, context.retryReason), built-in middleware (ignoreSelf, directMention), say() availability per event type, event envelope (body) vs inner event, URL verification auto-handling, and dashboard subscription requirements. Provide 2-3 canonical TypeScript examples."