Skip to main content

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:

  1. The listener's ready line proves only that a listener started — nothing about matching the registered endpoint URL.
  2. herdr/tmux scrollback keeps old ready lines. After a restart, confirm a new event line, never a stale ready. The __ping round-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.ts handles): 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_…) into optolink-backend/.env as CLERK_WEBHOOK_SECRET and 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)​

PrerequisiteHow to verifyFix if missing
CLI linked to the backend's appclerk whoami → appId matches the dev instanceclerk link
Svix app exists for the instanceclerk api /webhooks/svix -X POST --yes -d '{}' → svix_app_existssame call creates it
Svix endpoint pinned to the relay tokenclerk api /webhooks/svix_url -X POST --yes -d '{}' → open URL, check endpoint URLedit endpoint URL to the pinned relay URL
Endpoint signing secret == .env CLERK_WEBHOOK_SECRETcompare in Svix dashboard vs .envcopy endpoint secret into .env, restart server
Endpoint subscribed to the 8 eventsSvix dashboard → endpoint → eventsadd the 8 (list above)
org_metadata on default session tokena @Roles-gated call returns 200, not 403Clerk dev instance → session token
Testing Mode on (OTP 424242)only needed for UI/Playwright flowsDashboard → 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 /sessions returns a plain 404 page not found from the Clerk API (header clerk-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), OrgResolutionPipe 403 on missing rows (src/portal/decorators/org-from-user.decorator.ts), CLERK_WEBHOOK_SECRET required in src/config/env.validation.ts.