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:
- Start a tunnel:
bun run clerk:tunnelgives a public HTTPS URL forlocalhost:3000. - In the Clerk dashboard under Webhooks, add an endpoint at
<tunnel-url>/api/webhooks/clerk, subscribed touser.created,user.updatedanduser.deleted. - 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. - 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.