Skip to main content
Localizia

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:

EventFired when
member.addedA user joins the organization
member.removedA user is removed or leaves
member.role_changedA member's role changes
invitation.sentAn invitation is sent
invitation.revokedA pending invitation is revoked
subscription.updatedThe Stripe subscription changes
subscription.canceledThe subscription is canceled
payment.failedAn 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 name
  • x-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. the 169.254.169.254 cloud-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.