Skip to content

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.

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.

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.

  1. List the type in PRODUCT_WEBHOOK_EVENT_TYPES in packages/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.

  2. 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 };
    }
    }
  3. Call emitEvent where 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.

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_id is 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), a user-agent of Starterdough-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.

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.

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.ts lists 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.

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_SECRET makes 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.

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.

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:

  • emitEvent records nothing, for any event: no message, no delivery, no job. It returns null.
  • 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, redeliver and rotateSecret answer WEBHOOKS_DISABLED (403), after the tenant guard, so a member without webhook: manage still gets FORBIDDEN. webhooks.list, deliveries and delete still work, so you can clean up the endpoints left over.
  • webhooks.list answers webhooksEnabled: 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.

  1. Set WEBHOOK_ALLOW_PRIVATE_URLS=true in .env and restart bun run dev:app.
  2. 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'); } })".
  3. Add an endpoint for http://localhost:4000/hook and press Send test event. The delivery log shows the attempt; verify the signature with the standardwebhooks package as the customers’ page shows.
  4. Stop the receiver and send another event to watch the retries, then start it and press Redeliver.