Webhooks — Behavior
Inbound surface
| Endpoint | Source | Decorator | Verifier |
|---|---|---|---|
POST /webhooks/clerk | Clerk | @Public() | Svix (new Webhook(secret).verify) |
Clerk handler (clerk-webhook.controller.ts)
Headers
The handler requires all three Svix headers:
svix-idsvix-timestampsvix-signature
Missing any → 400 Missing svix headers.
Signature verification
const wh = new Webhook(secret);
event = wh.verify(req.rawBody!.toString(), { 'svix-id', 'svix-timestamp', 'svix-signature' });- The raw body is captured by an express middleware (
apps/api/src/main.ts) so JSON parsing doesn’t strip the bytes Svix needs. CLERK_WEBHOOK_SECRETis the per-environment secret printed by Clerk’s webhook console.- A failed verify →
400 Invalid webhook signature. Sentry captures the breadcrumb but does not raise an issue (these are noisy and often expected during secret rotation).
Event router
The controller switches on event.type:
| Event | Action |
|---|---|
user.created | usersService.findOrCreateFromClerk(clerkId) → creates the DB user with email pulled from Clerk. Tracks user_signed_up to PostHog. Attempts acceptPendingInvitations(clerkId) — best-effort, errors logged. |
user.updated | usersService.syncFromClerk(clerkId, { email, imageUrl }) — mirrors profile updates. |
user.deleted | usersService.deleteSelf(clerkId, { skipClerk: true }) — soft-deletes the local user. skipClerk: true prevents recursion. |
Other event types currently no-op (handler returns { received: true }).
Response shape
{ "received": true }Returned with 200 OK regardless of which branch ran — Clerk’s retry logic relies on a 200 to mark the delivery successful.
Observability
- Every accepted event adds a Sentry breadcrumb
webhook.clerkwitheventTypeandclerkId. - Every accepted event logs at info level via Pino:
{ eventType, clerkId }. - Failures inside
acceptPendingInvitationsare logged atwarnand swallowed so the webhook still returns 200 (idempotent retry won’t help — the issue is data-shaped, not transient). - Hard failures (signature, unknown event) bubble up as 400.
Idempotency posture
The handler does not maintain an explicit processed_event_ids table. Idempotency is achieved by:
findOrCreateFromClerk— idempotent by Clerk id uniqueness.syncFromClerk— overwrites fields; identical replays are no-ops.deleteSelf— soft-delete is idempotent (deletedAtis only set once).acceptPendingInvitations— checks status before promoting; replays don’t double-accept.
This is intentionally minimal — a per-event dedupe table would be defensive but unnecessary for the current event types.
Payment-provider webhooks (out of scope of this module)
Payment-provider callbacks live in apps/api/src/payments/providers/:
- CardCom 3DS / charge callbacks
- Morning IPN
- Bit IPN
Each provider validates its own signature scheme and persists to payment_transactions. They share an outer rate limiter but otherwise do not pass through webhooks.module.ts.
Failure modes
| Failure | Surface | Recovery |
|---|---|---|
| Bad signature | 400 | Caller (Clerk) retries with exponential backoff up to its retry budget. |
| Missing rawBody (middleware regression) | 500 | Sentry. Block-level fix; deploy hotfix. |
| DB write fails | 500 | Clerk retries. If persistent, manual replay (out-of-band) is required. |
| PostHog identify fails | Logged warn, not surfaced | No retry — analytics is fire-and-forget. |
acceptPendingInvitations fails | Logged warn, 200 returned | Invitation remains pending; user manually accepts from the dashboard. |