Capturing the handshake
Create a URL above, then open api.slack.com/apps → your app → Event Subscriptions and paste it into Request URL. Slack immediately POSTs the handshake:
{"token":"...","challenge":"3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P","type":"url_verification"}
It will appear in the list here within a second — and Slack will then show a red “Your URL didn’t respond with the value of the challenge parameter” error, because a capture bin returns its own acknowledgement rather than your challenge string. That is the expected outcome, and it is still useful: it proves Slack can reach the URL, and it shows you the exact shape you have to answer.
The handler Slack is waiting for is three lines:
if (body.type === 'url_verification') {
return new Response(body.challenge, { headers: { 'content-type': 'text/plain' } });
}
Once your own endpoint does that, point Slack at it and use this page for the deliveries afterwards.
Verifying the v0 signature
Two headers arrive together:
X-Slack-Request-Timestamp: 1750000000
X-Slack-Signature: v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503
The signed string is built from three pieces joined by colons:
v0:1750000000:{"token":"...","challenge":"...","type":"url_verification"}
HMAC-SHA256 that with your signing secret, hex-encode it, prefix v0=, and compare in constant time against the header. In the Signature tab above, pick Slack and paste the secret; the timestamp comes from the captured header.
The timestamp check is not optional
Slack’s signature contains a timestamp but does not expire. The expiry is your job:
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 60 * 5) return new Response('stale', { status: 400 });
Do this before the HMAC, not after. It is cheaper and it closes the replay window that the signature alone leaves open.
Designing for three seconds
Slack’s deadline shapes the whole architecture of a Slack app. There is no way to make a three-second budget cover a database write, an LLM call, and a chat.postMessage. The pattern that works:
- Verify the signature and the timestamp.
- Return
200 with an empty body.
- Do the work in a queue, a background task, or a waitUntil-style continuation.
- Reply through
response_url (valid for 30 minutes) or chat.postMessage.
For slash commands you can return a short acknowledgement message in step 2 and replace it later — that is what response_type: "ephemeral" plus a follow-up to response_url is for.
Reading event bodies
Real events arrive wrapped, not bare:
{
"type": "event_callback",
"team_id": "T0000000000",
"api_app_id": "A0000000000",
"event_id": "Ev0000000000",
"event_time": 1750000000,
"event": { "type": "app_mention", "user": "U0000", "text": "<@U0BOT> deploy", "channel": "C0000", "ts": "1750000000.000100" }
}
The outer type is always event_callback; the one you switch on is event.type. event_id is your idempotency key, and event.ts is both the message timestamp and its identifier — you need it to reply in a thread.
One thing that catches every new Slack bot: your own bot’s messages are events too. Check event.bot_id and return early, or the first thing your bot says will trigger it again.