Adding a test endpoint
Create a URL above, then open Clerk Dashboard → Configure → Webhooks → Add Endpoint. Paste the URL, choose the events you want, and save. Clerk shows the Signing Secret for that endpoint — a whsec_ string — on the endpoint’s page.
Nothing is delivered on creation. To produce an event, either sign up a user in your development instance, or use the endpoint’s Testing tab, which sends a synthetic payload of the type you pick with a genuine signature. That second option is what you want while you are getting verification working: it is repeatable and it does not litter your user list.
svix-id: msg_2fakeMessageIdExample
svix-timestamp: 1750000000
svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
All three are load-bearing. svix-id identifies the message and is stable across retries — it is your idempotency key. svix-timestamp is a Unix timestamp. And both of them, not just the body, go into the string that gets signed.
The sample above shows the same headers without the svix- prefix — webhook-id, webhook-timestamp, webhook-signature — because that is what the Standard Webhooks specification calls them, and what newer implementations such as Supabase send. Clerk uses the prefixed names. The verifier here accepts either spelling, and so should yours.
Verifying the signature, step by step
This scheme has more moving parts than most, and each step has a common failure mode.
1. Build the signed content. Three values joined by literal dots:
msg_2fakeMessageIdExample.1750000000.{"type":"user.created","timestamp":"2026-06-15T10:04:00Z",...}
2. Derive the key. Take the secret, drop the whsec_ prefix, and base64-decode the remainder into bytes:
const key = base64Decode(secret.replace(/^whsec_/, ''));
This is the step that catches everyone. The secret is a base64 encoding of the key, not the key itself. Signing with the ASCII string produces a valid-looking digest that simply never matches.
3. HMAC-SHA256 and base64-encode. Compare against each v1, entry in the header, splitting on spaces. Multiple entries appear during a rotation; accept if any matches, in constant time.
4. Check the timestamp. Standard Webhooks recommends a five-minute tolerance in either direction. The signature does not expire on its own.
In the Signature tab above, choose Svix / Standard Webhooks and paste the whsec_ secret — the decoding and the dot-joining are done for you, so a pass here and a failure in your code narrows the problem to those two steps.
The race nobody expects
The usual plan is: user signs up, user.created fires, your handler writes a row, the user lands on the dashboard. In practice those last three happen at the same time, and the browser often wins. The user’s first authenticated request arrives before the row exists, and your app 500s on a new signup — intermittently, and never in local testing where the webhook cannot reach you at all.
The robust shape is to make row creation idempotent and reachable from both directions:
async function ensureUser(clerkId: string) {
return db.users.upsert({ where: { clerkId }, create: { clerkId }, update: {} });
}
Call it from the webhook and from your session middleware. The webhook then becomes what it should be — a way to receive profile updates and deletions — rather than a prerequisite for the app working at all.
Reading the payload
{
"type": "user.created",
"data": {
"id": "user_2fakeUserId",
"email_addresses": [{ "email_address": "buyer@example.com", "verification": { "status": "verified" } }],
"first_name": "Ada"
}
}
email_addresses is an array, and the primary one is identified by primary_email_address_id on the user rather than by position. Taking email_addresses[0] works until a user adds a second address and it does not.
public_metadata, private_metadata, and unsafe_metadata all come through. Only public_metadata is readable from the frontend; unsafe_metadata is writable by the user, so never trust it for anything that grants access.