Outgoing webhooks
Signed webhooks to your customers' endpoints, with event subscriptions, retries, a queryable delivery log, secret rotation, URL checks and auto-disable.
The webhooks-out SQL module stores the endpoints
your customers register, their signing secrets and one delivery row per
event and endpoint. createWebhooks from
better-supabase/blocks/webhooks queues events, signs and sends the deliveries,
retries failures and keeps the log.
pnpm better-supabase sql add webhooks-outSecrets live in Supabase Vault
by default and clients can never read them. With the
access contract installed, members with
webhooks.read read the endpoints and the delivery log of their
organization through RLS, and webhooks.manage adds, changes, rotates and
redelivers. Without it, only the service role has access.
Endpoints
An endpoint is a row with a url (HTTPS unless options.allowHttp),
event_types and enabled. Event types match exactly, with * for every
event, or by prefix with invoice.*. Create the first secret with
rotateSecret and show it to the user once:
"use server";
import { createWebhooks, sqlTransport } from "better-supabase/blocks/webhooks";
export async function addEndpoint(
organizationId: string,
input: { url: string; events: string[] },
) {
const ctx = await bs.context();
const db = postgres.asUser(ctx.auth.claims);
const [endpoint] = await db.queryRaw<{ id: string }>(
`insert into better_supabase.webhook_endpoints (organization_id, name, url, event_types)
values ($1, $2, $3, $4) returning id`,
[organizationId, new URL(input.url).host, input.url, input.events],
);
const webhooks = createWebhooks({ transport: sqlTransport(db) });
return webhooks.rotateSecret(endpoint!.id).orThrow();
}rotateSecret(id, { overlap }) creates a new secret and keeps the older ones
signing for overlap ('24 hours' by default), so receivers can switch
without missing a delivery. Pass secret to import one, for example when you
migrate from another system.
Publishing events
publish queues the event for every enabled endpoint in the organization
that subscribes to its type, and returns how many deliveries it queued.
Publish from a service connection:
import "server-only";
import { createWebhooks, sqlTransport } from "better-supabase/blocks/webhooks";
export const webhooks = createWebhooks({
transport: sqlTransport(postgres.admin),
events: betterSupabase.events,
});await webhooks
.publish({
type: "invoice.paid",
data: { id: invoice.id, amount: invoice.amount },
tenant: organizationId,
id: `invoice-paid-${invoice.id}`,
})
.orThrow();With an id, publishing the same event again queues nothing, so a retried
request never sends twice. dispatch({ endpointId, type, data, runId?, eventId? }) sends to one endpoint outside its subscriptions, for example
from a workflow step, and returns the delivery id. A member with
webhooks.manage may call it too.
To publish from the outbox, pass webhooks.sink() to
the relay. It publishes each CloudEvent with its partitionkey as the
organization and its id as the event id, and removes the relay's type
prefix so endpoints subscribe to invoice.paid rather than
dev.better-supabase.invoice.paid. Pass sink({ typePrefix }) when the relay
uses its own prefix.
Delivering
Call deliver() from a cron route or a job. deliverRoute wraps it for
Vercel Cron, behind the CRON_SECRET bearer token:
import { webhooks } from "@/lib/webhooks";
export const GET = webhooks.deliverRoute({ secret: process.env.CRON_SECRET });deliver claims due rows with for update skip locked and leases them
(lease, two minutes by default), so two runs never send the same delivery.
The claim counts the attempt, so a worker that dies mid-send still uses one
up, and the attempt is the lease token: a worker whose lease ran out and was
claimed again can't record its result. A batch (batch, 25) is sent with
concurrency (10) requests in flight, so it finishes inside the lease.
Each request carries the Standard Webhooks headers (webhook-id,
webhook-timestamp and webhook-signature) and the body
{ type, timestamp, data }. Receivers check it with
verifyWebhook or any Standard Webhooks library.
The delivery id is the webhook-id, the same on every retry.
| Response | Result |
|---|---|
| 2xx | succeeded |
| 408, 429, 5xx, a network error | retrying, on the schedule below |
| A URL check that fails (DNS) | retrying, on the schedule below |
| Any other status | dead |
A URL allowUrl rejects | dead, never sent |
The last of maxAttempts (8) | dead, also when the worker died |
A delivery is pending until a worker claims it and delivering while the
lease holds it. shouldDeliver returning false and disabling the endpoint
leave it canceled.
Retries follow the Svix schedule: 5 seconds, 5 minutes, 30 minutes, 2
hours, 5 hours, then 10 hours, each with full jitter (a random wait up to
that long) so failed receivers aren't hit in bursts. A Retry-After header
on the response wins when it asks for longer, up to a day.
An endpoint whose deliveries keep failing for disableAfter (5 days)
without a success in between is disabled, its pending deliveries are
canceled and the outbox gets webhook.disabled. Setting enabled back to
true clears failing_since.
redeliver(deliveryId) queues a finished delivery again from its first
attempt.
The log keeps the status, attempt, response status and body (the first 2000 characters), duration and last error of every delivery, so you can show it in your settings page with a plain query.
URL checks
The default allowUrl is publicUrl(): HTTPS only, no credentials in the
URL, no local host names, and every address the host resolves to must be
public (not loopback, private, link-local or reserved, so cloud metadata
endpoints are out). IPv6 addresses that carry an IPv4 address (mapped,
IPv4-compatible, NAT64, 6to4 and Teredo) are checked by that address or
refused. It resolves with node:dns where the runtime has it; pass
resolve on other runtimes. Use allowHosts for a receiver you trust.
fetchTransport follows no redirects by default (maxRedirects: 0), so a
redirect dead-letters the delivery; each hop you allow is checked with
allowUrl first. It reads at most maxResponseBytes (64 KiB) of a response
body. The check and the connection resolve the host separately, so a host
whose DNS changes in between (DNS rebinding) can still reach an internal
address. When that matters, send webhooks through an egress proxy that
refuses private addresses, with a custom http transport.
Signing secrets are 32 random bytes (whsec_ and base64). With Vault
storage, deleting a secret row or its endpoint deletes the Vault secret.
createWebhooks({
transport: sqlTransport(postgres.admin),
allowUrl: publicUrl({ allowHosts: ["hooks.internal.example.com"] }),
});Other requests to URLs users give you
createSafeFetch applies the same checks to any request whose URL comes
from a user or tenant: link previews, imports from a URL, avatars by URL,
OAuth callback checks. It returns a fetch that refuses a URL outside
publicUrl (or your allowUrl) with UnsafeUrlError, checks every redirect
before following it (3 at most by default, maxRedirects), drops
authorization, cookie and proxy-authorization on a redirect to another
origin, and stops after timeoutMs (10 seconds):
import { createSafeFetch } from "better-supabase/blocks/webhooks";
const safeFetch = createSafeFetch();
const response = await safeFetch(userSuppliedUrl);When your requests carry credentials in other headers, such as an API key
for the service you call, list them in sensitiveHeaders so a redirect to
another origin drops them too. Redirects within the same origin keep them.
const safeFetch = createSafeFetch({ sensitiveHeaders: ["x-api-key"] });When the check itself fails, usually because the DNS lookup for the host
failed, safeFetch throws UrlCheckError instead, with the lookup error as
cause. The URL was not refused, so treat it as a transient failure you may
retry, and treat UnsafeUrlError as final:
import { UnsafeUrlError, UrlCheckError } from "better-supabase/blocks/webhooks";
try {
await safeFetch(userSuppliedUrl);
} catch (error) {
if (error instanceof UnsafeUrlError) return { status: "refused" };
if (error instanceof UrlCheckError) return { status: "retry" };
throw error;
}The DNS caveat above applies here too.
Extending it
| Option or hook | Use |
|---|---|
signer | standardWebhooks({ headers }) renames the headers; hmacSigner matches other formats |
transform | The JSON body for a delivery |
headers | Extra headers per delivery, e.g. an idempotency-key |
shouldDeliver | Return false (or throw) to cancel a delivery, e.g. for a suspended plan |
retry | maxAttempts, backoff(attempt) in seconds, retryable(status) |
http | A WebhookTransport, e.g. through an egress proxy |
secrets | A WebhookSecretStore, e.g. a KMS |
after_webhook_delivery hook | A SQL function that runs after each delivery completes, with its id and status |
webhook.* block events | delivered, failed and disabled on betterSupabase.events |
webhooks.* permissions | Rename them with sql.modules.webhooks-out.permissions.manage and .view |
testWebhookSigner, testWebhookTransport and testWebhookSecretStore
from better-supabase/testing check your own implementations against the
contract.
Existing tables
The managed tables are webhook_endpoints with event_types,
webhook_endpoint_secrets and webhook_deliveries with event_type, keyed
on organization_id. Adopt yours, rename what differs and map what you
don't have to null. This adopts a webhook_destinations table with
event_kinds, keeps secrets in a column, stores uuid event and run ids,
maps the stored statuses and signs with an existing HMAC format:
export default defineConfig({
sql: {
modules: {
"webhooks-out": {
mode: "adopt",
idType: "uuid",
tables: {
endpoints: "public.webhook_destinations",
secrets: "public.webhook_destination_secrets",
},
columns: {
endpoints: {
eventTypes: "event_kinds",
failingSince: null,
disabledAt: null,
disabledReason: null,
},
secrets: {
endpoint: "destination_id",
vaultId: null,
expiresAt: null,
},
deliveries: {
endpoint: "destination_id",
type: "event_kind",
run: "workflow_run_id",
},
},
options: {
secretStorage: "column",
eventIdType: "uuid",
runIdType: "uuid",
statuses: {
delivering: "processing",
succeeded: "completed",
retrying: "failed",
dead: "dead_lettered",
},
},
},
},
},
});createWebhooks({
transport: sqlTransport(postgres.admin),
signer: hmacSigner({
signatureHeader: "x-acme-signature",
timestampHeader: "x-acme-timestamp",
}),
});With the default secretStorage: "vault", an adopted secrets table needs
the vault_secret_id column (map columns.secrets.vaultId when yours has
another name) and no secret column: the module never reads it, so sql sync doesn't ask you to map it to null. With secretStorage: "column" it
is the other way round.
Without failingSince, endpoints are never disabled automatically.
Without expiresAt, rotating replaces the secret at once instead of
overlapping. With secretStorage: "column" the secret is stored as plain
text in a table no client can read. statuses maps each status to the value
your table stores; the ones you leave out keep their names. The options
marked migration-only match an existing schema: the config accepts them in
mode: "adopt" only, and doctor warns about them (BS314) until you remove them.
The module's functions stay in its own schema (better_supabase unless
schema names another one that the Data API doesn't expose, see
BS312); tables points at the adopted tables
wherever they are.
| Option | Default | Meaning |
|---|---|---|
secretStorage | vault | vault, or column (migration-only, adopt mode) |
allowHttp | false | Allow http: endpoint URLs |
disableAfter | 5 days | How long an endpoint fails without a success before it's disabled |
eventIdType | text | The type of the event id column: text or uuid; others are migration-only |
runIdType | text | The type of the run id column: text or uuid; others are migration-only |
statuses | none | The stored value of each status, in adopt mode only |
Error codes
hint | When |
|---|---|
WEBHOOK_TYPE_REQUIRED | publish or dispatch without an event type |
WEBHOOK_FORBIDDEN | The caller lacks webhooks.manage in the organization |
WEBHOOK_ENDPOINT_NOT_FOUND | No endpoint with that id |
WEBHOOK_ENDPOINT_DISABLED | Dispatching or redelivering to a disabled endpoint |
WEBHOOK_DELIVERY_NOT_FOUND | No delivery with that id |
WEBHOOK_DELIVERY_IN_PROGRESS | Redelivering a delivery that hasn't finished |
WEBHOOK_STATUS_UNKNOWN | Completing a delivery with a status the block doesn't know |
WEBHOOK_ATTEMPT_REQUIRED | Completing a delivery without the attempt its claim returned |
WEBHOOK_SECRET_TOO_SHORT | Importing a secret shorter than 16 characters |
Last updated on
Inbox
A shared inbox for support conversations from an in-app widget, Slack, WhatsApp, SMS and other channels, with assignment, internal notes, read receipts, bot handoff and realtime updates.
Incoming webhooks
Trigger URLs a tenant hands to other systems, with hashed tokens, optional signatures, limits and an inbox.