Slack signs every request it sends to your app (Events API, slash commands, interactivity). It builds the base string v0:{X-Slack-Request-Timestamp}:{raw request body}, runs HMAC-SHA256 over it with your app's Signing Secret, hex-encodes the result and sends it as X-Slack-Signature: v0=<hex>. Your check fails whenever any of those three inputs differs by a single byte.
application/x-www-form-urlencoded; events are JSON. If express.json(), express.urlencoded() or your framework parses the body first and you re-serialize it, the bytes change. Hash the raw bytes exactly as received.xoxb-) won't work.v0= prefix or wrong encoding. The header is v0= plus a lowercase hex digest, not base64.X-Slack-Retry-Num and X-Slack-Retry-Reason. Acknowledge first and do slow work afterwards.const crypto = require('crypto');
app.post('/slack/events', express.raw({ type: '*/*' }), (req, res) => {
const ts = req.get('X-Slack-Request-Timestamp') || '';
const sig = req.get('X-Slack-Signature') || '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
const base = `v0:${ts}:${req.body.toString('utf8')}`; // req.body is a Buffer here
const expected = 'v0=' + crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET).update(base).digest('hex');
const a = Buffer.from(expected), b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const body = req.is('application/json') ? JSON.parse(req.body) : Object.fromEntries(new URLSearchParams(req.body.toString()));
if (body.type === 'url_verification') return res.json({ challenge: body.challenge });
res.sendStatus(200); // ack within 3 s, then do the work
});
With Bolt for JavaScript, pass signingSecret to new App() and don't add your own body parser on the same routes; Bolt verifies for you.
import hmac, hashlib, os, time
from flask import request, abort
def verify_slack():
ts = request.headers.get('X-Slack-Request-Timestamp', '')
sig = request.headers.get('X-Slack-Signature', '')
if not ts.isdigit() or abs(time.time() - int(ts)) > 300: abort(401)
raw = request.get_data() # raw bytes, read before request.form
base = b'v0:' + ts.encode() + b':' + raw
expected = 'v0=' + hmac.new(os.environ['SLACK_SIGNING_SECRET'].encode(), base, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig): abort(401)
The official SDK has the same check: from slack_sdk.signature import SignatureVerifier, then SignatureVerifier(secret).is_valid_request(request.get_data(), request.headers). In FastAPI use await request.body() for the raw bytes.
The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. Its built-in schemes cover Stripe, Lemon Squeezy, Shopify, Paddle Billing, Square, Twilio (status callbacks) and GitHub. Slack isn't one of them, so for Slack you would add a small verifier like the one above.
Fix Your Stripe Webhooks guide ($9) · Self-Hosted Webhook Relay Kit ($19)
Free download: Webhook debugging cheat sheet.
Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint. More fixes: all webhook error pages.