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

Webhooks — Data Model

The webhooks module owns no tables of its own. It writes into the user + membership domain.

Tables written

TableOperationTrigger
usersINSERT or UPDATEuser.created (insert) and user.updated (mirror profile).
usersUPDATE (soft delete: deleted_at, is_active)user.deleted.
membershipsUPDATE (invitedactive)user.created when pending invitations exist for the email.

External state

  • Clerk — source of truth for identity. Webhook is the inbound channel; outbound calls happen in apps/api/src/users/users.service.ts (e.g. deleteSelf calls Clerk’s API unless skipClerk: true).
  • Svix — Clerk’s delivery infrastructure. Each delivery has a unique svix-id we could persist for idempotency but currently don’t.
  • PostHoguser_signed_up event captured on first user.created.

Audit trail

Webhook actions are not currently written to audit_logs. Rationale: actor is “Clerk” (not a human), and the resulting writes are mechanical mirrors. Investigation goes through Pino + Sentry breadcrumb.

Idempotency

No dedupe table. Idempotency emerges from:

  • Clerk id uniqueness on users.clerk_id.
  • syncFromClerk issuing an UPDATE with where clerk_id = ? — repeated identical updates are no-ops at the row level.
  • acceptPendingInvitations checking the membership status before flipping.
  • deleteSelf only setting deleted_at if currently null.