Mirroring Clerk events into the local DB
Scope: getting a Clerk event into your local database during a testing session — path 1: live relay delivery; path 2: signed-payload replay with no relay. Not the one-time Svix endpoint setup (webhook-relay), not what each event writes (auth-clerk).
Source-checked against optolink-backend @ 29f8589, 2026-10-06 (
clerk-webhook.controller.tsverifies the svix signature and 400s on missing headers / bad signature;clerk-sync.service.tshandles exactly the 8 event types). Relay output shapes and CLI behavior are recorded 2026-09-13; re-verify live on next use — they live in the Clerk CLI and Svix, not this repo.
Path 1 — live relay delivery (preferred)
Use for a real Clerk event flowing into the local DB (signup, onboarding, team invite/accept, org edits). One-time setup and the pinned token are on the relay page; per session:
cd optolink-backend
npx nest start & # wait for "listening on port 3000"
clerk webhooks listen --token c_Nvj2aA97D0 \
--forward-to http://localhost:3000/webhooks/clerk --json # keep running
Then do anything in Clerk (create a user/org, accept invite, edit metadata).
Reading the relay's --json lines:
forward_status:200→ server accepted it (signature verified ✓)forward_status:400→ signature mismatch → endpoint secret ≠.envCLERK_WEBHOOK_SECRET. Fix, restart server.- no line appears → endpoint URL doesn't point at the pinned relay token, relay isn't running, or the event type isn't subscribed. Check the Svix dashboard (relay page checklist).
Events are async — after a Clerk write, allow ~1–5s for delivery before asserting DB state.
Path 2 — signed-payload replay (fallback, no relay)
Use when you can't/don't want to run the relay but need an event mirrored.
Posts a svix-signed payload directly to /webhooks/clerk — the same
verify() code path as a real delivery, without Clerk's transport. Good for
seeding a specific DB state fast.
# sign + post any event; $1 = event type, $2 = JSON data
node -e '
const { Webhook } = require("svix"); const http = require("http");
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET);
const evt = { type: process.argv[1], object: "event", data: JSON.parse(process.argv[2]) };
const payload = JSON.stringify(evt); const mid = "msg_"+Date.now(); const ts = new Date();
const sig = wh.sign(mid, ts, payload);
const req = http.request({ hostname:"localhost", port:3000, path:"/webhooks/clerk", method:"POST",
headers:{ "Content-Type":"application/json","svix-id":mid,
"svix-timestamp":String(Math.floor(ts.getTime()/1000)),"svix-signature":sig } },
r=>{ let b=""; r.on("data",d=>b+=d); r.on("end",()=>console.log(r.statusCode,b)); });
req.write(payload); req.end();' "organization.created" '{"id":"org_xxx","name":"RT"}'
# expect: 200 {"received":true}
For multi-event flows (org + user + membership), chain the same pattern with
each event's minimal data shape:
| Event type | Minimal data shape |
|---|---|
organization.created / .updated | { id, name } |
organization.deleted | { id } |
user.created / .updated | { id, email_addresses: [{id, email_address}], primary_email_address_id, first_name, last_name } |
organizationMembership.created | { organization: {id, name}, public_user_data: {user_id, identifier}, public_metadata: {role} } — identifier (the email) is required; without it the membership replay 500s |
organizationMembership.updated | same as .created |
organizationMembership.deleted | { organization: {id}, public_user_data: {user_id} } |
Replay is a transport shortcut; prefer Path 1 when you need to prove Clerk itself emits/delivers the event. What each event writes to the DB is on auth-clerk.
Sanity probes when the endpoint misbehaves
# Missing svix headers → 400
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST localhost:3000/webhooks/clerk \
-H 'Content-Type: application/json' \
-d '{"type":"organization.created"}'
# Tampered payload → 400
# Wrong/mismatched CLERK_WEBHOOK_SECRET → 400
Unit coverage for these behaviors: clerk-webhook.controller.spec.ts
(supertest with createNestApplication({ rawBody: true }): valid → 200,
tampered → 400, wrong secret → 400, missing headers → 400, empty body → 400)
and clerk-sync.service.spec.ts (every event type, role mapping, email
fallback, upsert-on-duplicate, unknown-event ignore, orgKey collision retry).