Webhooks
Overview
Outbound webhooks let an organization register HTTPS endpoints and receive a
signed POST whenever something happens in their account — a member joins, a
subscription changes, a payment fails. They are the outbound counterpart to the
Stripe webhooks the app already receives, and the mechanism customers use to
wire your app into their own systems.
Enable it with a feature flag:
WEBHOOKS_ENABLED=true
Delivery runs on the background-jobs cron, so
CRON_SECRET must also be set and the deliver-pending-webhooks job scheduled.
When the flag is off, the endpoints, the management UI, and the nav item are all
hidden.
Event catalog
The typed catalog lives in src/config/webhooks.config.ts:
| Event | Fired when |
|---|---|
member.added | A user joins the organization |
member.removed | A user is removed or leaves |
member.role_changed | A member's role changes |
invitation.sent | An invitation is sent |
invitation.revoked | A pending invitation is revoked |
subscription.updated | The Stripe subscription changes |
subscription.canceled | The subscription is canceled |
payment.failed | An invoice payment fails |
Each endpoint subscribes to one or more events. Add a new event by extending
WEBHOOK_EVENTS and calling emitWebhookEvent() at the trigger site.
Delivery model
Delivery is queued, not inline. When an event fires, emitWebhookEvent()
writes one WebhookDelivery row (status pending) per subscribed endpoint and
returns immediately — it never blocks or breaks the action that triggered it.
The deliver-pending-webhooks cron job then picks up due deliveries and POSTs
them.
The practical consequence: delivery latency is bounded by your cron interval. This is a deliberate trade for robustness (retries, isolation, no request-path failures). Run the cron frequently if you need low latency.
Each request carries these headers:
x-webhook-signature— the signature (see below)x-webhook-event— the event namex-webhook-id— the delivery id (use it for idempotency)
Verifying the signature
The signature format mirrors Stripe's: a timestamp plus an HMAC-SHA256 over
${timestamp}.${rawBody}, keyed with the endpoint's signing secret (shown
once at creation, rotatable). The header value is t=<unix>,v1=<hex>.
Verify it on your receiver, and reject stale timestamps to prevent replay:
import { createHmac, timingSafeEqual } from "node:crypto"
function verify(secret: string, rawBody: string, header: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string]),
)
const t = Number(parts.t)
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex")
const a = Buffer.from(parts.v1)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
Always sign/verify against the raw request body, not a re-serialized object.
Security: anti-SSRF
Customer-supplied URLs are a Server-Side Request Forgery risk. Every endpoint URL
is validated when it's created or edited and again before each delivery
(src/lib/webhooks/ssrf.ts):
- The URL must be
https. - The host is DNS-resolved, and delivery is refused if any resolved address
is loopback, private (
10/8,172.16/12,192.168/16), CGNAT, link-local (incl. the169.254.169.254cloud-metadata address), or their IPv6 equivalents.
Delivery then pins the connection to the vetted addresses: a custom DNS lookup hands the socket only the IPs that passed the check (TLS still verifies the hostname), so a DNS-rebinding swap between validation and connect cannot reach an internal target.
Signing secrets are encrypted at rest (AES-256-GCM, key derived from
BETTER_AUTH_SECRET). Caveat: rotating BETTER_AUTH_SECRET makes stored
webhook secrets undecryptable — rotate each endpoint's secret from the UI
afterwards. Endpoints created before encryption keep working and are encrypted
on their next rotation.
Retries and auto-disable
A non-2xx response, a timeout, or a network error schedules a retry with
exponential backoff (retryScheduleMinutes — roughly 6 attempts by default).
Once the schedule is exhausted the delivery is abandoned (nextRetryAt cleared).
Consecutive failures across deliveries accumulate on the endpoint; after
autoDisableAfter (15 by default) the endpoint is auto-disabled so a dead
URL doesn't generate traffic forever. A successful delivery resets the streak.
Managers can re-enable it, rotate the secret, or redeliver a failed delivery
manually from the UI.
Managing endpoints
Owners and admins manage webhooks under organization → webhooks: add an
endpoint, choose its events, copy the signing secret (shown once), toggle it,
rotate the secret, send a test ping (a signed ping event delivered
synchronously, to verify URL/secret wiring without waiting for a real event),
and inspect the delivery log with per-delivery status and a redeliver
action. Plain members have no access.
Extending
Models live in prisma/schema.prisma (WebhookEndpoint, WebhookDelivery);
signing and SSRF helpers in src/lib/webhooks/; the emit primitive in
src/lib/webhooks/index.ts; the delivery job in
src/lib/jobs/deliver-pending-webhooks.ts; and the procedures in
src/server/routers/webhooks.router.ts. To emit a new event, add it to
WEBHOOK_EVENTS and call emitWebhookEvent({ organizationId, event, data }) at
the trigger site — it is best-effort and never throws.