Clerk webhook relay
Scope: Clerk → localhost delivery for live testing: the pinned relay token, the Svix endpoint registration, the listen command, and how to prove events actually arrive. Not what the backend does with each event — that's auth-clerk.
Source-checked items against optolink-backend @ 29f8589, 2026-10-06. The token value, Svix dashboard state, and CLI behavior are recorded 2026-09-13; re-verify live on next use — they live in Clerk's/Svix's dashboard, not in this repo.
The pinned relay token (read first — the #1 gotcha)
clerk webhooks token is ephemeral: a different value on every call.
Run clerk webhooks listen --token "$(clerk webhooks token)" and you listen
on a throwaway token the registered Svix endpoint doesn't deliver to → no
events arrive and you'll wrongly conclude "webhooks are broken."
The Svix endpoint URL is pinned to one token — always pass this exact token:
RELAY_TOKEN=c_Nvj2aA97D0
# relay URL = https://webhooks.clerk.com/in/c_Nvj2aA97D0/
It's a reusable relay channel name, it doesn't expire. If delivery stops working, check the endpoint URL in the Svix dashboard first (did someone change it?), not the token command.
Incident lessons (2026-09-12, FLOW-011): the relay ran for a day with a
stray, mis-typed token (c_Nvj2aA97D0D0) — Clerk delivered into that token's
inbox while the listener that "looked running" was attached elsewhere; zero
events mirrored while relay and backend both looked healthy. Two rules came
out of it:
- The listener's
readyline proves only that a listener started — nothing about matching the registered endpoint URL. herdr/tmux scrollback keeps oldreadylines. After a restart, confirm a new event line, never a staleready. The__pinground-trip below is the fastest check.
(The CLI rejects malformed tokens — c_ + 10 base62 chars — which is how the
typo surfaced.)
One-time registration (Svix endpoint)
Required for any flow depending on Clerk → DB sync (signup, onboarding, team
invite/accept, org/user edits). The local rows are created only by the
webhook handler POST /webhooks/clerk — OrgResolutionPipe 403s when
User/Organization/OrgMember rows are missing.
APP_ID=app_3FUCVgyzycb2dhlYnQOTJwymdmz # from `clerk apps list`
# Ensure a Svix app exists for the instance (one per instance; idempotent)
clerk api "/webhooks/svix" -X POST --app $APP_ID --yes -d '{}'
# → "svix_app_exists" if already created (fine)
# Get a one-click Svix dashboard URL
clerk api "/webhooks/svix_url" -X POST --app $APP_ID --yes -d '{}'
# → { "svix_url": "https://app.svix.com/login?..." }
In the Svix dashboard → Add Endpoint:
- Endpoint URL:
https://webhooks.clerk.com/in/c_Nvj2aA97D0/(the pinned relay URL — exact token, no typo) - Events (exactly these 8 — the set
clerk-sync.service.tshandles):user.created,user.updated,organization.created,organization.updated,organization.deleted,organizationMembership.created,organizationMembership.updated,organizationMembership.deleted - Create → open the endpoint → copy its Signing Secret (
whsec_…) intooptolink-backend/.envasCLERK_WEBHOOK_SECRETand restart the server.
The secret must be valid base64 after whsec_ — svix base64-decodes it
(src/clerk/clerk-webhook.controller.ts uses the svix Webhook verifier;
CLERK_WEBHOOK_SECRET is a required var in src/config/env.validation.ts).
The placeholder whsec_dev_placeholder is NOT valid base64 and 400s every
live delivery.
Per testing session
Start the relay and keep it running (Clerk can't reach localhost):
clerk webhooks listen --token c_Nvj2aA97D0 \
--forward-to http://localhost:3000/webhooks/clerk --json
Prove the round-trip before trusting it (backend running):
node -e 'const{createClerkClient}=require("@clerk/backend");\
const c=createClerkClient({secretKey:process.env.CLERK_SECRET_KEY});\
c.users.updateUserMetadata("<user_id>",{publicMetadata:{__ping:""+Date.now()}})'
# listener (--json) shows {"type":"event","event_type":"user.updated","forward_status":200}
# backend log shows: POST /webhooks/clerk 200
Prerequisites checklist (verify if something breaks)
| Prerequisite | How to verify | Fix if missing |
|---|---|---|
| CLI linked to the backend's app | clerk whoami → appId matches the dev instance | clerk link |
| Svix app exists for the instance | clerk api /webhooks/svix -X POST --yes -d '{}' → svix_app_exists | same call creates it |
| Svix endpoint pinned to the relay token | clerk api /webhooks/svix_url -X POST --yes -d '{}' → open URL, check endpoint URL | edit endpoint URL to the pinned relay URL |
Endpoint signing secret == .env CLERK_WEBHOOK_SECRET | compare in Svix dashboard vs .env | copy endpoint secret into .env, restart server |
| Endpoint subscribed to the 8 events | Svix dashboard → endpoint → events | add the 8 (list above) |
org_metadata on default session token | a @Roles-gated call returns 200, not 403 | Clerk dev instance → session token |
Testing Mode on (OTP 424242) | only needed for UI/Playwright flows | Dashboard → Configure → Testing |
Clerk CLI quick reference
# identity & webhooks
clerk whoami
clerk api /webhooks/svix -X POST --yes -d '{}' # idempotent
clerk api /webhooks/svix_url -X POST --yes -d '{}' # short-lived URL
clerk webhooks listen --token c_Nvj2aA97D0 --forward-to http://localhost:3000/webhooks/clerk --json
clerk webhooks verify
# ⚠ `clerk webhooks token` → EPHEMERAL, do not use; reuse the pinned c_Nvj2aA97D0
# users / orgs / memberships (writes fire webhooks)
clerk users create --email X --password Y --json
clerk api /organizations -d '{"name":"N","created_by":"$U"}' --yes
clerk api /organizations/$O/memberships/$U/metadata -X PATCH -d '{"public_metadata":{"role":"owner"}}' --yes
clerk api /organizations/$O -X DELETE --yes
clerk api /users/$U -X DELETE --yes
# sessions + org-active token (real-token testing)
clerk api /sessions -d '{"user_id":"$U"}' --yes # → .id
clerk api /sessions/$SID/tokens -d '{"organization_id":"$O"}' --yes # → .jwt (~60s)
Recorded 2026-09-10 (clerk CLI 3.0.0):
POST /sessionsreturns a plain404 page not foundfrom the Clerk API (headerclerk-api-version: 2026-05-12) — GET routes still work. Until the CLI catches up, mint session tokens via the Backend API directly (POST https://api.clerk.com/v1/sessions,Authorization: Bearer $CLERK_SECRET_KEY). Re-verify live on next use.
Source-checked against optolink-backend @ 29f8589, 2026-10-06: route
POST /webhooks/clerk+ svix signature verification (src/clerk/clerk-webhook.controller.ts), the 8 handled event types (src/clerk/clerk-sync.service.ts),OrgResolutionPipe403 on missing rows (src/portal/decorators/org-from-user.decorator.ts),CLERK_WEBHOOK_SECRETrequired insrc/config/env.validation.ts.