Slack webhooks & Events API: testing guide

Slack webhooks go two ways: Incoming Webhooks (you POST messages to Slack) and the Events API (Slack POSTs to you). For events, answer the url_verification challenge, verify X-Slack-Signature (v0, HMAC-SHA256) and return HTTP 200 within 3 seconds.

"Slack webhooks" means two opposite things, and confusing them is the first way to waste an afternoon. Incoming Webhooks are URLs you POST to, to push messages into Slack. The Events API (plus slash commands and interactivity) is Slack POSTing to you. This guide covers both, with the emphasis on the second — that's the direction with a handshake, signatures, and a brutal response-time budget.

Direction one: Incoming Webhooks (you → Slack)

An Incoming Webhook is a URL like https://hooks.slack.com/services/T00000000/B00000000/XXXX, minted in your app's settings under Features → Incoming Webhooks. POST JSON to it — {"text": "hello"}, or a Block Kit payload — and the message appears in the channel the webhook is bound to:

curl -X POST -H "Content-Type: application/json" \
  -d '{"text":"deploy finished :rocket:"}' \
  https://hooks.slack.com/services/T000/B000/XXXX

There's no signature here — the URL itself is the credential, so treat it like a password. Testing this direction is easy: it's your outbound HTTP call, and any failure comes back synchronously as a non-200 response.

Direction two: Events API (Slack → you)

The Events API, slash commands, and interactive components (buttons, modals, shortcuts) all work the same way: Slack POSTs to a public Request URL you configure in the app settings (Event Subscriptions / Interactivity & Shortcuts / your slash command). One URL receives every subscribed event; routing by event type is your job.

To see what's actually arriving before you trust your handler, point the Request URL at a bin from view.webhooks.page — no signup, and you watch Slack's exact bytes land live. The bin answers the handshake below by echoing the challenge, so Slack activates the URL and every event after it lands in the bin too. To rehearse specific shapes, build them with our payload generator first.

The url_verification handshake

Slack won't activate a Request URL until it proves you control it. The first thing your endpoint ever receives is:

{"token": "…", "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", "type": "url_verification"}

Your handler must respond with the challenge value echoed back as plaintext. That's the whole ceremony — but your signature middleware runs first, so make sure verification passes on this payload too (it's signed like any other request) or the handshake 401s and the URL never activates.

Verify X-Slack-Signature

Every request from Slack — events, commands, interactivity — carries two headers: X-Slack-Request-Timestamp and X-Slack-Signature. The signed basestring is the version, timestamp, and raw body concatenated:

basestring = "v0:" + timestamp + ":" + rawBody
expected   = "v0=" + hexHMAC_SHA256(signingSecret, basestring)

The signing secret lives in your app's Basic Information page (App Credentials → Signing Secret) — not an OAuth token, not the verification token from the bad old days. Rules that keep you safe:

  • Check the timestamp first. Reject anything older than 5 minutes — that window is your replay-attack protection.
  • Sign the raw body. Not the parsed JSON, not the re-serialized form fields — the exact bytes Slack sent. Events arrive as application/json; slash commands and interactivity arrive as application/x-www-form-urlencoded (interactivity nests its JSON inside a payload form field). Either way, hash the undecoded body.
  • Compare constant-time. No === on strings.

Node/Express:

import crypto from "node:crypto";
import express from "express";

const app = express();

// Raw body for every content type — Slack signs the exact bytes it sent.
app.post("/slack/events", express.raw({ type: "*/*" }), (req, res) => {
  const ts = req.get("X-Slack-Request-Timestamp") ?? "";
  const theirSig = req.get("X-Slack-Signature") ?? "";

  const age = Math.abs(Date.now() / 1000 - Number(ts));
  if (age > 60 * 5) return res.sendStatus(401); // replay window

  const base = "v0:" + ts + ":" + req.body.toString("utf8");
  const expected =
    "v0=" +
    crypto.createHmac("sha256", process.env.SLACK_SIGNING_SECRET!).update(base).digest("hex");

  const a = Buffer.from(theirSig);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  const body = JSON.parse(req.body.toString("utf8"));
  if (body.type === "url_verification") return res.send(body.challenge);

  res.sendStatus(200); // ack inside 3 seconds — real work happens after
});

The 3-second ack rule

Slack gives your endpoint 3 seconds to return HTTP 200. Miss it and Slack counts the delivery failed and retries — while your slow handler was probably doing something expensive like calling an API or waiting on a cold start. The only architecture that survives: verify, ack, then process asynchronously (queue, background job, whatever fits). We walk through that pattern in testing webhooks locally. If you're building this from behind a tunnel, testing without ngrok covers the options.

Retries are visible: they carry X-Slack-Retry-Num (attempt counter) and X-Slack-Retry-Reason (e.g. http_timeout). Two uses: log them to catch slow-handler regressions, and treat retry deliveries as potential duplicates — dedupe on the event ID (event_id in the envelope). Persistently failing endpoints get their event delivery paused by Slack, so a flaky handler doesn't just drop events, it eventually stops receiving them.

Common pitfalls

  • Verifying the parsed body. Especially deadly for form-encoded commands: decoding then re-encoding never reproduces Slack's exact bytes. Hash first, parse after. (Same disease as Stripe and GitHub.)
  • Handshake behind signature middleware that consumes the body. A global JSON parser upstream of your route eats the raw stream; both verification and the challenge echo break.
  • Slow 200s. Three seconds including TLS and cold start. Measure from a bin, or post a generated payload at staging with curl before Slack does it for you.
  • Confusing the two directions. If you're staring at hooks.slack.com wondering where the signature header went — wrong direction; that's the URL you POST to.
  • Double-processing retries. No dedupe means a timed-out ack still runs your side effects, then the retry runs them again.

Slack's contract is tighter than most providers' — a handshake, a short replay window, a 3-second budget — but it's fully deterministic. Verify the bytes, ack fast, and the Events API is boring in the best way. (For chat-ops payloads going the other way, the Discord playbook covers a similar shape.)

Last updated

Try it in 10 seconds.

Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.