Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWebhooksWebhooks — Behavior

Webhooks — Behavior

Inbound surface

EndpointSourceDecoratorVerifier
POST /webhooks/clerkClerk@Public()Svix (new Webhook(secret).verify)

Clerk handler (clerk-webhook.controller.ts)

Headers

The handler requires all three Svix headers:

  • svix-id
  • svix-timestamp
  • svix-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_SECRET is 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:

EventAction
user.createdusersService.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.updatedusersService.syncFromClerk(clerkId, { email, imageUrl }) — mirrors profile updates.
user.deletedusersService.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.clerk with eventType and clerkId.
  • Every accepted event logs at info level via Pino: { eventType, clerkId }.
  • Failures inside acceptPendingInvitations are logged at warn and 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:

  1. findOrCreateFromClerk — idempotent by Clerk id uniqueness.
  2. syncFromClerk — overwrites fields; identical replays are no-ops.
  3. deleteSelf — soft-delete is idempotent (deletedAt is only set once).
  4. 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

FailureSurfaceRecovery
Bad signature400Caller (Clerk) retries with exponential backoff up to its retry budget.
Missing rawBody (middleware regression)500Sentry. Block-level fix; deploy hotfix.
DB write fails500Clerk retries. If persistent, manual replay (out-of-band) is required.
PostHog identify failsLogged warn, not surfacedNo retry — analytics is fire-and-forget.
acceptPendingInvitations failsLogged warn, 200 returnedInvitation remains pending; user manually accepts from the dashboard.