Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

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.started
  • billing.subscription.cancelled
  • organization.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's webhookEndpoints (one row per URL a user registered, with its events array and HMAC secret) and webhookDeliveries (one row per delivery attempt sequence, not per HTTP request — attempts counts 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 — the KNOWN_WEBHOOK_EVENTS list, split into its own file with no "server-only" import specifically so components/settings/webhooks-manager.tsx (a Client Component, for the event-selection checkboxes) can import the same list without dragging in lib/webhooks.ts's server-only DB/after() code, which fails to bundle client-side.
  • lib/webhooks.ts's emitWebhookEvent(userId, event, data) — the entry point called from wherever an event actually happens. Looks up that user's enabled endpoints subscribed to event, writes one pending delivery row per matching endpoint synchronously (so it's visible in the log immediately even mid-flight), then schedules the actual HTTP POST via Next's after() 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 in lib/auth.ts: the organization plugin's role-update hook, and the @better-auth/stripe plugin's onSubscriptionUpdate/ onSubscriptionCancel callbacks (see billing.md).
  • Delivery + retry — attemptDelivery POSTs {event, data} as JSON with three headers: X-Webhook-Event, X-Webhook-Timestamp, and X-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 via AbortController; deliberately short backoff, not minutes, because this all runs inside a single after() 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 hashed key column) — 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 by lib/actions/webhooks.ts's Server Actions (createWebhookEndpoint, deleteWebhookEndpoint, redeliverWebhookAction), same "Server Action called directly as an async function, no <form action>" pattern as lib/actions/waitlist.ts.

How to extend it

  • A new event type — add it to KNOWN_WEBHOOK_EVENTS in lib/webhook-events.ts, then call emitWebhookEvent(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 its pending DB row survives, so it's visible as stuck rather than silently vanishing). Swap lib/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 an organizationId column and membership checks in lib/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.