Incoming webhooks
Trigger URLs a tenant hands to other systems, with hashed tokens, optional signatures, limits and an inbox.
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 with the
endpoint's tenant, so it is acknowledged fast and processed with retries.
better-supabase sql add webhooks-in # adds access, tenant and webhook-inboxEndpoints
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
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.
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 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
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 }):
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
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.
const changed = await hooks
.update(endpoint.id, {
metadata: { workflowId: nextWorkflowId },
verify: "hmac-sha256",
})
.orThrow();
// changed.secret is the new HMAC secret, shown onceChanging 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
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:
"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
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:
sql: {
modules: {
"webhooks-in": {
options: {
subjects: {
workflow: { table: "workflows", cascade: true },
integration: { table: "app.integrations", permission: "integrations.read" },
},
},
},
},
},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
Route the public URL to receive on a service connection:
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), 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
With the data-lifecycle module 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
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
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:
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>" }.
Last updated on
Outgoing webhooks
Signed webhooks to your customers' endpoints, with event subscriptions, retries, a queryable delivery log, secret rotation, URL checks and auto-disable.
API keys
Hashed API keys for tenants and users, with scopes, expiry, rotation, a rate limit per key and an apiKey caller in every server adapter.