Webhook Studio
/Clerk webhooks
Webhooks / Clerk

Clerk webhooks

Watch Clerk user and session events arrive, and verify Svix signatures correctly.

Get a test URL

Get a live Clerk webhook URL
No signup, no install. The URL works the moment you click, and every request Clerk sends is stored with its full headers and raw body.
Free, anonymous, and expires after 3 days.
Building this with an AI agent? Webhook Studio is also an MCP server, so your agent can provision this endpoint, wait for the Clerk event, replay it into localhost, and generate types from the real payload — keyless, no human in the browser. One-line install:
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

  1. Create a test endpoint below and copy its URL.
  2. In the Clerk Dashboard, open Configure → Webhooks → Add Endpoint.
  3. Paste the Webhook Studio URL and subscribe to the events you want (start with user.created).
  4. Copy the signing secret Clerk shows — it starts with whsec_.
  5. Use the Dashboard's Testing tab to send a sample event, and watch it land in the Inbox.
Nothing to install. Webhook Studio sits between Clerk and your app. Every delivery is stored and inspectable, and routing rules forward the ones you care about to your real backend — with retries, and without losing the request history when your backend is down.

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
  "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

HeaderWhat it carries
svix-signatureSpace-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-idUnique delivery id, stable across retries. The correct idempotency key, and part of the signed string.
svix-timestampUnix seconds. Also part of the signed string, and what you compare against your clock to bound replay attacks.
User-AgentSvix-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.

EventFires when
user.createdA user signs up. The event to provision your own user row on.
user.updatedA user's profile, email, or metadata changes.
user.deletedA user is deleted. Arrives with a deleted flag rather than a full object.
session.createdA user signs in and a session begins.
session.endedA session ends normally.
session.revokedA session is revoked, by the user or from the Dashboard.
organization.createdAn organization is created.
organizationMembership.createdA user joins an organization — the B2B seat-assignment hook.
organizationInvitation.acceptedAn invited user accepts and joins.
email.createdClerk 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.

Node.js (Express)
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

Using the whsec_ secret as a raw string

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".

Signing the body alone

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.

Taking only the first signature

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.

Assuming user.created arrives before the user hits your app

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

How do I test Clerk webhooks without deploying?

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.

Why does my Clerk webhook signature verification fail?

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.

Why did I get two signatures in one svix-signature header?

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.

How long does Clerk retry a failed webhook?

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.

Can I forward Clerk webhooks to localhost?

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.