NewLocalCan 3.0 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. That works (a free ngrok account now includes one assigned dev domain, so the address itself no longer churns every session), but the free tier is metered at 20k requests and 1GB a month across at most three online endpoints, which a chatty user.* subscription burns through faster than you would expect.

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.

What makes the address expensive to change is everything pinned to it: the endpoint, its subscribed event list, and its whsec_ are all properties of the URL you registered. Move the URL and you re-create all three. A fixed LocalCan URL means you do that once, and a custom domain means you never do it again.

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 request

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.

A replayed capture verifies for five minutes and then stops. Svix signs svix-timestamp into the digest and enforces a five-minute tolerance, so Replay is the right tool while you iterate on a handler and the wrong one for something captured an hour ago. That fails as too old even with the correct secret. For an older event, use Replay in the Dashboard’s Message attempts table, which redelivers it freshly signed.

LocalCan vs Clerk CLI

Clerk CLI 2.0 added a genuine local webhooks toolkit: clerk webhooks listen --forward-to opens a relay tunnel and forwards deliveries to your handler, --token pins a URL you can reuse across restarts, and clerk webhooks verify checks a signature offline with no network call at all. Plenty of guides, including Clerk’s own syncing docs, still tell you to bring an external tunnel. The honest split: use the Clerk CLI when your whole loop is Clerk; reach for LocalCan when one stable URL has to serve Clerk and everything else, or when you want a GUI inspector and replay the team can use.

LocalCanClerk CLI
Stable public URLPersistent …localcan.dev, survives restarts--token pins a reusable relay URL
Forward without a public URLPublic URL (also reachable by others)clerk webhooks listen --forward-to, no URL needed
Works across providersOne URL for Clerk, Stripe, GitHub…Clerk only
Fire a test eventUse the Dashboard Testing tabSend Example from the Testing tab
Verify the signatureSurfaces headers + raw body for your verifierverifyWebhook(), plus clerk webhooks verify offline
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 + password or secret linkDashboard 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.