Webhooks
An organization’s owners and admins add webhook endpoints under Organization → Webhooks, and
the API sends each event they subscribe to as a signed HTTPS POST: a member joined, a workspace
was created, a document was uploaded, an AI job finished. This page is for you, the developer. The
page your customers read is apps/docs/src/content/product/reference/webhooks.md, which the kit
wrote once and you own: it has the event catalog, the envelope, the headers and how to verify a
signature, so edit it as your product adds events.
The pieces:
| Piece | Where |
|---|---|
| The tables | webhook_endpoint, webhook_message, webhook_delivery (migration 0014); packages/db/src/outbound-webhooks.ts |
| The events and their payloads | WebhookEventMap in @repo/db; WEBHOOK_EVENT_TYPES in packages/api-contract/src/b2b.ts, plus your PRODUCT_WEBHOOK_EVENT_TYPES |
| Emitting one | emitEvent(organizationId, type, data) from @repo/db |
| Managing endpoints | webhooks.* in apps/api/src/rpc/webhooks.ts; the pages under /app/organization/webhooks |
| Delivering | the internal job kind webhook.deliver, apps/api/src/webhooks/ |
They are not to be confused with webhook_event, which dedupes the webhooks Stripe sends us.
Events
Section titled “Events”An event is a JSON envelope, { id, type, version, timestamp, organizationId, data }, whose
data WebhookEventMap types. The kit emits these:
| Event | Emitted by |
|---|---|
member.added, member.removed, member.role_updated |
the Better Auth hook (packages/auth/src/audit-hook.ts): accepted invitations, auth.api.addMember, removals, leaving, role changes |
workspace.created, workspace.updated, workspace.deleted |
the workspace procedures |
document.uploaded, document.deleted |
documents.complete and documents.delete |
job.succeeded, job.failed |
the worker, for the kinds customers see, once a job succeeds or fails with no attempt left |
subscription.updated |
the Stripe subscription callbacks (packages/auth/src/billing.ts) |
webhook.ping |
Send test event (webhooks.test) only; no endpoint subscribes to it |
version is 1. Within it, fields are only added, to the envelope and to any data; a change that
removes, renames or redefines one is a new version. Keep to that for your own events too: a
receiver ignores a field it does not know, and breaks on one that disappears.
The body is stored as the exact string that is signed (webhook_message.body is text, not
jsonb), so a retry or a redelivery sends the same bytes with the same webhook-id.
Emitting an event
Section titled “Emitting an event”Call emitEvent once the action has committed, next to its recordAudit:
await recordAudit({ organizationId, ...auditActor(context), action: 'note.create', … });await emitEvent(organizationId, 'note.created', { id: note.id, workspaceId: note.workspaceId });It looks up the organization’s enabled endpoints subscribed to the type and, in one transaction,
stores one message and a delivery and a job per endpoint. With no such endpoint it writes nothing.
Like recordAudit it never throws: a failure is logged (webhook event not queued) and the action
stands. So emission is at most once (the process can stop between the action and the call) and
delivery at least once (an attempt can be repeated), and receivers dedupe on webhook-id. Do not
call it inside the action’s transaction: it runs on its own connection, and an event about a
change that then rolls back cannot be taken back.
Adding an event of your own
Section titled “Adding an event of your own”-
List the type in
PRODUCT_WEBHOOK_EVENT_TYPESinpackages/api-contract/src/product-webhooks.ts, which kit releases never change. Endpoints can then subscribe to it, and the Webhooks page offers it by its name. -
Type its payload by extending
WebhookEventMap, in a file of yours such as your feature’s queries:declare module '@repo/db' {interface WebhookEventMap {'note.created': { id: string; workspaceId: string };}} -
Call
emitEventwhere it happens, and add the event to your customers’ webhooks page.
apps/api/src/webhooks/handler.ts checks at compile time that the two lists name the same types,
so bun run check fails while you have done one without the other.
Delivery
Section titled “Delivery”Each attempt is one job of the kit’s internal kind webhook.deliver (kitJobs in
apps/api/src/jobs/handlers.ts). It is not in JOB_KINDS, so no customer sees it in the Jobs
list or through jobs.*, and it is free. An attempt:
- loads the delivery, and does nothing if it is gone, finished, or names another job: a delivery’s
job_idis the one job allowed to act, so a job the stale sweep re-runs after it already queued the next attempt cannot start a second retry chain; - abandons the delivery if the endpoint was switched off meanwhile;
- posts the body with the Standard Webhooks headers (
webhook-id,webhook-timestamp,webhook-signature), auser-agentofStarterdough-Webhooks/1, and a 10-second limit; - records the answer, and in the same transaction either marks the delivery done or queues the next attempt at its backoff: 30 s, 2 min, 10 min, 30 min, 2 h, 6 h and 12 h, eight attempts over about 21 hours.
A failed attempt is not a failed job; the job throws only when that bookkeeping fails, and the
queue’s own retries cover that. When ten deliveries in a row have used up their attempts, the
endpoint switches itself off (disabled_reason = 'failing') and the audit log records
webhook.disable with no actor. Any success resets the streak, and so does switching the endpoint
on again. Redeliver (webhooks.redeliver) queues a new delivery of the same message.
A platform admin can switch off every endpoint of one organization at once, with its API keys
(admin.organizations.revokeMachineAccess, see Admin): each is off by hand
(disabled_reason = 'manual') and recorded as webhook.disable with the admin as the actor and
by: 'platform-admin'. Its pending deliveries end without an attempt, as for any endpoint
switched off.
Delivery lanes
Section titled “Delivery lanes”Deliveries never take one of the worker’s WORKER_CONCURRENCY slots, so documents and jobs never
wait behind them. They run on lanes of their own (lanes on the webhook.deliver definition,
apps/api/src/webhooks/handler.ts): four at once in each worker process, at most one for any one
organization. The lanes take organizations in turns: a lane passes over an organization that
already has a delivery running, or has had its turn, and takes the next organization’s, until
every organization with a delivery due has had one. So one tenant’s slow or dead receivers delay
its own webhooks, however many of its events are queued, and anyone else’s by at most one attempt.
A receiver that never answers holds its lane for 10 seconds per attempt, and no longer:
postWebhook settles every attempt by that deadline whatever the receiver sends, and the worker’s
JOB_TIMEOUT_MS cancels it sooner if that is shorter. One whose host cannot be reached at all is
given up after 5 seconds (WEBHOOK_CONNECT_TIMEOUT_MS). When many organizations’ receivers hang
at once, an organization’s next delivery waits for one attempt of each of them, four at a time:
with twelve, about three rounds of 10 seconds. If your customers run many endpoints, run more
worker processes (bun run --cwd apps/api worker, the worker compose profile), each with lanes
and turns of its own.
What an endpoint may reach
Section titled “What an endpoint may reach”An organization admin chooses the URL and reads the first 1 KiB of every answer, so a webhook that could reach your own network would hand them Postgres, the AI service and the cloud’s metadata endpoint. So:
- the URL must be
https://, without a user name or password; - saving refuses a host that is a private, loopback, link-local or otherwise non-public address,
or
localhost; - every attempt resolves the host again, refuses it if any of its addresses is non-public, and
connects only to the addresses it checked, so a DNS answer that changes after the check cannot
redirect it (
apps/api/src/webhooks/url.tslists the ranges); - redirects are never followed, nor is a protocol upgrade (
101 Switching Protocols), and a fresh connection is made each time.
WEBHOOK_ALLOW_PRIVATE_URLS=true lifts the address checks and allows http://, for a receiver on
your own machine. The API refuses to start with it in production.
Signing secrets
Section titled “Signing secrets”Each endpoint has a whsec_ secret, shown once by webhooks.create and webhooks.rotateSecret.
It has to be read back to sign, so it is stored encrypted rather than hashed: AES-256-GCM under a
key derived from BETTER_AUTH_SECRET, so a database backup alone cannot forge deliveries.
- Rotating
BETTER_AUTH_SECRETmakes every stored webhook secret unreadable. Deliveries then fail with a message saying so; rotate each endpoint’s secret and give it to its receiver. - Rotation has no overlap: pending retries are signed with the new secret at once. Tell customers to update the receiver as soon as the new secret is shown, then redeliver what failed.
Who may do what
Section titled “Who may do what”webhook: read lists endpoints and deliveries and webhook: manage changes them; owners and
admins hold both. No API key can reach webhooks.*. Creating, changing, deleting and rotating
are audited (webhook.create, webhook.update, webhook.delete, webhook.secret_rotate), and
the secret never appears in the log. The public demo refuses webhooks.create, update, test,
redeliver and rotateSecret, so its server never calls a URL a visitor chose; a deployment with
webhooks switched off refuses the same (below). An organization
can have 10 endpoints. Messages and their deliveries are purged after 30 days, with finished
jobs.
Switching webhooks off
Section titled “Switching webhooks off”WEBHOOKS_ENABLED=false switches outbound webhooks off for the whole deployment, every
organization at once: on a server with open sign-up, say, where you would rather no stranger could
make it send requests. It is true by default. While it is off:
emitEventrecords nothing, for any event: no message, no delivery, no job. It returnsnull.- A delivery already queued is settled by its next attempt as failed, with the error “Webhooks are switched off on this server.”, and nothing is sent. Switching webhooks on again does not resume it; Redeliver it then if it should still go out.
webhooks.create,update,test,redeliverandrotateSecretanswerWEBHOOKS_DISABLED(403), after the tenant guard, so a member withoutwebhook: managestill getsFORBIDDEN.webhooks.list,deliveriesanddeletestill work, so you can clean up the endpoints left over.webhooks.listanswerswebhooksEnabled: false. The Webhooks page then shows a notice where it offered Add endpoint, and disables the actions the API refuses; an endpoint’s page does too.
Endpoints keep their own on or off state. Each process reads the variable when it starts, so restart the API and any separate worker after changing it: a worker still running with webhooks on goes on delivering.
Local development
Section titled “Local development”- Set
WEBHOOK_ALLOW_PRIVATE_URLS=truein.envand restartbun run dev:app. - Run a receiver, for example
bun -e "Bun.serve({ port: 4000, async fetch(r) { console.log(r.headers.get('webhook-id'), await r.text()); return new Response('ok'); } })". - Add an endpoint for
http://localhost:4000/hookand press Send test event. The delivery log shows the attempt; verify the signature with thestandardwebhookspackage as the customers’ page shows. - Stop the receiver and send another event to watch the retries, then start it and press Redeliver.