Clerk webhooks
Watch Clerk user and session events arrive, and verify Svix signatures correctly.
Get a test URL
claude mcp add --transport http webhook-studio https://webhook-studio.com/mcp \
--header "Authorization: Bearer $(curl -s -X POST https://webhook-studio.com/api/start | jq -r .api_token)"Point Clerk at this URL
- Create a test endpoint below and copy its URL.
- In the Clerk Dashboard, open Configure → Webhooks → Add Endpoint.
- Paste the Webhook Studio URL and subscribe to the events you want (start with user.created).
- Copy the signing secret Clerk shows — it starts with whsec_.
- Use the Dashboard's Testing tab to send a sample event, and watch it land in the Inbox.
What a Clerk delivery looks like
A representative body, using Clerk’s field names. Your own deliveries appear in the Inbox exactly as sent, headers and raw bytes included.
{
"type": "user.created",
"object": "event",
"timestamp": 1721558400000,
"data": {
"id": "user_2abcDEF456ghiJKL789mnoPQR",
"object": "user",
"username": null,
"first_name": "Alex",
"last_name": "Rivera",
"image_url": "https://img.clerk.com/preview.png",
"has_image": false,
"primary_email_address_id": "idn_2abcDEF456ghiJKL789mno",
"email_addresses": [
{
"id": "idn_2abcDEF456ghiJKL789mno",
"object": "email_address",
"email_address": "alex@example.com",
"verification": {
"status": "verified",
"strategy": "email_code"
}
}
],
"public_metadata": {},
"private_metadata": {},
"unsafe_metadata": {},
"created_at": 1721558397000,
"updated_at": 1721558397000
}
}Delivery headers
| Header | What it carries |
|---|---|
svix-signature | Space-delimited list of versioned signatures, each "v1,<base64>". More than one appears during a secret rotation — treat it as match-any, not match-all. |
svix-id | Unique delivery id, stable across retries. The correct idempotency key, and part of the signed string. |
svix-timestamp | Unix seconds. Also part of the signed string, and what you compare against your clock to bound replay attacks. |
User-Agent | Svix-Webhooks/<version> — Clerk delivers through Svix, so the UA identifies Svix rather than Clerk. |
Event types
The events worth handling first. See Clerk’s own documentation for the complete list.
| Event | Fires when |
|---|---|
user.created | A user signs up. The event to provision your own user row on. |
user.updated | A user's profile, email, or metadata changes. |
user.deleted | A user is deleted. Arrives with a deleted flag rather than a full object. |
session.created | A user signs in and a session begins. |
session.ended | A session ends normally. |
session.revoked | A session is revoked, by the user or from the Dashboard. |
organization.created | An organization is created. |
organizationMembership.created | A user joins an organization — the B2B seat-assignment hook. |
organizationInvitation.accepted | An invited user accepts and joins. |
email.created | Clerk queues an email, letting you mirror or suppress its own transactional mail. |
Verify the svix-signature header
Clerk signs each delivery with HMAC-SHA256 over the raw request body, keyed on your webhook secret. Verify before you parse, and before you act.
import express from 'express';
import crypto from 'crypto';
const app = express();
app.post('/webhooks/clerk', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.get('svix-id');
const ts = req.get('svix-timestamp');
const header = req.get('svix-signature') ?? '';
// Reject stale deliveries — the timestamp is signed, so this is meaningful.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
// The secret is whsec_<base64>. Unlike Stripe's identically prefixed
// secret, the bytes after the prefix are base64-DECODED to make the key.
// Using the string as-is is the single most common Clerk verification bug.
const key = Buffer.from(process.env.CLERK_WEBHOOK_SECRET.split('_')[1], 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${ts}.${req.body}`) // id + timestamp + raw body
.digest('base64');
// During a rotation Svix sends old and new together; any match is valid.
const ok = header.split(' ').some((part) => {
const sig = part.startsWith('v1,') ? part.slice(3) : null;
if (!sig) return false;
const a = Buffer.from(expected, 'base64');
const b = Buffer.from(sig, 'base64');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) return res.sendStatus(401);
res.sendStatus(200);
const event = JSON.parse(req.body);
// if (event.type === 'user.created') provision(event.data.id)
});Retries and delivery guarantees
Clerk delivers through Svix, which retries on this schedule: immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours again — 8 attempts over roughly 32 hours. Success is any 2xx within about 15 seconds. If every delivery to an endpoint fails over a 5-day window, Svix disables the endpoint and notifies you. Delivery is at-least-once and unordered, so deduplicate on svix-id.
Common pitfalls
The key is the base64-decoded bytes after the whsec_ prefix. Stripe's secret carries the same prefix and is used verbatim, so the habit ports over and silently breaks everything: every signature mismatches with no other symptom. This is the first thing to check when Clerk "won't verify".
The signed string is `${svix-id}.${svix-timestamp}.${body}`, not the body. A verifier that HMACs only the payload — the shape most other providers use — fails on every delivery.
svix-signature can carry several space-delimited signatures while a secret is being rotated. Code that splits and takes [0] works until the day you rotate, then rejects live traffic. Accept if any entry matches.
The webhook and the browser redirect race, and the redirect often wins. Treat your user row as create-if-missing rather than assuming the webhook populated it first.
FAQ
Create a test endpoint on this page, paste its URL into Clerk's Dashboard under Configure → Webhooks, and use the Testing tab to send a sample event. The full request — svix headers, raw body and signature — appears in the Inbox immediately, with no code deployed and no account required.
Nearly always the secret. Clerk's whsec_ value must be base64-decoded after the prefix to form the HMAC key, whereas Stripe's identically prefixed secret is used as-is. The other common cause is signing only the body: Svix signs svix-id, svix-timestamp and the body joined by full stops.
You are mid-rotation. Svix signs with the old and new secret simultaneously so neither side has to cut over atomically. Verification should pass if any of the space-delimited entries matches.
8 attempts over about 32 hours — immediately, 5s, 5m, 30m, 2h, 5h, 10h, 10h. If everything to an endpoint fails for 5 days, Svix disables it. Because delivery is at-least-once and unordered, deduplicate on svix-id.
Yes. Add a routing rule pointing at your local tunnel and matching events are forwarded with retries while still being stored here — so you keep the full history of user.created events even across restarts of your dev server.