Skip to content

Webhooks

A subscription delivers the events of resources as they happen: to a URL, to one of the app’s API functions, or to code of its own. Subscriptions are config: export them from the config module like resources, so the server has them and the admin UI never does. A URL or secret usually comes from the environment.

docs-examples/events/subscriptions.ts
import { subscription } from '@protobase/server'
// Where the events of docs-examples/events/orders.ts go: exported from the config module, like the resource.
/** A Slack message per order, to an incoming webhook whose URL is in the environment; off without it. */
export const newOrdersToSlack = subscription('newOrdersToSlack', {
events: ['orders.created'],
url: process.env.SLACK_ORDERS_WEBHOOK,
enabled: Boolean(process.env.SLACK_ORDERS_WEBHOOK),
body: ({ data }) => ({ text: `New order ${data.after?.number}: €${data.after?.total}` }),
})
/**
* Every change of an order to the app's own `sync-order` function, one at a time per order and in order, with only
* what an accountant may read, signed so the function checks it as it would a call from outside.
*/
export const ordersToAccounting = subscription('ordersToAccounting', {
events: ['orders.*'],
function: 'sync-order',
secret: process.env.ACCOUNTING_WEBHOOK_SECRET,
as: { roles: ['accountant'] },
ordered: true,
})
/** Code of the app's own, for each rush order; it throws to have the event tried again. */
export const rushOrders = subscription('rushOrders', {
events: ['orders.rush'],
run: async (event, { db }) => {
await db.insertInto('sales.pick_lists').values({ order_id: Number(event.data.key), priority: 'rush' }).execute()
},
})

subscription(name, options): the name is unique across the config and the configs it extends, letters, digits, _ and -. Each has one target:

Target
url A POST of the event as JSON. 2xx is a success; anything else, a redirect too, is a failure. The receiver has 15 seconds.
function One of the app’s API functions, called in this process with the same request a URL gets.
run (event, { db, attempt, signal }) => ..., run for each event; it throws to fail the try.
Option
events Event names (orders.created), every event of a resource (orders.*), or all of them (*). Each must match a declared event.
when A filter on the record after the change, or before it for a delete, in the language of access rules.
as: { roles } Delivers as these roles: only records they may read, and only fields they may read. Without it, every record and every field but the sensitive ones.
tenant Only events of this tenant’s records.
payload thin leaves after and before out, for a receiver that reads the record through the API. Default full.
ordered One delivery at a time per record, in commit order. Default false.
enabled Default true; false keeps the subscription in the config without delivering.
secret whsec_ and base64: signs the requests of a URL or function target. Without it they are unsigned. newWebhookSecret() makes one.
headers Extra request headers, such as an API key the receiver wants.
body (event) => ...: what is sent instead of the event, such as Slack’s { text }.

The server does not start while a subscription names an event no resource declares, has a when that does not check against a resource it covers, calls a function the app lacks, or is enabled with an empty URL.

POST /hooks/protobase
content-type: application/json
webhook-id: evt_48211_large
webhook-timestamp: 1791666245
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{ "id": "evt_48211_large", "type": "orders.large", "timestamp": "2026-10-10T21:04:05.123Z",
"data": { "resource": "orders", "operation": "update", "key": "7", "sequence": "48211",
"after": { ... }, "before": { ... }, "changed": ["total"], "tenant": 1,
"actor": { "id": "u_12", "roles": ["sales"] }, "source": "api", "transaction": "753" } }
  • webhook-id is the same on every retry: a receiver that keeps the ids it handled ignores a second copy.
  • The signature follows Standard Webhooks: an HMAC-SHA256 of id.timestamp.body with the secret. A Protobase function checks it with verifyStandardWebhook, and the Standard Webhooks libraries do the same elsewhere.
  • Records use field names and values as GET /orders/{key} returns them, never sensitive fields. changed lists sensitive fields that changed, by name only.
  • sequence grows with every change of a record, so a receiver can drop an event older than one it already has.
  • actor is the caller of a write through the API, null for any other write (source: "database").

A function target is called as the subscription’s as, so context.caller() and its records are those roles; without as, as a caller without roles.

  • The first try goes out from the dispatch job, right after the write: within milliseconds for a write through the API, within two seconds for one from elsewhere.
  • A failed try becomes a webhooks.deliver job, retried on the Standard Webhooks schedule with up to 10% jitter: 5 s, 5 m, 30 m, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h, 10 tries over about three days. A Retry-After header makes the wait longer, up to a day.
  • 410 Gone fails the delivery at once.
  • After the last try the job is kept as failed, on the failed jobs page, where it can be retried.
  • Ordered subscriptions get no first try from the dispatch job: a webhooks.deliver-ordered job delivers a record’s events of one dispatch in order, retried with doubling waits from 5 s up to a day, 10 tries. Meanwhile the record’s later events to that subscription wait. A retry sends again the events of that job that went through before the one that failed.
  • At least once: a process that stops after delivering but before recording it delivers again when the dispatch job is retried, under the same webhook-id.
  • Event code that throws, such as a match that reads a field it did not expect to be empty, a subscription’s when or an access rule of its as roles, keeps that change as a failed webhooks.dispatch-change job on the failed jobs page, naming the event or subscription and the error, and dispatch moves on to later changes. Retry it once the code is fixed to deliver the change’s events. A database error instead fails the whole dispatch job, which is retried.

Every outcome is a line in the server log, such as webhook newOrdersToSlack orders.created evt_48211_created delivered (200).

A subscription in the config suits a developer. For people who make their own at runtime, such as a tenant admin wiring their organization to a tool they use, extend the config with webhooks():

import { defineConfig, webhooks } from '@protobase/server'
export default defineConfig({ extends: [webhooks()], config, auth, authenticate })

It adds two pages, over a webhooks schema that protobase migration writes (NNN_webhooks.sql):

  • Webhook subscriptions (webhook_subscriptions, the table webhooks.subscriptions): a name, the events (orders.created orders.large, or orders.*), the URL, a filter, extra headers, full or thin payload, and whether it is enabled. The server sets the tenant and the owner, the person who made it, and makes the secret, which only a reveal shows. Saving checks the events and the filter against the resources, and refuses a URL whose host is a private, loopback or link-local address.
  • Webhook deliveries (webhook_deliveries, a view over the delivery jobs): the deliveries that wait for their next try or failed for good, with their last error. A delivery leaves the list once it succeeds.

Both are scoped to the tenant like any resource with .tenant(), and their access decides who may do what: webhook_subscriptions.create, webhook_deliveries.update and so on, as for the app’s own resources.

A subscription made here delivers as its owner, with the role they act with now in its tenant (the active role they last chose, else their first): only records and fields it may read. Letting people make subscriptions therefore grants them nothing they could not read already. The roles are read from the project’s Better Auth; a project that signs people in otherwise passes webhooks({ ownerRoles: async ({ id, tenant }) => [...] }).

Action
Send test event Sends the first event the subscription takes, made up from the newest record it may read, with "test": true.
Resend failed Tries each delivery that failed for good once more. Webhook deliveries has the same for one delivery.
Rotate secret Makes a new secret. For 24 hours requests carry a signature with each, so the receiver can switch over.
Disable Stops its deliveries; waiting ones fail when their turn comes. Enabling it again starts afresh.

A subscription is disabled by itself when its receiver answers 410 Gone, or when no delivery succeeded for three days; the page says why.

webhooks(options)
schema The schema of its tables, for a database that has a webhooks schema already. Default webhooks.
deliveryLog Also keeps every try, successes too, for 30 days, listed as Webhook delivery log: status, try, status code, the first 2 KB of the answer and how long it took. Default false.
ownerRoles The roles an owner holds now in a tenant, for a project without Better Auth.
allowPrivateAddresses Lets URLs reach private addresses, for development and tests. Default false.