Skip to main content

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.createdCreateMember (lookup-first, idempotent)
    • user.updatedUpdateMember (upserts if the member is unknown)
    • user.deleteddeferred (logs + no-op; awaits DisableUser, core#821)
    • organizationMembership.createdAddTeamMembership
    • organizationMembership.deletedRemoveTeamMembership
  • Acks 200 on benign duplicates (AlreadyExists / NotFound); returns 500 on genuine failures so Clerk retries; 401 on signature failure; 400 on 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 varPurpose
CLERK_WEBHOOK_SIGNING_SECRETsvix signing secret (whsec_...) from the Clerk dashboard endpoint config. Security gate — without it the webhook is not mounted.
CLERK_WEBHOOK_DEFAULT_ORG_IDUpsQuad 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:

  1. Deploy the core change so /webhooks/clerk is live on the gateway, with both env vars set. Confirm the "mounted" log line.
  2. 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 into CLERK_WEBHOOK_SIGNING_SECRET (each Clerk endpoint has its own secret) and redeploy/restart context-engine if it differs.
  3. 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 of user.created no-ops when the member already exists.
  4. 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 to CLERK_WEBHOOK_DEFAULT_ORG_ID. A GetOrgByClerkId-style resolution (or per-tenant Clerk endpoints) is tracked against core#1242.
  • user.deleted propagation waits on MemberService.DisableUser (core#821, OB-B3) for audit-preserving disable.