Incoming webhooks
Trigger URLs a tenant hands to other systems, with hashed tokens, optional signatures, limits and an inbox.
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 with the
endpoint's tenant, so it is acknowledged fast and processed with retries.
better-supabase 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
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.
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 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:
blocks: {
"webhooks-in": {
on: {
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({
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), 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. To keep endpoints in a table of your own, see
Over your own tables.
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-bsb-endpoint-id header:
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
destination decides where a verified delivery goes. The default,
inboxDestination, stores it in the webhook inbox as above.
jobsDestination enqueues it on a jobs queue instead,
once per endpoint and message id, with the endpoint's tenant as the job's
context:
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 checks that a
destination reports a repeated endpoint and message id as a duplicate.
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:
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:
"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
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).
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.