Outbound Webhooks
Outbound Webhooks
What it does
Lets a user register their own webhook URL(s) at /settings/webhooks,
subscribe each to specific event types, and see a delivery log — status,
attempt count, HTTP response code, last error — with a manual "Redeliver"
button per failed row. Every delivery is HMAC-signed so the receiving end
can verify it actually came from this app.
Three events ship today (lib/webhook-events.ts):
billing.subscription.startedbilling.subscription.cancelledorganization.member.role_updated
How to configure it
Nothing to configure — no env var gate, same as api.md. Endpoints and secrets are entirely user-created data, stored in Postgres.
How it works
lib/db/schema/app.ts'swebhookEndpoints(one row per URL a user registered, with itseventsarray and HMACsecret) andwebhookDeliveries(one row per delivery attempt sequence, not per HTTP request —attemptscounts up in place across the retry loop so the log reads as "this event, this many tries, this outcome" rather than a flood of per-try rows).lib/webhook-events.ts— theKNOWN_WEBHOOK_EVENTSlist, split into its own file with no"server-only"import specifically socomponents/settings/webhooks-manager.tsx(a Client Component, for the event-selection checkboxes) can import the same list without dragging inlib/webhooks.ts's server-only DB/after()code, which fails to bundle client-side.lib/webhooks.ts'semitWebhookEvent(userId, event, data)— the entry point called from wherever an event actually happens. Looks up that user's enabled endpoints subscribed toevent, writes onependingdelivery row per matching endpoint synchronously (so it's visible in the log immediately even mid-flight), then schedules the actual HTTP POST via Next'safter()so the caller's own request (a better-auth hook, a Stripe webhook handler) never blocks on a third party's latency. Called from three places inlib/auth.ts: the organization plugin's role-update hook, and the@better-auth/stripeplugin'sonSubscriptionUpdate/onSubscriptionCancelcallbacks (see billing.md).- Delivery + retry —
attemptDeliveryPOSTs{event, data}as JSON with three headers:X-Webhook-Event,X-Webhook-Timestamp, andX-Webhook-Signature: sha256=<hex hmac>where the HMAC is computed over`${timestamp}.${rawBody}`using the endpoint's secret — verify it the same way on the receiving end. Retries up to 3 attempts total (immediate, then+2s,+5s) with an 8s per-attempt timeout viaAbortController; deliberately short backoff, not minutes, because this all runs inside a singleafter()invocation with no persisted job queue behind it — see the note below on durability. whsec_secret — generated once at endpoint creation (lib/actions/webhooks.ts), shown in full exactly once, then stored in plain text (unlike an API key's hashedkeycolumn) — deliberately, because unlike a bearer credential that authenticates inbound requests, this secret has to be read back on every outbound delivery to compute the signature; hashing it would make delivery impossible.app/(app)/settings/webhooks/page.tsx+components/settings/webhooks-manager.tsx— create/delete endpoints and trigger redelivery, backed bylib/actions/webhooks.ts's Server Actions (createWebhookEndpoint,deleteWebhookEndpoint,redeliverWebhookAction), same "Server Action called directly as an async function, no<form action>" pattern aslib/actions/waitlist.ts.
How to extend it
- A new event type — add it to
KNOWN_WEBHOOK_EVENTSinlib/webhook-events.ts, then callemitWebhookEvent(userId, "your.event", data)from wherever it happens. That's the whole integration surface — endpoint matching, signing, retries, and logging are all generic. - Durability across restarts / longer backoff — the current design
trades durability for simplicity: if the server process dies mid-
after()callback, that in-flight delivery attempt is lost (though itspendingDB row survives, so it's visible as stuck rather than silently vanishing). Swaplib/webhooks.ts's retry loop for a real job queue (BullMQ, Inngest, a Postgres-polling worker table) if you need guaranteed delivery or backoff windows longer than a few seconds. - Org-scoped endpoints — currently
userId-scoped only, same tradeoff as api.md's API keys; would need anorganizationIdcolumn and membership checks inlib/actions/webhooks.ts.
How to remove it
Remove the three emitWebhookEvent call sites from lib/auth.ts, delete
lib/webhooks.ts, lib/webhook-events.ts, lib/actions/webhooks.ts,
app/(app)/settings/webhooks/, components/settings/webhooks-manager.tsx,
the webhookEndpoints/webhookDeliveries tables from
lib/db/schema/app.ts (regenerate a migration), and the "Webhooks" sidebar
link in app/(app)/layout.tsx.