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 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.

WebhookMon's Signature tab for a Slack message event with a valid signature: the X-Slack-Signature value, its timestamp one second before arrival within the 5-minute tolerance, and the body size
Download WebhookMon free trial Learn more

Related guides