Clerk webhooks: user.created events, Svix headers and verifyWebhook
To test Clerk webhooks, add a free webhooks.sh bin URL (no signup, 48 hours, 100 requests a day) as an endpoint and send user.created from the Testing tab; the JSON and svix headers show up live. Clerk delivers through Svix, which retries failed messages from 5 seconds to 10 hours apart.
Clerk webhooks are Svix messages: a JSON event body sent with svix-id, svix-timestamp and svix-signature headers. You can use the Testing tab in the Clerk Dashboard to send an example user.created event to a free bin so you see the exact request before writing a handler. This page covers setup, the payload and headers, verifyWebhook, retries and the usual errors.
Send user.created to a free bin
- Create a free bin in your browser at view.webhooks.page or run
curl -X POST https://webhooks.sh/v1/binsto get aningest_urland aview_url. - Open the Webhooks page in the Clerk Dashboard, add an endpoint using your bin's ingest URL (any path works, like
https://<bin>.webhooks.page/clerk), and subscribe touser.created. - Open your new endpoint's Testing tab, pick
user.created, and click Send Example. - Watch the request show up live in your bin viewer, while Message Attempts in Clerk marks the delivery as succeeded because the bin returned
200.
Clerk's own webhook debugging guide suggests using a hosted inspection service when localhost isn't reachable, and a free bin is just that.
# Create a free bin (or open view.webhooks.page)
curl -X POST https://webhooks.sh/v1/bins
# Endpoint URL for Clerk, e.g.
# https://<bin>.webhooks.page/clerk
What Clerk sends
The JSON body has data (which holds the object itself, like the user record on user events), object set to event, type for the event name such as user.created, timestamp in milliseconds, and instance_id.
Every delivery also carries three Svix headers. svix-id is the unique message ID and stays the same across retries, so use it as your deduplication key. svix-timestamp is the Unix timestamp in seconds, and svix-signature holds a space-separated list of v1,<base64> signatures. None of the three names contains a word the webhooks.sh viewer redacts, so all three show up in full.
| Header | Example | Meaning |
|---|---|---|
svix-id | <message id> | Message ID, the same when resent |
svix-timestamp | 1793000000 | Seconds since epoch |
svix-signature | v1,<base64> | Space-separated signatures |
{
"data": {
"id": "user_<id>",
"object": "user",
"first_name": "Example",
"email_addresses": [
{ "email_address": "example@example.org" }
]
},
"object": "event",
"type": "user.created",
"timestamp": 1793000000000,
"instance_id": "ins_<id>"
}
Verify with verifyWebhook
Import verifyWebhook from @clerk/backend/webhooks and pass it the incoming request. It checks the signature using Standard Webhooks and resolves to the verified, typed event, so you can branch on evt.type and read evt.data.
Each endpoint in the Clerk Dashboard has its own signing secret starting with whsec_. Clerk recommends storing it in the CLERK_WEBHOOK_SIGNING_SECRET environment variable rather than passing signingSecret in code. Wrap verifyWebhook in a try/catch block and return 400 when verification fails.
You also need to make the webhook route public in Clerk's middleware (middleware.ts in Next.js 15 and earlier, or proxy.ts in newer versions), or Clerk will block the incoming request with a 401. If you're verifying by hand instead of using the helper, compute the HMAC-SHA256 of <svix-id>.<svix-timestamp>.<raw body> keyed with the base64-decoded part of your secret after whsec_, base64-encode the result, and compare it against the v1 values in svix-signature.
import { verifyWebhook } from "@clerk/backend/webhooks";
export async function POST(req: Request) {
try {
const evt = await verifyWebhook(req);
if (evt.type === "user.created") {
console.log("new user", evt.data.id);
}
return new Response("ok", { status: 200 });
} catch (err) {
console.error("webhook verification failed", err);
return new Response("invalid webhook", { status: 400 });
}
}
Retries, replays and ordering
When your endpoint doesn't return a 2xx response, Svix retries on a fixed schedule, with each delay starting after the previous attempt fails.
Because those delays add up, a message that fails three times and succeeds on the fourth try arrives roughly 35 minutes and 5 seconds after the initial attempt. If every delivery to an endpoint fails for 5 days, Svix disables the endpoint. Once your handler is fixed, open the three-dot menu on the endpoint in Clerk to replay a single message, all failed messages since a date, or all missing messages.
Clerk's delivery is at least once and order isn't guaranteed. Dedupe incoming requests by svix-id, and compare data.updated_at (or the event timestamp) before writing to your database so an older user.updated event doesn't overwrite a newer one. See the webhook retries guide for more on handling retries.
A free bin answers 200 right away, so Clerk counts every test delivery as succeeded, but the bin expires and stops accepting requests after 48 hours, remove it from Clerk when you're done testing.
Here's the full Svix retry schedule:
| Attempt | Delay after the previous failure |
|---|---|
| 1 | Immediately |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
Fix the usual errors
When a delivery fails, check the status code under Message Attempts in the Clerk Dashboard:
400usually means signature verification failed in your handler.401means Clerk's middleware blocked the request because the webhook route isn't public.404means the endpoint URL is wrong or your app isn't running at that address.405means your route doesn't acceptPOSTrequests.500means the request reached your app but your code failed, often during verification or in the webhook logic.
Clerk can't reach localhost, so inspect payloads in a bin and relay them to your machine; if nothing shows up at all, see the webhook not received guide.
Keep the URL or move to localhost
To keep your test endpoint past 48 hours, claim the bin with a free Google sign-in before it expires. That stops the 48-hour timer and keeps new requests for 30 days at the exact same URL (up to 100 requests a day and 100KB bodies).
If you're ready to run the handler on your machine, the Solo plan ($12 a month, see pricing) gives you a persistent named endpoint on webhooks.host with 250,000 requests a month and the wsh CLI. You can run wsh listen <endpoint> --to localhost:3000 to test webhooks locally and replay captured deliveries without changing the endpoint URL registered in Clerk.
FAQ
How do I test Clerk webhooks locally?
Clerk can't send requests to localhost. Point the endpoint at a public URL like a free webhooks.sh bin to inspect payloads, then relay them to your local server with a tunnel or the wsh CLI.
What headers does a Clerk webhook have?
Every Clerk webhook request includes svix-id, svix-timestamp and svix-signature.
How do I verify a Clerk webhook?
Call verifyWebhook(request) from @clerk/backend/webhooks inside your route handler. It reads your endpoint's whsec_ secret from the CLERK_WEBHOOK_SIGNING_SECRET environment variable and returns the verified event payload.
Why does my Clerk webhook return 401?
Clerk's middleware is blocking the request before it reaches your handler. Make the webhook route public in middleware.ts (or proxy.ts in newer Next.js versions).
Does Clerk retry failed webhooks?
Yes. Svix retries failed deliveries starting immediately and backing off to 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, and disables the endpoint if all attempts fail for 5 days.
Can Clerk webhooks arrive twice or out of order?
Yes. Delivery is at least once with no ordering guarantee, so dedupe on svix-id and check data.updated_at before applying updates.
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.