Your Next.js route calls verifyWebhook(req), Clerk's dashboard shows the
delivery as failed (or your logs show Webhook verification failed), and the
event never reaches your database sync. Clerk delivers webhooks through
Svix, so "verification failed" almost always means one of five concrete
things went wrong with the Svix signature check. Here's how the signature actually works,
the five causes in rough order of likelihood, and how to isolate which one you have in
about a minute.
Every delivery carries three headers:
svix-id — unique message ID (stays the same across retries of the same event)svix-timestamp — Unix seconds at send timesvix-signature — a space-delimited list of signatures like v1,g0hM9SsE…=The signature is HMAC-SHA256 over the string
{svix-id}.{svix-timestamp}.{raw body}, base64-encoded. The key is
not the whsec_… string itself: it's the base64-decoded
bytes of the part after whsec_. Verifiers must strip the
v1, prefix, compare in constant time, and (in the official libraries) also
enforce a timestamp tolerance to block replays. Some Svix plans white-label the headers to
webhook-id / webhook-timestamp / webhook-signature —
same scheme, and it's also exactly what the Standard Webhooks spec uses, which is why the
identical check covers Resend, Railway, and a growing list of senders.
whsec_… value shown for that specific endpoint in the Clerk
Dashboard's Webhooks page. Using another endpoint's secret (dev vs prod is the classic),
a rotated/stale value, or Clerk's sk_… API key fails verification every time.
Check CLERK_WEBHOOK_SIGNING_SECRET is actually set in the environment your
app runs in — an undefined env var in production is cause #1.express.json() without a raw-body capture, a framework "helpfully" decoding
the body, hashing JSON.stringify(req.body) — key order or whitespace shifts
and the signature dies. In Next.js route handlers, verifyWebhook(req) reads
the raw body itself; don't consume req.json() before calling it.whsec_… in .env changes the key. Print
secret.length if in doubt.The fastest way to tell "wrong secret" from "body mangled by my framework" is to take your framework out of the loop. Hookden gives you a capture URL that verifies Svix signatures for you:
1. Create a bin → https://hookden.pages.dev 2. Bin settings → Signature verification → "Svix / Standard Webhooks" → paste the endpoint's whsec_… secret 3. Add the /h/… URL as a webhook endpoint in the Clerk Dashboard and send a test event
Every capture gets a ✓ / ✗ badge:
The capture also shows the exact svix-id / svix-timestamp /
svix-signature headers and raw body, so you can replay the same bytes against
your local verifier until it passes.
When your endpoint fails, Svix retries with backoff: immediately, then 5 seconds,
5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours again. Retries of the
same event reuse the same svix-id — handy for spotting duplicates, and it
means one broken deploy turns into a day of red entries in the dashboard. Deliveries you
missed entirely can be replayed from Clerk's dashboard once verification is fixed.
Clerk can't reach localhost:3000. Either run a tunnel, or skip the tunnel:
point the Clerk endpoint at a bin and relay captures to localhost —
the relay re-delivers the byte-identical body and the svix-* headers, so
verifyWebhook() passes against the relayed request exactly as if Svix had
called your machine directly.
Related: Stripe signature
verification failed (hex HMAC over t.body — similar shape, different
encoding) and webhooks to localhost without a
tunnel.
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new