Runbook: Rehoming the Clerk webhook to the core gateway (F7)
Context: upsquad-client#288. The Clerk webhook (svix → MemberService sync)
used to live in the Next portal at src/app/api/webhooks/clerk/route.ts. A
static SPA cannot host server endpoints, so the webhook now lives on the core
gateway at POST /webhooks/clerk (internal/gateway/clerkwebhook).
This runbook covers the zero-gap cutover from the portal endpoint to the gateway endpoint. The founder performs the Clerk dashboard steps (agents cannot access the Clerk dashboard).
What the gateway endpoint does
- Verifies the svix signature over the raw body using
CLERK_WEBHOOK_SIGNING_SECRET(HMAC-SHA256,{id}.{timestamp}.{body}, 5-minute timestamp tolerance to defeat replay). No svix SDK dependency. - Routes events to MemberService inside an org-scoped DB transaction (RLS GUCs
set the same way the gateway Scope middleware does):
user.created→CreateMember(lookup-first, idempotent)user.updated→UpdateMember(upserts if the member is unknown)user.deleted→ deferred (logs + no-op; awaitsDisableUser, core#821)organizationMembership.created→AddTeamMembershiporganizationMembership.deleted→RemoveTeamMembership
- Acks
200on benign duplicates (AlreadyExists/NotFound); returns500on genuine failures so Clerk retries;401on signature failure;400on missing svix headers / malformed body.
Configuration (fail-closed)
The endpoint is OFF (not mounted) unless BOTH env vars are set on context-engine. A half-configured deploy logs loudly at startup and stays off so member-sync traffic is never silently dropped.
| Env var | Purpose |
|---|---|
CLERK_WEBHOOK_SIGNING_SECRET | svix signing secret (whsec_...) from the Clerk dashboard endpoint config. Security gate — without it the webhook is not mounted. |
CLERK_WEBHOOK_DEFAULT_ORG_ID | UpsQuad org id that Clerk events route to (single-tenant fallback). Multi-tenant routing is a follow-up to core#1242. |
Verify at startup: look for Clerk webhook endpoint mounted path=/webhooks/clerk
in the context-engine logs. A DISABLED ERROR line names the missing var.
Dual-registration cutover (NO sync gap)
The gateway endpoint is designed to coexist with the portal endpoint so both receive deliveries during the transition. Sequence:
- Deploy the core change so
/webhooks/clerkis live on the gateway, with both env vars set. Confirm the "mounted" log line. - Add the new endpoint in the Clerk dashboard (Webhooks → Add Endpoint),
URL =
https://<gateway-host>/webhooks/clerk, subscribed to the same events the portal endpoint uses:user.created,user.updated,user.deleted,organizationMembership.created,organizationMembership.deleted. Leave the existing portal endpoint registered. Copy the new endpoint's signing secret intoCLERK_WEBHOOK_SIGNING_SECRET(each Clerk endpoint has its own secret) and redeploy/restart context-engine if it differs. - Verify deliveries to the new endpoint. In the Clerk dashboard, use
"Send test event" and/or trigger a real user/membership change, then check
the endpoint's delivery log shows
200. Cross-check the context-engine logs (clerk-webhook: received/created member/added team membership) and confirm the member rows land in the DB. Idempotency means the duplicate deliveries (old + new endpoint both firing) are safe — re-delivery ofuser.createdno-ops when the member already exists. - Only after the new endpoint is confirmed healthy, remove (or disable) the portal endpoint registration in the Clerk dashboard. The portal route keeps existing until the portal itself is retired; deregistering it in Clerk simply stops Clerk delivering to it.
Rollback
If the gateway endpoint misbehaves, re-enable the portal endpoint in the Clerk dashboard (it is unchanged) and disable the new one. No code rollback needed — both paths are idempotent against MemberService.
Known follow-ups
- Multi-tenant routing:
user.*events carry no UpsQuad tenant id, so today we route toCLERK_WEBHOOK_DEFAULT_ORG_ID. AGetOrgByClerkId-style resolution (or per-tenant Clerk endpoints) is tracked against core#1242. user.deletedpropagation waits onMemberService.DisableUser(core#821, OB-B3) for audit-preserving disable.