Test Slack webhooks without deploying
Capture Events API callbacks, slash commands and interactive payloads, and verify the v0 X-Slack-Signature.
Get a URL that accepts the webhook#
A capture URL takes any method, any body, and records exactly what arrived: method, headers, query string, body and source IP. You do not need an account to make one, and an anonymous URL is deleted after 24 hours.
curl -X POST https://mocklane.com/api/v1/anon/endpoints \
-H "Content-Type: application/json" -d '{}'
# The response carries a capture URL of the form:
# https://mocklane.com/h/<endpoint-id>Paste that URL into Slack at api.slack.com app config, Event Subscriptions, Request URL, then trigger something. Events worth starting with:
message.channelsapp_mentionreaction_addedteam_join
Verify the X-Slack-Signature header#
Slack signs each request so you can tell a real delivery from anyone who has guessed your URL. The digest is HMAC-SHA256 over the literal "v0", the timestamp and the raw body, joined with colons, hex-encoded, keyed with your signing secret.
- Header
X-Slack-Signature- Timestamp header
X-Slack-Request-Timestamp- Value format
v0=<hex digest>- Algorithm
- HMAC-SHA256, hex
- Signed content
- the literal "v0", the timestamp and the raw body, joined with colons
- Replay window
- 5 minutes (300 seconds), per Slack's own guidance
const crypto = require("crypto");
const ts = req.headers["x-slack-request-timestamp"];
// Reject old requests before doing any crypto. This is the replay defence.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return reject();
const base = `v0:${ts}:${rawBody}`;
const expected =
"v0=" +
crypto
.createHmac("sha256", process.env.SLACK_SIGNING_SECRET)
.update(base)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(req.headers["x-slack-signature"]),
);crypto.timingSafeEqual. A plain === returns as soon as two bytes differ, and that timing difference is enough to recover a valid signature one byte at a time.What catches people out#
- The signed string is not the body alone. It is v0, the timestamp and the body joined with colons, and the v0 appears twice: once inside the string and once as the prefix on the header value.
- The timestamp check is part of the scheme, not optional hardening. Without it a captured request stays valid forever.
- Slash commands and interactive payloads arrive form-encoded rather than as JSON, but the signature is still over the raw body exactly as sent.
- Slack expects a response within 3 seconds, so verify and acknowledge before doing any real work.
Replay it until your handler is right#
A captured request can be re-sent to your own endpoint, with the body or headers edited first. That turns a bug that needed a real Slack event into something you can reproduce on demand, which matters most for events that are awkward to trigger twice.
Assert on it in CI#
A scenario chains the steps into one run that passes or fails, so a change that breaks your Slack handling fails the build rather than reaching production.
steps:
- type: send_webhook
body: { event: "message.channels" }
- type: wait_for_webhook
timeout_seconds: 30
assert:
- status_code equals 200Triggered from CI with an API token. See the scenarios documentation for the full step list.
Signature details checked against Slack’s documentation on 22 October 2026. Schemes change: if this page and their docs disagree, believe theirs and tell us.