# 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/webhooks-in

Automation products let 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/jobs#webhook-inbox) with the
endpoint's tenant, so it is acknowledged fast and processed with retries.

```bash
better-supabase sql 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
`sql.modules.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(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"
sql: {
  modules: {
    "webhooks-in": {
      options: {
        subjects: {
          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(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,
`sql.modules.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. The endpoint table's columns can't be renamed in
`sql.modules.webhooks-in.columns`.

### 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-bs-endpoint-id` header:

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

const inbox = createWebhookInbox(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>" }`.