Skip to content

Testing a Clerk webhook locally without a tunnel round trip

Sign the payload yourself and POST it at localhost. You get replays, retry ids and bad-signature cases in one second instead of thirty.

Clerk3 min readships at docs/solutions/clerk/testing-clerk-webhooks-locally.md

Tags: clerk · webhooks · svix · local-development · testing

The documented way to develop a webhook is: start a tunnel, paste the public URL into the vendor dashboard, trigger a real event, watch the delivery log. It works, and it costs about thirty seconds per attempt. Worse, the two cases you most need to test are awkward or impossible that way: replaying the same delivery id, and sending a deliberately invalid signature.

There is a faster loop, and it exercises the same verification code.

Sign the payload yourself

Svix signatures are not exotic. The signature is an HMAC-SHA256 over ${svix-id}.${svix-timestamp}.${body}, keyed with the signing secret (which is base64 after the whsec_ prefix) and sent as v1,<base64 signature>.

import { createHmac } from "node:crypto";

const secret = process.env.CLERK_WEBHOOK_SIGNING_SECRET!;   // whsec_...
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");

const id = "msg_local_1";
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify({ type: "user.created", data: { /* ... */ } });

const signature = createHmac("sha256", key)
  .update(`${id}.${timestamp}.${body}`)
  .digest("base64");

await fetch("http://localhost:3000/api/webhooks/clerk", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "svix-id": id,
    "svix-timestamp": timestamp,
    "svix-signature": `v1,${signature}`,
  },
  body,
});

The body string must be the same bytes you signed: that is why it is built once into a variable and passed to both. Rebuilding it with a second JSON.stringify usually produces identical output and will one day not.

This repo ships that as a script:

bun run clerk:webhook:send                                   # user.created
bun scripts/clerk-webhook.ts user.updated --id msg_retry_1
bun scripts/clerk-webhook.ts user.created --tamper       # expects a 400

Arguments go on the script rather than on the package script: one of the three package managers needs a -- in front of them and the other two do not.

The four cases worth automating

Happy path. 200, and the row exists with the right values.

Retry. Send the same event twice with the same --id. Still one row, both responses 200. This is the case a dashboard cannot easily give you, and it is the one that finds missing unique constraints.

Out of order. Send user.updated with a recent updated_at, then user.created with an older one. The newer data must survive. This finds a missing staleness check, which otherwise shows up in production as a user's name mysteriously reverting.

Bad signature. Flip one byte of the body after signing. The handler must answer 400 and must not touch the database. If it returns 200, verification is happening after the write, or not at all.

What this does not cover

The local script proves your handler is correct. It does not prove the delivery path is correct. Before you ship, do one real round trip:

  1. Start a tunnel: bun run clerk:tunnel gives a public HTTPS URL for localhost:3000.
  2. In the Clerk dashboard under Webhooks, add an endpoint at <tunnel-url>/api/webhooks/clerk, subscribed to user.created, user.updated and user.deleted.
  3. Copy that endpoint's signing secret into CLERK_WEBHOOK_SIGNING_SECRET. Each endpoint has its own: local, staging and production are three different secrets, and using the wrong one produces a 400 that looks exactly like a forgery.
  4. Sign up a throwaway user in the app and watch the delivery arrive.

The dashboard's delivery log has a Replay button, which is the quickest way to re-run a failed delivery after a fix.

Deliveries you did not expect

Subscribe to what you handle, and handle what you subscribe to. Selecting "all events" in the dashboard sends session, organisation and email events at your user-sync handler; if the default branch of your switch returns anything other than 200, Svix will retry every one of them for hours.

Return 200 for an event type you deliberately ignore. It was handled: handling it correctly meant doing nothing.

Keep the secret out of the repo

The signing secret belongs in .env.local, which is gitignored, and in the hosting provider's environment. .env.example gets whsec_replace_me. A signing secret in a commit is a write credential for your users table: anyone who has it can forge deliveries. If it leaks, rotate it in the dashboard, which invalidates the old one immediately.

Checking your work

  • All four local cases behave as described above.
  • One real delivery from the dashboard arrives and stores.
  • The production endpoint's secret is different from the local one.
  • git grep whsec_ returns only the placeholder in .env.example.