Test Clerk Webhooks Locally
Clerk signs webhooks with Svix, so your handler 400s on a signature mismatch even when the secret looks right — usually the parsed body or HMAC-ing the literal whsec_ string. Get a stable URL Clerk can reach, see the exact svix-signature and raw body it sent, and replay the delivery until verification passes.
30-second setup
From a stuck handler to a live Clerk delivery you can read and replay.
- 1Start LocalCan and point it at your local server:
localcan http 3000. It opens the tunnel and prints your.localdomains plus a public HTTPS URL. - 2Grab your persistent URL — something like
your-app-12.localcan.dev. It is saved to your project file and comes back identical after every restart, so you wire it up once. - 3In the Clerk Dashboard go to Webhooks → Add Endpoint and paste your LocalCan URL with your route, e.g.
https://your-app-12.localcan.dev/api/webhooks. Subscribe to the events you need (user.created,session.created, …), then open the endpoint and reveal its Signing Secret (whsec_…) — Clerk recommends storing it asCLERK_WEBHOOK_SIGNING_SECRET. - 4On the endpoint, open the Testing tab, pick an event in the Select event dropdown (e.g.
user.created) and click Send Example to fire a synthetic payload. For a real delivery that failed, expand it in the Message attempts table to read the response code, then use the row menu → Replay to resend that exact message. - 5Open Inspect Traffic in LocalCan. The delivery lands with full headers and body — expand it, fix your handler, and hit Replay to send the exact same request again. No new test event needed.
Why localhost can't receive Clerk webhooks
Clerk dispatches webhooks from its servers (via Svix) to a public HTTPS URL whenever something happens in your instance — a user signs up, a session starts, an org membership changes. localhost:3000 only exists on your machine, so Clerk has nothing to connect to and every attempt lands in the Message attempts table as a failed delivery.
You need a public URL that forwards to your local handler — and one that does not change on restart. Clerk’s own syncing guide reaches for ngrok here, but a free ngrok subdomain rotates every session, so you end up re-pasting the new URL into the Dashboard endpoint and re-revealing the signing secret every morning.
Just want to see what Clerk sends first? Peek at the raw payload on webhook.cool, then point the endpoint at LocalCan to forward it into localhost.
What makes the Clerk loop fast
Register the endpoint once
Your …localcan.dev URL survives restarts and sleeps, so the endpoint you add in the Clerk Dashboard keeps receiving user.*, session.* and organization.* events tomorrow without a single edit.
Clerk’s docs default to ngrok, whose free subdomain changes on every restart — so you re-paste the URL and re-reveal the whsec_ each session. A fixed LocalCan URL means the endpoint, the subscribed events, and the signing secret all stay valid.
See the raw svix-signature and body
Svix verification fails on the smallest mismatch, and the error never tells you which half is wrong. The inspector shows all three svix-id, svix-timestamp and svix-signature headers plus the untouched raw body — exactly the four inputs verifyWebhook() hashes.
Now you can tell a parsed-and-re-stringified body from a drifted clock from HMAC-ing the literal whsec_ string, instead of staring at a generic 400.
Replay the captured delivery
Hit Replay to re-send the identical captured event — same svix-id, same body bytes — so you iterate on your handler against the real payload instead of clicking Send Example in the Dashboard for every attempt.
One caveat unique to Svix: the 5-minute svix-timestamp tolerance means a delivery you captured an hour ago will be rejected as too old. Replay while you iterate, or fire a fresh Send Example.
Inspect & replay Clerk events
Common events
user.createduser.updateduser.deletedsession.createdorganization.createdorganizationMembership.createdemail.created
These are the events most Clerk apps wire up first — user.created/user.updated/user.deleted to mirror users into your own database, session.created for login side effects, and organization.created / organizationMembership.created for B2B tenanting. Fire one with Send Example from the Testing tab, watch it land in Inspect Traffic with its full Svix headers and JSON body, and expand it to read the data object and type.
When your handler throws, fix the code and hit Replay — LocalCan re-sends the exact same event, so you test against the real payload, not a hand-rolled fixture. The Clerk-specific caveat: because Svix enforces a 5-minute svix-timestamp tolerance, a replay of something captured long ago will fail verification as too old even though the secret is correct — replay promptly, or send a fresh example.
Verify the Clerk signature
- Header
svix-signature- Algorithm
- HMAC-SHA256, base64-encoded (space-delimited
v1,<sig>list) - What's signed
- Clerk (via Svix) signs the string
{svix-id}.{svix-timestamp}.{raw body}— thesvix-idheader, a literal dot, thesvix-timestampheader, a literal dot, then the exact bytes of the request body. The HMAC-SHA256 key is NOT thewhsec_string itself: strip thewhsec_prefix and base64-decode the rest to get the key bytes. The result is base64-encoded and compared against each space-separatedv1,<sig>entry insvix-signature(strip thev1,prefix; at least one must match). - Secret
- The endpoint Signing Secret (
whsec_…) from Clerk Dashboard → Webhooks → your endpoint → Signing Secret (click the eye icon). Clerk recommendsCLERK_WEBHOOK_SIGNING_SECRET. Despite the prefix it is a Svix base64 secret —verifyWebhook()and thesvixlibrary base64-decode it for you; only hand-rolled HMAC has to do it manually.
// Clerk's own scheme, by hand, so the verification is explicit.
// In production prefer verifyWebhook() from @clerk/nextjs/webhooks (or the svix
// lib) — it does the base64 secret decode, the v1, prefix strip and the
// 5-minute timestamp check for you.
import crypto from 'crypto'
function verifyClerkWebhook(rawBody, headers, signingSecret) {
const svixId = headers['svix-id']
const svixTimestamp = headers['svix-timestamp']
const svixSignature = headers['svix-signature']
if (!svixId || !svixTimestamp || !svixSignature) {
throw new Error('missing one of svix-id / svix-timestamp / svix-signature')
}
// 5-minute replay window (Svix default): reject stale or future timestamps.
const now = Math.floor(Date.now() / 1000)
const sent = parseInt(svixTimestamp, 10)
if (Math.abs(now - sent) > 60 * 5) {
throw new Error('svix-timestamp outside the 5-minute tolerance')
}
// whsec_ secret is base64 AFTER the prefix — decode it to raw key bytes.
const key = Buffer.from(signingSecret.replace(/^whsec_/, ''), 'base64')
// Sign "{svix-id}.{svix-timestamp}.{raw body}" — raw body, never re-stringified.
const signedContent = svixId + '.' + svixTimestamp + '.' + rawBody
const expected = crypto.createHmac('sha256', key).update(signedContent).digest('base64')
// svix-signature is a space-delimited list of "v1,<base64sig>" (key rotation).
// Strip the "v1," version prefix and match at least one in constant time.
const ok = svixSignature.split(' ').some((entry) => {
const sig = entry.split(',')[1]
if (!sig || sig.length !== expected.length) return false
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
})
if (!ok) throw new Error('no v1 signature matched the expected HMAC')
return JSON.parse(rawBody)
}
// Next.js App Router: read the RAW body, never the parsed JSON.
export async function POST(req) {
const rawBody = await req.text()
const headers = Object.fromEntries(req.headers)
try {
const evt = verifyClerkWebhook(rawBody, headers, process.env.CLERK_WEBHOOK_SIGNING_SECRET)
if (evt.type === 'user.created') {
// mirror the user into your DB
}
return new Response('ok', { status: 200 })
} catch (err) {
return new Response('Webhook Error: ' + err.message, { status: 400 })
}
}LocalCan does not verify the signature for you — verifyWebhook() (or the snippet above) does — but it hands you every input the hash depends on so a failure is obvious instead of silent. The inspector shows the real svix-id, svix-timestamp and svix-signature Clerk sent and the untouched raw body, so you can separate the three classic Svix failures in seconds: a parsed/re-stringified body, a drifted clock tripping the 5-minute tolerance, and HMAC-ing the literal whsec_ string instead of its base64-decoded bytes.
Then hit Replay to re-send the same captured request and confirm the fix — bearing in mind the timestamp tolerance, so replay promptly.
LocalCan vs Clerk tooling
Clerk ships no first-party local-forwarding CLI for webhooks — its docs point you at an external tunnel (ngrok, localtunnel, Cloudflare Tunnel, Pinggy) and give you verifyWebhook() plus the Dashboard Testing/Replay UI. The honest split: lean on the Dashboard’s Send Example and Replay when your whole loop is Clerk; reach for LocalCan when one stable URL has to serve Clerk and everything else, and you want a GUI inspector and replay the team can use.
| LocalCan | Clerk tooling | |
|---|---|---|
| Stable public URL | Persistent …localcan.dev, survives restarts | No first-party tunnel; docs suggest ngrok |
| Forwarding tool from the provider | External tunnel (this is the tunnel) | None — bring your own tunnel |
| Works across providers | One URL for Clerk, Stripe, GitHub… | n/a — no forwarding tool |
| Fire a test event | Use the Dashboard Testing tab | Send Example from the Testing tab |
| Verify the signature | Surfaces headers + raw body for your verifier | verifyWebhook() handles the Svix scheme |
| Inspect full payloads | GUI inspector, headers + body, on by default | Message attempts table in the Dashboard |
| Replay a delivery | One-click Replay of the exact request | Message attempts → row menu → Replay |
| Share with a teammate | Shareable URL + basic auth | Dashboard only |
Clerk webhook FAQ
Stop guessing why the webhook 401'd. Get a stable Clerk endpoint, read every delivery, and replay it until your handler is right.
Free trial. No credit card required.