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

Webhooks — QA Plan

Signature verification

StepExpected
POST /webhooks/clerk with no Svix headers400 Missing svix headers.
POST with all headers but a forged signature400 Invalid webhook signature.
POST with a valid Svix payload signed with the wrong secret400 Invalid webhook signature.
POST with a payload whose body has been altered after signing400 Invalid webhook signature.
POST with a stale svix-timestamp (e.g. 1h old)400 Invalid webhook signature (Svix rejects on staleness).

Event handling — user.created

StepExpected
First-ever user.created for clerkIdRow inserted in users; user_signed_up PostHog event fired.
user.created replayed (Clerk retry)Idempotent — same row, no duplicate PostHog event observed (handler invokes findOrCreateFromClerk which returns the existing row; track fires again but PostHog dedupes by event).
user.created for a user with one pending invitationInvitation promoted to active; log line confirms count.
user.created for a user with multiple invitationsAll promoted in a single call.
acceptPendingInvitations throwsWarning logged, webhook returns 200, user row still created.

Event handling — user.updated

StepExpected
Email changes in Clerkusers.email updated.
Image URL changesusers.image_url updated.
Replay an identical eventNo-op at the row level; idempotent.

Event handling — user.deleted

StepExpected
user.deleted for an existing clerk userusers.deleted_at set; is_active flipped. Memberships stay (soft delete only).
ReplayIdempotent — deleted_at not overwritten.
deleteSelf errors (e.g. row missing)Error logged at error level; webhook still returns 200 (intentional — Clerk shouldn’t retry forever for a missing row).

Idempotency

StepExpected
Send user.created twice in rapid successionOne row in users; both calls return 200.
Send user.updated 10 timesOne UPDATE result observed each; no row count change.

Observability

  • Each accepted event adds a webhook.clerk Sentry breadcrumb with eventType and clerkId.
  • Pino logs at info level for accepted events.
  • Failure inside acceptPendingInvitations logs at warn but returns 200.
  • Hard signature failures log at warn (or Pino default error if uncaught) and return 400.

Negative tests

  • Unknown event type (e.g. session.created) → 200, no DB writes.
  • Body parser regression (rawBody not captured) → 500 surfaced; Sentry alert.
  • DB outage on findOrCreateFromClerk → 500, Clerk retries.

Reference apps/api/src/payments/providers/*.provider.ts for provider-specific QA — out of scope here but covered in the payments QA plan.

Replay / backfill

  • No in-app replay surface. Procedure: trigger Clerk’s “Send test” or replay from the Svix dashboard.
  • For a known-missed event, run a one-off backfill query (findOrCreateFromClerk over an exported user list) and document in audit_logs manually.