# Incoming webhooks

> Trigger URLs a tenant hands to other systems, with hashed tokens, optional signatures, limits and an inbox.

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

`incoming-webhooks` lets a tenant create a URL that another system calls:
a form, a CRM, a monitoring tool. The `webhooks-in` SQL module keeps those
endpoints per tenant, and `createIncomingWebhooks` from
`better-supabase/blocks/webhooks` serves them. Each delivery is checked, then
stored in the [webhook inbox](/docs/blocks/queue#webhook-inbox) with the
endpoint's tenant, so it is acknowledged fast and processed with retries.

```bash
better-supabase add webhooks-in   # adds access, tenant and webhook-inbox
```

## Endpoints [#endpoints]

A member with `webhooks.manage` (see Permissions below) creates an endpoint for their tenant. The token, which goes in the URL, and
the signing secret are returned once. Only the token's hash is stored, and
the secret is a [Vault](https://supabase.com/docs/guides/database/vault)
secret that only the receiving route decrypts. Set
`blocks.webhooks-in.options.secretStorage` to `"column"` to keep it in
the `secret` column instead (members can't read that column either). Deleting
an endpoint, or rotating its secret, deletes the old Vault secret.

```ts
import { createIncomingWebhooks } from "better-supabase/blocks/webhooks";

const hooks = createIncomingWebhooks({ sql: ctx.postgres });
const endpoint = await hooks
  .create({
    tenant: organizationId,
    name: "Website form",
    verify: "standard-webhooks",
  })
  .orThrow();
// https://api.example.com/hooks/${endpoint.token}, signed with endpoint.secret

await hooks.rotate(endpoint.id, { rotateSecret: true });
await hooks.setEnabled(endpoint.id, false);
await hooks.remove(endpoint.id);
```

| `verify`            | A delivery must carry                                                                                           |
| ------------------- | --------------------------------------------------------------------------------------------------------------- |
| `none` (default)    | only the token in the URL                                                                                       |
| `standard-webhooks` | a [Standard Webhooks](/docs/standards) signature with the endpoint's `whsec_` secret                            |
| `hmac-sha256`       | the hex HMAC-SHA256 of the body, optionally prefixed `sha256=`, in `signatureHeader` (`x-signature` by default) |
| `shared-secret`     | the endpoint's secret itself in `signatureHeader` (`x-webhook-secret` by default), for senders that cannot sign |

### Rotating the secret [#rotating-the-secret]

`rotate(id)` issues a new token, so the old URL stops working. To replace
only the signing secret and keep the URL, call `rotateSecret(id, { grace })`:

```ts
const { secret, previousSecretExpiresAt } = await hooks
  .rotateSecret(endpoint.id, { grace: "24 hours" })
  .orThrow();
```

During `grace` (a Postgres interval or a `Temporal.Duration`) deliveries
signed with the old secret still verify, so the sender can switch over
without dropped deliveries. Without `grace` the old secret stops at once.
The old Vault secret is deleted at the next rotation, a change of `verify`
or the endpoint's deletion. An endpoint without a secret (`verify: "none"`)
is refused with `WEBHOOK_IN_NO_SECRET`. In SQL it is
`rotate_incoming_webhook_secret(endpoint, grace)`, checked with the `update`
key.

### Changing an endpoint [#changing-an-endpoint]

`update(id, { name?, metadata?, verify?, signatureHeader? })` renames an
endpoint, replaces its `metadata` (to bind it to another workflow, say) or
changes how deliveries are verified, and keeps its token and URL. Fields
you leave out stay as they are.

```ts
const changed = await hooks
  .update(endpoint.id, {
    metadata: { workflowId: nextWorkflowId },
    verify: "hmac-sha256",
  })
  .orThrow();
// changed.secret is the new HMAC secret, shown once
```

Changing `verify` to a signing mode returns a new secret once, and the old
Vault secret is deleted; changing it to `none` drops the secret. The same
`verify` keeps the secret, and `secret` comes back `null`. In SQL it is
`update_incoming_webhook(endpoint, name, metadata, verify, signature_header)`,
which refuses an empty name (`WEBHOOK_IN_NAME_REQUIRED`), metadata that is
not an object (`WEBHOOK_IN_METADATA_INVALID`), an unknown mode
(`WEBHOOK_IN_VERIFY_UNKNOWN`), and a caller without the `update` key
(`WEBHOOK_IN_NOT_FOUND`).

### Permissions [#permissions]

Creating, changing and deleting an endpoint check three keys, each
`webhooks.manage` by default. `permissions.manage` sets all three at once,
and `create`, `update` or `delete` override one of them, so an app can let
members add endpoints while only admins remove them:

```ts title="better-supabase.config.ts"
"webhooks-in": {
  permissions: { manage: "integrations.manage", delete: "integrations.delete" },
},
```

| Action   | Checked by                                                                                                                |
| -------- | ------------------------------------------------------------------------------------------------------------------------- |
| `create` | `create_incoming_webhook`                                                                                                 |
| `update` | `update_incoming_webhook`, `rotate_incoming_webhook`, `rotate_incoming_webhook_secret` and `set_incoming_webhook_enabled` |
| `delete` | `delete_incoming_webhook`                                                                                                 |
| `view`   | the read policy and `list_incoming_webhooks` (`webhooks.read`)                                                            |

Use `shared-secret` only for a sender that can set a fixed header but
cannot compute a signature, such as a form tool or an older CRM. The secret
travels with every request, so it protects against a leaked URL but not
against someone who can read the traffic. `receive` compares it in constant
time and never stores that header, even when `keepHeaders` names it.

Members with `webhooks.read` read their tenant's endpoints, including
`receive_count`, `last_received_at` and `last_status`. `hooks.list(tenant)`
returns them without secrets through `list_incoming_webhooks`, which runs as
the caller.

### Endpoints that belong to a record [#endpoints-that-belong-to-a-record]

An endpoint can belong to a record in your schema, such as the workflow it
starts or the integration it feeds. Map each subject type to its table, the
same way the comments block does:

```ts title="better-supabase.config.ts"
blocks: {
  "webhooks-in": {
    on: {
      workflow: { table: "workflows", cascade: true },
      integration: { table: "app.integrations", permission: "integrations.read" },
    },
  },
},
```

```ts
await hooks.create({
  tenant: organizationId,
  name: "Form submissions",
  subject: { type: "workflow", id: workflowId },
});
const forWorkflow = await hooks.list(organizationId, {
  type: "workflow",
  id: workflowId,
});
```

Creating an endpoint checks that the subject exists in the tenant and that
the caller holds its `permission` (`WEBHOOK_IN_SUBJECT_INVALID` otherwise).
The read policy also requires that the caller can read the subject row
through the subject table's own policies, so an endpoint on a record a
member can't see is hidden from them. `cascade: true` deletes a subject's
endpoints when its row is deleted, and the receiving route stores each
delivery as before, so the inbox handler finds the subject on the endpoint.
`id` defaults to `id` and `tenant` to `organization_id`.

## Receiving [#receiving]

Route the public URL to `receive` on a service connection:

```ts title="app/hooks/[token]/route.ts"
const hooks = createIncomingWebhooks({
  sql: postgres.admin,
  rateLimit: { max: 60, period: 60 },
});

export const POST = (
  request: Request,
  { params }: { params: { token: string } },
) => hooks.receive(request, params.token);
```

`receive` answers `404` for an unknown or disabled token, `413` for a body over
the endpoint's `max_body_bytes` (1 MiB by default,
`blocks.webhooks-in.options.maxBodyBytes`), `429` with `Retry-After` past
`rateLimit` (counted per endpoint with the
[`rate-limit` module](/docs/blocks/sql#limits-in-route-handlers)), and `401` for a
bad signature. Otherwise it stores the delivery and answers `202`, or `200`
when the same delivery arrived before: one with a `webhook-id` seen before
or, on an `hmac-sha256` endpoint, one with a signature seen before. The id
header isn't signed there, so a replayed body is stored once whatever id it
carries. Every answer after the lookup is counted on the endpoint.

### Tenant exports and purges [#tenant-exports-and-purges]

With the [`data-lifecycle` module](/docs/blocks/data-lifecycle) installed,
a tenant's endpoints are in its organization export, without `token_hash`,
the secrets and their Vault ids, and the purge deletes them together with
their Vault secrets. To keep endpoints in a table of your own, see
[Over your own tables](#over-your-own-tables).

### Upgrading from version 1 [#upgrading-from-version-1]

Version 2 of the module moves signing secrets to Vault. `better-supabase sql
upgrade` writes a step that adds the `secret_id` column, copies every
plaintext secret into Vault and clears the column; the receiving route reads
either while the step is pending.

## Processing [#processing]

Deliveries are inbox messages of source `webhook-in` (or `source`), with the
endpoint's tenant and its id in the `x-bsb-endpoint-id` header:

```ts
import { createWebhookInbox, sqlTransport } from "better-supabase/blocks/queue";
import { INCOMING_ENDPOINT_HEADER } from "better-supabase/blocks/webhooks";

const inbox = createWebhookInbox({
  transport: sqlTransport(postgres.admin),
  source: "webhook-in",
  verify: () =>
    Promise.reject(
      new Error("deliveries arrive through createIncomingWebhooks"),
    ),
});

await inbox.process(async (message) => {
  const endpointId = message.headers[INCOMING_ENDPOINT_HEADER];
  await startWorkflowFor(message.tenant, endpointId, message.payload);
});
```

A JSON body is stored as it is; any other body as `{ "body": "<text>" }`.

### Sending deliveries to a queue [#sending-deliveries-to-a-queue]

`destination` decides where a verified delivery goes. The default,
`inboxDestination`, stores it in the webhook inbox as above.
`jobsDestination` enqueues it on a [jobs](/docs/blocks/queue) queue instead,
once per endpoint and message id, with the endpoint's tenant as the job's
context:

```ts
import * as v from "valibot";
import { createQueue } from "better-supabase/blocks/queue";
import {
  createIncomingWebhooks,
  jobsDestination,
} from "better-supabase/blocks/webhooks";

const jobs = createQueue({
  sql: postgres.admin,
  queues: { leads: v.object({ route: v.string(), body: v.unknown() }) },
});

export const hooks = createIncomingWebhooks({
  sql: postgres.admin,
  destination: jobsDestination(jobs, {
    queue: "leads",
    payload: (delivery) => ({
      route: String(delivery.endpoint.metadata["route"] ?? "default"),
      body: delivery.payload,
    }),
  }),
});
```

The queue key stays taken after the job completes, so a sender that retries
a delivery gets `200` and no second job. Without `payload` the job gets the
whole delivery: the endpoint's id, tenant and metadata, the source, the
message id, the type, the payload and the kept headers. Route on the
endpoint's `metadata` (set with `create` or `update`); `source` only labels
the inbox messages. The jobs destination needs the SQL or RPC queue backend:
the public pgmq backend has no `enqueueOnce`.

Any object with `apiVersion: 1`, a `name` and `deliver(delivery)` is a
destination. It returns the stored id and whether the delivery was there
already, which `receive` answers with `200`.
[`testIncomingDestination`](/docs/extending/conformance) checks that a
destination reports a repeated endpoint and message id as a duplicate.

## Over your own tables [#over-your-own-tables]

The module runs over an endpoint table your app already has. Map its table
and columns, and map an optional column to `null` when you don't want it:

```ts title="better-supabase.config.ts"
export default defineConfig({
  blocks: {
    "webhooks-in": {
      tables: { endpoints: "public.integration_hooks" },
      columns: {
        endpoints: {
          tenant: "organization_id",
          tokenHash: "token_digest",
          previousSecretId: null,
          previousSecretExpiresAt: null,
          receiveCount: null,
          lastReceivedAt: null,
          lastStatus: null,
          subjectType: null,
          subjectId: null,
        },
      },
    },
  },
});
```

The module then skips its `create table`, adds the columns it needs that
your table lacks, and writes its functions, policies, indexes and the Vault
cleanup trigger over it. `id`, `tenant`, `name`, `tokenHash`, `verify` and
`enabled` are required, and so is `secretId` (or `secret` with
`secretStorage: "column"`). Each optional column mapped to `null` turns off
what needs it: without the previous-secret columns a rotation with
`graceSeconds` fails with `WEBHOOK_IN_NO_GRACE`, without the subject columns
`subjects` can't be configured, without `metadata` endpoints take no
metadata, and without the counters `receive` records nothing.

The webhook inbox runs over a table of yours the same way, through
`blocks.webhook-inbox` with the `messages` table:

```ts title="better-supabase.config.ts"
"webhook-inbox": {
  tables: { messages: "public.inbound_events" },
  columns: {
    messages: { messageId: "external_id", tenant: "organization_id", checkpoint: null },
  },
},
```

`id` (a `bigint`), `source`, `messageId`, `payload`, `status`, `attempts`,
`availableAt`, `lockedBy`, `lockedUntil` and `receivedAt` are required.
`eventType`, `headers`, `maxAttempts` (8 when missing), `lastError`,
`processedAt`, `memberships` and `checkpoint` are optional; one mapped to
`null` reads as the managed table's default. The functions stay in
`better_supabase`, and `receive_webhook` takes a lock per message before it
looks the message up, so your table needs no particular unique
constraint, though one on source, tenant and message id is a good guard.

## Hooks and transports [#hooks-and-transports]

`createIncomingWebhooks` takes the service connection as `sql`, which
`receive` uses. The management calls (`create`, `list`, `update`, `rotate`,
`rotateSecret`, `setEnabled`, `remove`) go through `transport` when you pass
one, such as the member's `sqlTransport(postgres.asUser(claims))`, so
`webhooks.manage` is checked for the caller, and through `sql` otherwise.
`hooks` runs your code around those calls (see
[extending blocks](/docs/extending/blocks#hooks)).