NewLocalCan 3.0 Beta with CLI, Multi-region and more ⟶

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.

Start Free Trial →DownloadFree trial. No credit card required.

30-second setup

From a stuck handler to a live Clerk delivery you can read and replay.

  1. 1Start LocalCan and point it at your local server: localcan http 3000. It opens the tunnel and prints your .local domains plus a public HTTPS URL.
  2. 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.
  3. 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 as CLERK_WEBHOOK_SIGNING_SECRET.
  4. 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.
  5. 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.created
  • user.updated
  • user.deleted
  • session.created
  • organization.created
  • organizationMembership.created
  • email.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} — the svix-id header, a literal dot, the svix-timestamp header, a literal dot, then the exact bytes of the request body. The HMAC-SHA256 key is NOT the whsec_ string itself: strip the whsec_ prefix and base64-decode the rest to get the key bytes. The result is base64-encoded and compared against each space-separated v1,<sig> entry in svix-signature (strip the v1, 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 recommends CLERK_WEBHOOK_SIGNING_SECRET. Despite the prefix it is a Svix base64 secret — verifyWebhook() and the svix library base64-decode it for you; only hand-rolled HMAC has to do it manually.
Node.js
// 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.

LocalCanClerk tooling
Stable public URLPersistent …localcan.dev, survives restartsNo first-party tunnel; docs suggest ngrok
Forwarding tool from the providerExternal tunnel (this is the tunnel)None — bring your own tunnel
Works across providersOne URL for Clerk, Stripe, GitHub…n/a — no forwarding tool
Fire a test eventUse the Dashboard Testing tabSend Example from the Testing tab
Verify the signatureSurfaces headers + raw body for your verifierverifyWebhook() handles the Svix scheme
Inspect full payloadsGUI inspector, headers + body, on by defaultMessage attempts table in the Dashboard
Replay a deliveryOne-click Replay of the exact requestMessage attempts → row menu → Replay
Share with a teammateShareable URL + basic authDashboard 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.