Receiving Slack Events
Slack shows up in two transports, and picking the right one matters. This guide covers the Events API — Slack's asynchronous server-to-server notifications, received over the webhook transport. If you want a real-time conversational bot instead, that's the chat transport, not this.
What we're building: when someone posts in a channel our bot is in, our
slack flow runs an ingestMessage action. We answer Slack's one-time URL
verification handshake, verify every signature, and read the event type out of
Slack's nested payload.
Events API vs the chat transport
The chat transport wraps a Slack bot for live conversation: mentions, slash commands, reactions, replies streamed back into the thread. It's bidirectional.
The Events API is one-directional. Slack POSTs your endpoint when something happens — a message, a reaction, a member joining — and expects a fast 2xx. No reply channel; the webhook transport returns a 202 and the action runs in the background. Use it when you're ingesting Slack activity into your backend, not holding a conversation.
1. The URL verification handshake
Before Slack sends real events, it posts a one-time challenge:
{ "type": "url_verification", "challenge": "3eZbrw1a..." }
Slack expects the challenge string echoed straight back. The provider's
acknowledge hook handles this: return the challenge and the adapter responds
200 with it and skips dispatch. Return null for everything else and normal
routing proceeds.
2. The host verifies and answers the handshake
import { createFlowState, inMemoryStores } from "@flow-state-dev/server";
import {
createWebhookTransportAdapter,
slackWebhookVerifier,
} from "@flow-state-dev/server";
import { slackFlow } from "@/flows/slack";
interface SlackEnvelope {
type?: string; // "url_verification" | "event_callback"
challenge?: string; // present on the handshake
event_id?: string; // stable per-delivery id
event?: { type?: string }; // "message" | "reaction_added" | ...
}
export const flowstate = createFlowState({
flows: { slackFlow },
stores: { default: { primary: inMemoryStores() } },
adapters: [
createWebhookTransportAdapter({
providers: {
slack: {
verify: slackWebhookVerifier(() => process.env.SLACK_SIGNING_SECRET!),
// Answer the one-time url_verification challenge, skip dispatch.
acknowledge: (e) => {
const p = e.payload as SlackEnvelope;
return p.type === "url_verification" ? p.challenge ?? null : null;
},
// Event type lives nested under event.type for real events.
eventType: (payload) => {
const p = payload as SlackEnvelope;
return p.event?.type ?? p.type ?? null;
},
deliveryId: (payload) => (payload as SlackEnvelope).event_id,
},
},
}),
],
});
slackWebhookVerifier implements Slack's signing scheme: it signs
v0:<timestamp>:<rawBody>, with the timestamp coming from the
X-Slack-Request-Timestamp header and the signature from X-Slack-Signature,
and rejects stale requests (default five-minute window). The signing secret is
the one from your Slack app's Basic Information page, not a bot token.
import { flowstate } from "@/lib/flowstate";
import { createVercelNextHandler } from "@flow-state-dev/vercel/next";
export const { GET, POST, PATCH, DELETE } = createVercelNextHandler(flowstate);
3. The flow declares routing
The on keys match the nested event.type the host extracts.
import { defineFlow, defineWebhookBinding, sequencer } from "@flow-state-dev/core";
interface SlackEventCallback {
event: {
type: string;
text?: string;
user?: string;
channel?: string;
ts?: string;
};
}
export const slackFlow = defineFlow({
kind: "slack",
authentication: { defaultUserId: "slack-bot", requireUser: false },
webhooks: {
slack: {
on: {
message: defineWebhookBinding<SlackEventCallback>({
block: ingestPipeline,
// Ignore the bot's own messages to avoid a loop.
when: (e) => e.payload.event.user !== undefined,
input: (e) => ({
text: e.payload.event.text ?? "",
user: e.payload.event.user,
channel: e.payload.event.channel,
}),
sessionId: (e) => `channel-${e.payload.event.channel}`,
}),
},
},
},
});
4. Point Slack at the endpoint
In your Slack app's Event Subscriptions settings:
- Turn events on and set the Request URL to
https://your-app.example.com/api/flows/slack/webhooks/slack. - Slack immediately posts the
url_verificationchallenge. Theacknowledgehook answers it; the URL shows as Verified. - Under Subscribe to bot events, add
message.channels(and any others you want), then reinstall the app.
Slack is strict about the response budget — three seconds — and retries on a
miss. Because the action runs after the 202, the ack returns fast no matter how
long ingestion takes. Slack stamps each delivery with event_id; wiring
deliveryId puts it on metadata.webhook so a retried delivery can be deduped
in your action.
What you have
- A verified Slack Events endpoint that auto-answers the URL handshake.
messageevents routed toingestMessage, the bot's own messages filtered out, keyed per channel.- The nested event type handled by the host's
eventTypeextractor.
For the full surface — and when to use the chat transport instead — see the webhook receivers reference.