Webhook Debugging Guide

Verifying Svix webhooks (svix-id, svix-timestamp, svix-signature)

Short answer

Svix sends webhooks on behalf of other services: Clerk says it uses Svix, and Resend's verification docs use the same headers. Every request carries three of them:

svix-id: msg_p5jXN8AQM9LWM0D4loKWxJek
svix-timestamp: 1614265330
svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

The signature is an HMAC-SHA256, base64 encoded, of {svix-id}.{svix-timestamp}.{raw body}. The key is your signing secret with the whsec_ prefix removed and the rest base64-decoded. To verify: check the timestamp is recent, compute the HMAC over the raw bytes, and compare it in constant time to each v1, entry in svix-signature. The svix package does all of that in one call, and it's the right choice unless you have a reason not to add the dependency.

With the svix library (Node)

Express, with the raw body kept for the webhook route:

import express from 'express';
import { Webhook } from 'svix';

const app = express();
const wh = new Webhook(process.env.WEBHOOK_SECRET);

app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    wh.verify(req.body, req.headers);
  } catch (err) {
    return res.status(400).send(`invalid webhook: ${err.message}`);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  console.log(event.type);
  res.sendStatus(204);
});

app.listen(3000);

Next.js App Router: read the body with req.text(), and pass the headers as a plain object.

import { Webhook } from 'svix';

export async function POST(req) {
  const payload = await req.text();
  const headers = {
    'svix-id': req.headers.get('svix-id') ?? '',
    'svix-timestamp': req.headers.get('svix-timestamp') ?? '',
    'svix-signature': req.headers.get('svix-signature') ?? '',
  };

  try {
    new Webhook(process.env.WEBHOOK_SECRET).verify(payload, headers);
  } catch (err) {
    return new Response(`invalid webhook: ${err.message}`, { status: 400 });
  }

  const event = JSON.parse(payload);
  console.log(event.type);
  return new Response(null, { status: 204 });
}

Don't pass req.headers from a Fetch API Request straight to verify(). It's a Headers instance, the library reads it as an empty object, and every request fails with Missing required headers. Build a plain object as above, or use Object.fromEntries(req.headers). Express's req.headers is already a plain object with lowercase names, so it works as-is.

In the current svix package (2.x), verify() returns nothing and throws on failure. The 1.x versions returned the parsed payload. Parsing the body yourself after verify(), as above, works with both.

With the svix library (Python)

import json
import os

from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
wh = Webhook(os.environ["WEBHOOK_SECRET"])


@app.post("/webhooks")
def webhooks():
    payload = request.get_data()
    try:
        wh.verify(payload, request.headers)
    except WebhookVerificationError as e:
        return f"invalid webhook: {e}", 400

    event = json.loads(payload)
    print(event["type"])
    return "", 204

The Python verify() lowercases header names itself, so Flask's request.headers works directly. In FastAPI, use payload = await request.body() and pass request.headers the same way.

Manual verification

If you'd rather not add the package, here's the same check by hand. Both versions were tested against the example from Svix's docs and against signatures generated by the svix library.

import crypto from 'node:crypto';

const TOLERANCE_SECONDS = 5 * 60;

// headers: lowercase header names; rawBody: Buffer of the exact request body
export function verifySvix(secret, headers, rawBody, now = Date.now()) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatures = headers['svix-signature'];
  if (!id || !timestamp || !signatures) throw new Error('missing svix headers');

  const ts = Number(timestamp);
  if (!Number.isInteger(ts)) throw new Error('bad svix-timestamp');
  if (Math.abs(now / 1000 - ts) > TOLERANCE_SECONDS) throw new Error('timestamp outside tolerance');

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const signedContent = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
  const expected = crypto.createHmac('sha256', key).update(signedContent).digest();

  for (const entry of signatures.split(' ')) {
    const [version, sig] = entry.split(',');
    if (version !== 'v1' || !sig) continue;
    const given = Buffer.from(sig, 'base64');
    if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) return;
  }
  throw new Error('no matching signature');
}
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_svix(secret: str, headers, raw_body: bytes, now: float | None = None) -> None:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signatures = headers.get("svix-signature")
    if not msg_id or not timestamp or not signatures:
        raise ValueError("missing svix headers")

    now = time.time() if now is None else now
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("timestamp outside tolerance")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(key, signed_content, hashlib.sha256).digest()

    for entry in signatures.split(" "):
        version, _, sig = entry.partition(",")
        if version != "v1" or not sig:
            continue
        if hmac.compare_digest(base64.b64decode(sig), expected):
            return
    raise ValueError("no matching signature")

Three details that are easy to get wrong when doing this by hand:

What the errors mean

Clerk, Resend and Standard Webhooks

You don't have to call Svix directly if the provider's SDK wraps it. Clerk's docs use verifyWebhook() from @clerk/nextjs/webhooks. Resend's SDK has resend.webhooks.verify(), which takes the payload, the three svix- header values and your webhook secret, and Resend's docs show the svix package as an alternative. Resend's method still needs the raw payload, not a parsed object.

Svix's docs also note that some senders use webhook-id, webhook-timestamp and webhook-signature instead of the svix- names. That's the Standard Webhooks spec, which signs the same way. The svix library accepts either set of names, and the Polar guide covers that variant.

Where WebhookMon fits

WebhookMon doesn't have a built-in Svix signature check: its verdicts cover Stripe, Polar, GitHub, Shopify and Slack, plus any sender using the Standard Webhooks webhook- headers. A Clerk or Resend endpoint still works with it for everything else. Point the provider at WebhookMon's relay URL and each event shows up with its exact body and all three svix- headers, which is the quickest way to see whether the bytes your handler hashed are the bytes that were sent. It forwards events to your local server, and replays them as captured. Because a replay keeps the original svix-timestamp, a replay more than five minutes after the event fails with Message timestamp too old unless you loosen the check in development. See the replay guide for why. If you want a Svix verdict, ask for it; requests decide what gets built next.

WebhookMon's Headers tab for a captured event, listing each request header name and value exactly as received, next to an event list with signature and local response columns
Download WebhookMon free trial Learn more

Related guides