# Outgoing webhooks

> Signed webhooks to your customers' endpoints, with event subscriptions, retries, a queryable delivery log, secret rotation, URL checks and auto-disable.

Source: https://bettersupabase.com/docs/blocks/webhooks-out

The `webhooks-out` [SQL module](/docs/blocks/sql) 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.

```bash
pnpm better-supabase sql add webhooks-out
```

Secrets live in [Supabase Vault](https://supabase.com/docs/guides/database/vault)
by default and clients can never read them. With the
[access contract](/docs/blocks/access) 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 [#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:

```ts title="app/settings/webhooks/actions.ts"
"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 [#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:

```ts title="lib/webhooks.ts"
import "server-only";

import { createWebhooks, sqlTransport } from "better-supabase/blocks/webhooks";

export const webhooks = createWebhooks({
  transport: sqlTransport(postgres.admin),
  events: betterSupabase.events,
});
```

```ts
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](/docs/blocks/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 [#delivering]

Call `deliver()` from a cron route or a job. `deliverRoute` wraps it for
Vercel Cron, behind the `CRON_SECRET` bearer token:

```ts title="app/api/webhooks/deliver/route.ts"
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`](/docs/standards/webhooks) 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 [#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.

```ts
createWebhooks({
  transport: sqlTransport(postgres.admin),
  allowUrl: publicUrl({ allowHosts: ["hooks.internal.example.com"] }),
});
```

### Other requests to URLs users give you [#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):

```ts
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.

```ts
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:

```ts
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 [#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 [#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:

```ts title="better-supabase.config.ts"
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",
          },
        },
      },
    },
  },
});
```

```ts
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](/docs/cli/doctor#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 [#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                |