Skip to main content

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.ts verifies the svix signature and 400s on missing headers / bad signature; clerk-sync.service.ts handles 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 ≠ .env CLERK_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 typeMinimal 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.updatedsame 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).