Webhook Debugging Guide
Verifying Slack request signatures (X-Slack-Signature, v0=)
Short answer
Every request Slack sends your app, Events API, slash commands and interactive payloads alike, carries two headers:
X-Slack-Request-Timestamp: 1759089600
X-Slack-Signature: v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503
The signature is an HMAC-SHA256, hex encoded, of the string v0:<timestamp>:<raw request body>, keyed with your app's Signing Secret. To verify: check the timestamp is within five minutes of now, build the same string from the raw body bytes, compute the HMAC, prefix it with v0=, and compare it to the header in constant time. Three parts of that sentence are where implementations go wrong: which secret, which body, and which units.
Which secret
The Signing Secret is under your app's Basic Information page, in App Credentials. It is not the Verification Token on the same page (deprecated, and not what the signature uses), and not a bot token starting with xoxb-. If you have a development app and a production app, they have different signing secrets, and pointing a dev tunnel at the prod app's events with the dev secret loaded fails every request with no other clue.
Node (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET;
// Raw body for every content type: Slack sends JSON for events and
// application/x-www-form-urlencoded for slash commands and interactivity.
app.post('/slack/events', express.raw({ type: '*/*' }), (req, res) => {
const timestamp = req.headers['x-slack-request-timestamp'];
const signature = req.headers['x-slack-signature'];
if (!timestamp || !signature) return res.status(400).send('missing Slack headers');
// Slack's timestamp is in seconds; Date.now() is milliseconds.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > 60 * 5) return res.status(400).send('stale timestamp');
const base = `v0:${timestamp}:${req.body.toString('utf8')}`;
const expected = 'v0=' + crypto.createHmac('sha256', SIGNING_SECRET).update(base).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(String(signature));
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature');
}
// Only now parse the body
const isJson = (req.headers['content-type'] || '').startsWith('application/json');
const body = isJson
? JSON.parse(req.body.toString('utf8'))
: Object.fromEntries(new URLSearchParams(req.body.toString('utf8')));
if (body.type === 'url_verification') return res.status(200).send(body.challenge);
res.sendStatus(200);
});
Slack signs the url_verification request too, so it's fine to verify before answering the challenge. Just make sure the verification code runs on the raw body, which is why the route uses express.raw for all content types rather than express.json().
Python
import hashlib
import hmac
import time
def slack_signature_is_valid(signing_secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
if abs(time.time() - int(timestamp)) > 60 * 5:
return False
base = f"v0:{timestamp}:".encode() + raw_body
expected = "v0=" + hmac.new(signing_secret.encode(), base, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Flask: read the raw bytes BEFORE touching request.form or request.json
raw = request.get_data()
ok = slack_signature_is_valid(
os.environ["SLACK_SIGNING_SECRET"],
request.headers["X-Slack-Request-Timestamp"],
request.headers["X-Slack-Signature"],
raw,
)
In Flask, request.get_data() returns the raw bytes only if nothing has consumed the stream yet. Reading request.form first parses the body, and a later get_data() returns something you can't sign. In FastAPI, await request.body() does the same job.
Why it fails even with the right secret
- The body was parsed and re-encoded. The most common cause. A global JSON parser re-serializes events; a form parser re-encodes slash-command bodies, and
%2Fbecoming/or keys changing order is enough to change the HMAC. Always sign the bytes that came off the wire. - Milliseconds vs seconds. Slack's timestamp is Unix seconds.
Date.now()is milliseconds. Compare without dividing by 1000 and every request looks decades stale, so the five-minute check rejects everything. - The
v0=prefix. The header includes it. Compare the whole string, or strip it from both sides, but not just one. - Comparing with
==. Functionally it works. It leaks timing, which is why every example above uses a constant-time compare. Do both lengths match before callingtimingSafeEqual, which throws on different lengths. - Replaying a saved request. The timestamp inside the signature is the original one. Five minutes later the same bytes fail on the age check, not the HMAC. Slack's own retries (
X-Slack-Retry-Num) are signed fresh, so they pass; a request you saved and re-sent is not. See the replay guide. - A proxy that rewrote the body. Some tunnels and logging layers pretty-print JSON or add a trailing newline. If
Content-Lengthfrom Slack differs from the length of the body you received, something in between changed it.
The easier way: WebhookMon
WebhookMon checks X-Slack-Signature against your signing secret the moment an event arrives at the relay, and says in a sentence whether it was the secret, a stale timestamp or a malformed header, so you can rule out Slack's side before touching your handler. The url_verification challenge is answered at the edge, events queue while your Mac is off, and Replay re-signs a saved event with a fresh timestamp so it passes your five-minute check.