Test Clerk webhooks without deploying
Capture user and organisation lifecycle events from Clerk, which delivers through Svix and signs with svix- headers.
Get a URL that accepts the webhook#
A capture URL takes any method, any body, and records exactly what arrived: method, headers, query string, body and source IP. You do not need an account to make one, and an anonymous URL is deleted after 24 hours.
curl -X POST https://mocklane.com/api/v1/anon/endpoints \
-H "Content-Type: application/json" -d '{}'
# The response carries a capture URL of the form:
# https://mocklane.com/h/<endpoint-id>Paste that URL into Clerk at Clerk dashboard, Webhooks, Add Endpoint, then trigger something. Events worth starting with:
user.createduser.updatedsession.createdorganization.created
Verify the svix-signature header#
Clerk signs each request so you can tell a real delivery from anyone who has guessed your URL. The digest is HMAC-SHA256 over the message id, the timestamp and the raw body, joined with full stops, base64-encoded, keyed with your signing secret (starts with whsec_).
- Header
svix-signature- Timestamp header
svix-timestamp- Value format
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=- Algorithm
- HMAC-SHA256, base64
- Signed content
- the message id, the timestamp and the raw body, joined with full stops
const crypto = require("crypto");
// Svix signs "{id}.{timestamp}.{rawBody}".
const signed = `${req.headers["svix-id"]}.${req.headers["svix-timestamp"]}.${rawBody}`;
// The key is the DECODED secret, not the whsec_ string itself.
const key = Buffer.from(process.env.CLERK_WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(signed).digest("base64");
// svix-signature can carry several space-separated "v1,<sig>" values during
// a secret rotation. Any one of them matching is a pass.
const ok = req.headers["svix-signature"]
.split(" ")
.some((part) => {
const sig = part.split(",")[1];
return sig && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
});crypto.timingSafeEqual. A plain === returns as soon as two bytes differ, and that timing difference is enough to recover a valid signature one byte at a time.What catches people out#
- The signing secret is prefixed with whsec_. The key is the base64 decode of everything after that prefix, not the literal string, which is the step most hand-written Svix verifiers miss.
- Three headers are involved, not one: svix-id, svix-timestamp and svix-signature. All three are part of the signed string.
- Clerk's own verifyWebhook helper does all of this. Hand-rolling it is only worth doing if you cannot add the dependency.
- On paid Svix plans the headers can be switched to a webhook- prefix, so do not hardcode svix- without checking.
Replay it until your handler is right#
A captured request can be re-sent to your own endpoint, with the body or headers edited first. That turns a bug that needed a real Clerk event into something you can reproduce on demand, which matters most for events that are awkward to trigger twice.
Assert on it in CI#
A scenario chains the steps into one run that passes or fails, so a change that breaks your Clerk handling fails the build rather than reaching production.
steps:
- type: send_webhook
body: { event: "user.created" }
- type: wait_for_webhook
timeout_seconds: 30
assert:
- status_code equals 200Triggered from CI with an API token. See the scenarios documentation for the full step list.
Signature details checked against Clerk’s documentation on 22 October 2026. Schemes change: if this page and their docs disagree, believe theirs and tell us.