Process Slack webhooks and slash commands serverlessly
Slack requires a 3-second response to slash commands or marks your app as slow. Build a serverless handler that verifies HMAC, returns immediately, and posts the real response via response_url from an async pipeline, so slow work never races the response window.
Last updated: 2026-10-26
The Slack 3-second challenge
- Slash commands: Slack requires HTTP 200 within 3 seconds or marks app as unresponsive
- Event subscriptions: acknowledgement required within 3 seconds, processing can continue
- Interactive components (buttons, modals): same 3-second window
Most Slack operations—LLM calls, database queries, external API calls—take longer than 3 seconds. The standard pattern is to acknowledge immediately, do the work asynchronously, and post the result back via response_url or the Slack Web API.
Inline Slack processing is almost always too slow
Any external API call inside the synchronous Slack handler risks exceeding the 3-second window. LLM inference, database queries with joins, and third-party API calls all regularly take 2–10 seconds.
Without an async handoff, users see "This app took too long to respond" and the command appears to fail even if it eventually succeeds.
Verify fast, respond immediately, work async
The Slack handler verifies the HMAC signature, extracts slash command parameters, triggers an async pipeline, and returns HTTP 200 with an empty body within milliseconds.
The pipeline step does the real work—calls the LLM, queries the database, fetches external data—and posts the result to Slack via response_url or chat.postMessage.
Slack webhook handling features
HMAC-SHA256 request verification
Slack signs requests with HMAC-SHA256 over timestamp + raw body. Verify before processing any command.
3-second fast ACK
Return 200 immediately after verification. Never await slow work before responding.
response_url async response
Post the real result to response_url from the async pipeline step. Supports ephemeral and in_channel responses.
Slash command routing
Route different slash commands to different pipeline jobs based on the command field.
Retry deduplication
Slack may retry slash commands and Events API deliveries. Key idempotency on trigger_id or event_id before starting pipelines.
Slack slash command flow
Verify HMAC, parse command
Check x-slack-signature with HMAC-SHA256. Extract command, text, user_id, response_url from URL-encoded body.
Trigger async orchestration, return 200
Call global.durable.startNew with command params including response_url. Return empty 200 response immediately.
Post result via response_url
Pipeline step performs the real work, then POSTs a Block Kit response to response_url.
Slack slash command handler
Verify, ACK, trigger async pipeline—all within the 3-second window. The pipeline step does the work and posts back via response_url.
import { createHmac, timingSafeEqual } from 'node:crypto'; export async function handler(event) { const body = event.body ?? ''; const timestamp = event.headers['x-slack-request-timestamp'] ?? ''; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return { statusCode: 400, body: 'stale request' }; } const sigBase = `v0:${timestamp}:${body}`; const expected = 'v0=' + createHmac('sha256', process.env.SLACK_SIGNING_SECRET).update(sigBase).digest('hex'); const received = event.headers['x-slack-signature'] ?? ''; if (expected.length !== received.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(received))) { return { statusCode: 401, body: 'invalid signature' }; } const params = Object.fromEntries(new URLSearchParams(body)); await global.durable.startNew('slack-command', undefined, { command: params.command, text: params.text, userId: params.user_id, channelId: params.channel_id, responseUrl: params.response_url, }); return { statusCode: 200, body: '' }; }
export async function handler(event) { const { command, text, userId, responseUrl } = event.payload ?? {}; const result = await processCommand(command, text, userId); await fetch(responseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ response_type: 'in_channel', blocks: [{ type: 'section', text: { type: 'mrkdwn', text: result } }], }), }); return { command, userId, result }; }
Use this for Slack automation
When this works
- Slash commands that trigger LLM inference, database lookups, or external API calls
- Event subscriptions that feed data into pipelines or trigger notifications
When to skip it
- Simple Slack incoming webhooks that just POST messages—those do not require HMAC verification
FAQ
What about Slack event subscriptions?
Respond to the challenge value within 3 seconds during URL verification. For production events, acknowledge and delegate to async pipelines exactly as slash commands.
How do I handle Slack modal submissions?
Modal submissions (view_submission) work the same way: verify HMAC, return 200 immediately, process async via pipeline.
What if Slack retries the same slash command?
Use trigger_id (slash commands) or event_id (Events API) as an idempotency key—upsert before global.durable.startNew so retries return 200 without duplicating pipeline runs.