Webhooks — QA Plan
Signature verification
| Step | Expected |
|---|---|
POST /webhooks/clerk with no Svix headers | 400 Missing svix headers. |
| POST with all headers but a forged signature | 400 Invalid webhook signature. |
| POST with a valid Svix payload signed with the wrong secret | 400 Invalid webhook signature. |
| POST with a payload whose body has been altered after signing | 400 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
| Step | Expected |
|---|---|
First-ever user.created for clerkId | Row 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 invitation | Invitation promoted to active; log line confirms count. |
user.created for a user with multiple invitations | All promoted in a single call. |
acceptPendingInvitations throws | Warning logged, webhook returns 200, user row still created. |
Event handling — user.updated
| Step | Expected |
|---|---|
| Email changes in Clerk | users.email updated. |
| Image URL changes | users.image_url updated. |
| Replay an identical event | No-op at the row level; idempotent. |
Event handling — user.deleted
| Step | Expected |
|---|---|
user.deleted for an existing clerk user | users.deleted_at set; is_active flipped. Memberships stay (soft delete only). |
| Replay | Idempotent — 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
| Step | Expected |
|---|---|
Send user.created twice in rapid succession | One row in users; both calls return 200. |
Send user.updated 10 times | One UPDATE result observed each; no row count change. |
Observability
- Each accepted event adds a
webhook.clerkSentry breadcrumb witheventTypeandclerkId. - Pino logs at info level for accepted events.
- Failure inside
acceptPendingInvitationslogs 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.
Payment provider webhooks (related)
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 (
findOrCreateFromClerkover an exported user list) and document inaudit_logsmanually.