# Use with AI agents > Give coding agents the docs as Markdown, an Agent Skill, an MCP server, the rules they most often get wrong, and prompts that work. Source: https://good-webhooks.dev/docs/ai Every page is available as plain Markdown. Coding agents read it more reliably than HTML. | Resource | What it is | | ---------------------------------- | ------------------------------------------------------------------------------------------ | | [`/llms.txt`](https://good-webhooks.dev/llms.txt) | An index of every page, grouped by section, with links to the Markdown versions | | [`/llms-full.txt`](https://good-webhooks.dev/llms-full.txt) | All the docs in one Markdown file | | `/docs/.md` | Any page: add `.md` to its URL, for example [`/docs/quick-start.md`](https://good-webhooks.dev/docs/quick-start.md.md) | | `Accept: text/markdown` | Any page URL returns Markdown when the request prefers it | | **Copy Markdown** and **Open in** | Buttons at the top of every page. Send the page to ChatGPT, Claude, Cursor, and others | The Markdown includes the type-checked example files and the generated type tables, so an agent sees the same code you do. The docs describe the latest beta; pin that version. ## Install the skill [#install-the-skill] The package ships an [Agent Skill](https://agentskills.io) with setup steps, code templates, and the rules below. It lives in `node_modules/good-webhooks/skills/good-webhooks/`, so it always matches your installed version. **From GitHub** ```sh npx skills add pplytas/good-webhooks ``` **From node_modules** Install skills from every installed package that ships one: ```sh npx skills experimental_sync ``` **Manually** Copy the folder into your agent's skills directory, for example for Claude Code: ```sh mkdir -p .claude/skills cp -R node_modules/good-webhooks/skills/good-webhooks .claude/skills/ ``` ## Connect the MCP server [#connect-the-mcp-server] `https://good-webhooks.dev/api/mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server with three tools: `list_pages`, `get_page`, and `search`. No sign-in needed. **Claude Code** ```sh claude mcp add --transport http good-webhooks https://good-webhooks.dev/api/mcp ``` **Cursor** ```json title=".cursor/mcp.json" { "mcpServers": { "good-webhooks": { "url": "https://good-webhooks.dev/api/mcp" } } } ``` **VS Code** ```json title=".vscode/mcp.json" { "servers": { "good-webhooks": { "type": "http", "url": "https://good-webhooks.dev/api/mcp" } } } ``` ## Or add the rules to your project [#or-add-the-rules-to-your-project] Without the skill, paste this into your `AGENTS.md`, `CLAUDE.md`, or your editor's rules file. It points the agent at the docs and lists the mistakes agents make most often with this library. ```md title="AGENTS.md" ## Good Webhooks Docs: https://good-webhooks.dev/llms.txt (fetch the linked .md pages for details) - Receivers must pass the raw request body (string or bytes) to `parseWebhook()`. Never parsed JSON. - Receivers must record `event.id` in the same transaction as their change. Delivery is at least once. - Respond 400 only for SIGNATURE_INVALID, SIGNATURE_EXPIRED, PAYLOAD_INVALID. Other errors are 500 so the sender retries. - `publish()` only stores the event. Nothing is sent unless a worker runs `worker.run()` or `worker.runOnce()`. - Load `WEBHOOK_ENCRYPTION_KEY` from secrets. Never call `generateEncryptionKey()` at startup. - Apply SQL from `getPostgresMigration()` with the migration tool. The library never runs migrations. - `forScope()` does not authorize. Check the caller's access before choosing a scope. - Publish with `{ transaction: client }` when the business write uses the same database. - `allowLocalhost: true` is for local development only. ``` ## Prompts to try [#prompts-to-try] **Try it** ```text Read https://good-webhooks.dev/docs/quick-start.md and follow it in a new directory. Run the script and show me the output. ``` **Add to an app** ```text Read https://good-webhooks.dev/llms.txt, then the installation, publish-events, and run-workers pages. Add Good Webhooks to this app: migration file, client module, publish an `order.created` event inside the transaction that creates the order, and a separate worker entrypoint. Follow the production checklist page. ``` **Add a receiver** ```text Read https://good-webhooks.dev/docs/guides/receive.md. Add a webhook route to this app for the events in events.ts. Verify with parseWebhook, deduplicate by event.id in the same transaction as the change, and map errors to status codes as the page describes. ``` ## Source [#source] The docs source is in [`apps/docs/content/docs`](https://github.com/pplytas/good-webhooks/tree/main/apps/docs/content/docs). The public API is in [`packages/good-webhooks/src`](https://github.com/pplytas/good-webhooks/tree/main/packages/good-webhooks/src) and ships with type declarations. # Database adapters > Which Better Auth adapters the plugin is verified against, what the tests cover, and what they do not. Source: https://good-webhooks.dev/docs/better-auth/database-adapters The plugin stores endpoints through Better Auth's public adapter interface, so it works with the adapter you already use. Delivery never queries the plugin's table directly. The baseline is Better Auth **1.7.7** on Node.js 24. **Short version:** SQLite, PostgreSQL, MySQL, MongoDB, SQL Server, libSQL, Cloudflare D1, Drizzle, and Prisma are all tested against real databases. SQL Server needs a UTF-8 collation. ## Verified profiles [#verified-profiles] Each profile runs against a real database, generates and applies the schema, renames the model and fields, and exercises the repository. No in-memory fake adapter is used. | Database or adapter | Test profile | ID modes | | ------------------- | ---------------------------------------------------- | ---------------------- | | SQLite | Node.js `DatabaseSync` | Default, serial, UUID | | PostgreSQL 16 | Native Kysely adapter | Default, serial, UUID | | MySQL 8.4 | Native Kysely adapter | Default, serial, UUID | | MongoDB 8 | Native MongoDB adapter, transactions disabled | Default ObjectId, UUID | | SQL Server 2022 | Native Kysely adapter, UTF8 collation | Default, serial, UUID | | libSQL | Local libSQL client through Kysely | Default, serial, UUID | | Cloudflare D1 | Local Miniflare/workerd binding | Default, serial, UUID | | Drizzle | PostgreSQL, schema generation and `drizzle-kit push` | Default, serial, UUID | | Prisma | PostgreSQL, schema generation and `db push` | Default, serial, UUID | The matrix checks concurrent creates competing for the last endpoint slots, complete enumeration of 1,000 endpoints regardless of adapter page size, concurrent revision-guarded updates with one winner, atomic secret and expiry writes, deletion with slot reuse, 100 subscriptions of maximum-length names, and maximum-length Unicode scopes, URLs, and descriptions. > **Warning: SQL Server needs a UTF8 collation.** Better Auth generates `varchar` columns. A legacy non-UTF8 collation corrupts Unicode scope IDs, > URLs, and descriptions. The matrix uses `Latin1_General_100_CI_AS_SC_UTF8`. ## Not covered [#not-covered] Bun's SQLite, hosted D1, remote Turso, MongoDB replica-set transactions, and Drizzle or Prisma on databases other than PostgreSQL have not been exercised. Passing the matrix is not a deployment or performance certification. Apply schema changes with your usual migration workflow before serving requests. ## How storage works [#how-storage-works] You only need this when reading the generated schema or debugging an adapter. **Slots, scope digests, batched lookup, and guarded writes** * **Slots.** Each live endpoint occupies one of 1,000 deterministic slots per scope. A unique index on `allocationKey` makes creation atomic, even under concurrent creators. Deletion releases the slot in the same conditional write and keeps a tombstone. * **Scope digest.** Slot keys use a SHA-256 digest of the scope. Reads verify the full scope identity afterwards and fail closed on a mismatch. A case-insensitive database comparison is never treated as equality. * **Batched lookup.** Recipient lookup reads 50 slot keys at a time, which avoids offset-pagination gaps under concurrent deletion and stays under D1's parameter limit. Any failed batch fails the lookup. * **Guarded mutations.** Updates use Better Auth's `incrementOne` with a revision predicate. Retries are limited to uniqueness contention and known rollback errors; an ambiguous connection failure is returned rather than retried, because retrying could create a duplicate. * **Subscription storage.** Two internal array fields hold up to 50 names each, so 100 subscriptions fit SQL Server's `varchar(8000)` array representation. This is a storage detail, not two public lists. Removing the unique index silently breaks the per-scope limit. Runtime validation does not replace correct migrations. ## Run the matrix yourself [#run-the-matrix-yourself] From the repository root: ```sh pnpm --filter good-webhooks test:adapters libsql,d1 pnpm --filter good-webhooks test:adapters postgres,mysql,mongodb,mssql,drizzle,prisma ``` Server profiles read `BA_MATRIX_POSTGRES_URL`, `BA_MATRIX_MYSQL_URL`, `BA_MATRIX_MONGODB_URL`, and `BA_MATRIX_MSSQL_URL`, accept loopback hosts only, and create and drop a random database per run. See the [contribution guide](https://github.com/pplytas/good-webhooks/blob/main/CONTRIBUTING.md) for the local service setup. ## Upstream contracts [#upstream-contracts] * [Adapter interface](https://github.com/better-auth/better-auth/blob/v1.7.7/packages/core/src/db/adapter/index.ts): `incrementOne`, conditional predicates, normalized IDs. * [SQL schema generation](https://github.com/better-auth/better-auth/blob/v1.7.7/packages/better-auth/src/db/get-migration.ts): column types per provider. * [MongoDB adapter](https://github.com/better-auth/better-auth/blob/v1.7.7/packages/mongo-adapter/src/mongodb-adapter.ts), [Drizzle adapter](https://github.com/better-auth/better-auth/blob/v1.7.7/packages/drizzle-adapter/src/drizzle-adapter.ts), [Prisma adapter](https://github.com/better-auth/better-auth/blob/v1.7.7/packages/prisma-adapter/src/prisma-adapter.ts). # Add delivery to Better Auth > Keep Better Auth as the source of endpoints and send events with the PostgreSQL delivery engine from a separate worker. Source: https://good-webhooks.dev/docs/better-auth/delivery Keep Better Auth for endpoint management and add Good Webhooks delivery on PostgreSQL. The two connect through `management.source`: 1. **Endpoints** (Better Auth): Users manage endpoints through the plugin, in your auth database. 2. **Publish** (your app): `delivery.publish()` asks the source who subscribes and stores deliveries in PostgreSQL. 3. **Deliver** (worker process): Rebuilds the same auth instance, resolves each endpoint, and sends. The app and the worker build the provider from the same auth configuration, so both must reach the same auth database. Complete the [plugin setup](https://good-webhooks.dev/docs/better-auth.md) first. ### Install delivery dependencies [#install-delivery-dependencies] npm pnpm yarn bun ```bash npm install pg zod ``` ```bash pnpm add pg zod ``` ```bash yarn add pg zod ``` ```bash bun add pg zod ``` ### Create delivery storage [#create-delivery-storage] Generate delivery-only SQL and apply it once with your migration runner. This creates the delivery tables in `public`; pass `schema` to [target another schema](https://good-webhooks.dev/docs/operations/migrations.md). ```sh node -e 'import("good-webhooks/migrations").then(m => process.stdout.write(m.getPostgresMigration({ component: "delivery" })))' > 001-webhook-delivery.sql psql "$DELIVERY_DATABASE_URL" --single-transaction -v ON_ERROR_STOP=1 -f 001-webhook-delivery.sql ``` Better Auth keeps owning the management model's migrations. The delivery SQL does not touch it. ### Construct the delivery engine [#construct-the-delivery-engine] ```ts title="delivery.ts" import { Pool } from 'pg' import { z } from 'zod' import { createBetterAuthManagement } from 'good-webhooks/better-auth' import { createDelivery } from 'good-webhooks/delivery' import { auth } from './auth.js' export const deliveryDatabase = new Pool({ connectionString: process.env.DELIVERY_DATABASE_URL }) const management = await createBetterAuthManagement(auth) // [!code highlight] export const delivery = createDelivery({ database: deliveryDatabase, source: management.source, // [!code highlight] events: { 'invoice.created': z.object({ invoiceId: z.string(), total: z.number().nonnegative() }), 'invoice.paid': z.object({ invoiceId: z.string() }), }, }) await delivery.check() ``` The provider uses Better Auth's adapter and encryption keys. It needs no session and no HTTP listener. ### Publish in the owner's scope [#publish-in-the-owners-scope] A personal endpoint belongs to `{ type: 'user', id }`. Publishing on the root instance uses application scope and will not reach it. ```ts const publisher = delivery.forScope({ type: 'user', id: user.id }) await publisher.publish({ type: 'invoice.created', data: { invoiceId: 'inv_123', total: 4200 }, idempotencyKey: 'invoice.created:inv_123', }) ``` Authorize the business action in your application before selecting the scope. The publication commits in PostgreSQL; it is not atomic with a Better Auth write. See [publish events](https://good-webhooks.dev/docs/guides/publish.md#publish-inside-your-transaction). ### Run a separate worker [#run-a-separate-worker] The worker rebuilds the auth instance and provider with the same options, then runs until signalled. The checked example splits auth, delivery, and worker setup into three modules: ```ts title="examples/better-auth/worker.ts" import { Pool } from 'pg' import { createExampleAuth, type ExampleAuthOptions } from './auth.js' import { createExampleDelivery } from './delivery.js' /** Explicit worker entry function. Importing this module does not start it. */ export async function runExampleWorker(options: { auth: ExampleAuthOptions deliveryDatabaseUrl: string signal: AbortSignal onError: (error: unknown) => void }) { // Use the app's same management filename, auth/plugin configuration, and retained secret keys. const { auth, database: managementDatabase } = createExampleAuth(options.auth) const deliveryDatabase = new Pool({ connectionString: options.deliveryDatabaseUrl }) try { const delivery = await createExampleDelivery(auth, deliveryDatabase) await delivery.check() await delivery.worker.run({ signal: options.signal, onError: options.onError }) } finally { await deliveryDatabase.end() managementDatabase.close() } } ``` An entrypoint wires signals and environment: ```ts title="start-worker.ts" import { runExampleWorker } from './worker.js' const stop = new AbortController() process.once('SIGTERM', () => stop.abort()) process.once('SIGINT', () => stop.abort()) await runExampleWorker({ auth: { managementFilename: process.env.AUTH_DATABASE_FILE!, baseURL: process.env.AUTH_BASE_URL!, secret: process.env.BETTER_AUTH_SECRET!, }, deliveryDatabaseUrl: process.env.DELIVERY_DATABASE_URL!, signal: stop.signal, onError(error) { console.error('Webhook worker failed:', error) process.exitCode = 1 stop.abort() }, }) ``` > **Warning: App and worker must share management storage.** With SQLite, both processes open the same persistent file. Two `:memory:` databases or two > container-local files do not share endpoints. With other adapters, both need the same database and > the same model configuration. Both also need the same current and retained Better Auth secrets. ## Next steps [#next-steps] - [Run workers](https://good-webhooks.dev/docs/operations/workers.md): Supervision, polling, and bounded shutdown. - [Key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md): Rotate Better Auth secrets without losing endpoint secrets. - [Billing example](https://good-webhooks.dev/docs/examples.md#better-auth-billing): A complete app with sessions, a worker, and a receiver. # Better Auth plugin > Let signed-in users manage their own webhook endpoints through authenticated routes, stored in your existing Better Auth database. Source: https://good-webhooks.dev/docs/better-auth The `goodWebhooks` plugin lets signed-in users manage their own webhook endpoints in your app. It adds one model and eight routes to your Better Auth instance, and stores endpoints with your existing database adapter. Creating an endpoint through the plugin: 1. **Call** (browser): `authClient.goodWebhooks.create({ url, eventTypes })` 2. **Authorize** (plugin): Checks the session, and the user's permission for the requested scope. 3. **Store** (your database): Saves the endpoint with an encrypted signing secret through your adapter. 4. **Show once** (browser): The response carries the signing secret. No later read does. The plugin manages endpoints only; it sends nothing. To send events, add the [built-in delivery](https://good-webhooks.dev/docs/better-auth/delivery.md) or a [custom sender](https://good-webhooks.dev/docs/guides/custom-senders.md). > **Note.** Want to see it work first? The [Better Auth quick start](https://good-webhooks.dev/docs/better-auth/quick-start.md) runs the > whole flow in one file with SQLite. ### Install [#install] npm pnpm yarn bun ```bash npm install good-webhooks@beta better-auth@1.7.7 ``` ```bash pnpm add good-webhooks@beta better-auth@1.7.7 ``` ```bash yarn add good-webhooks@beta better-auth@1.7.7 ``` ```bash bun add good-webhooks@beta better-auth@1.7.7 ``` Supports Better Auth `>=1.7.7 <1.8.0`; tested against 1.7.7. ### Add the server plugin [#add-the-server-plugin] ```ts title="auth.ts" import { betterAuth } from 'better-auth' import { goodWebhooks } from 'good-webhooks/better-auth' import { eventTypes } from './event-types.js' export const auth = betterAuth({ database, // your existing database or adapter secret: process.env.BETTER_AUTH_SECRET!, plugins: [goodWebhooks({ eventTypes })], // [!code highlight] }) ``` ```ts title="event-types.ts" export const eventTypes = ['invoice.created', 'invoice.paid'] as const ``` Keep the tuple in its own module so the browser can import it without pulling in server code. The `as const` is what carries the literal names into the client types. Management needs only names; payload validators belong to the sender. Mount `auth.handler` the way your framework already does. The routes appear under the handler's base path, for example `POST /api/auth/good-webhooks/create`. ### Generate and apply the schema [#generate-and-apply-the-schema] Use [Better Auth's CLI](https://better-auth.com/docs/concepts/cli) with the version that matches your installation: ```sh npx auth@1.7.7 migrate --config ./src/auth.ts # built-in SQL adapters npx auth@1.7.7 generate --config ./src/auth.ts # Drizzle or Prisma, then run their migrations ``` The plugin declares a `webhookEndpoint` model with a unique `allocationKey` index. Keep every generated field and that index; it enforces the per-scope endpoint limit under concurrent creates. MongoDB creates the index before writes. > **Warning.** Do not apply Good Webhooks' `management.sql` here. That file is for the standalone PostgreSQL > provider. Plugin initialization never applies migrations. ### Add the client plugin [#add-the-client-plugin] ```ts title="auth-client.ts" import { createAuthClient } from 'better-auth/client' import { goodWebhooksClient } from 'good-webhooks/better-auth/client' import { eventTypes } from './event-types.js' export const authClient = createAuthClient({ plugins: [goodWebhooksClient({ eventTypes })], }) ``` The client entrypoint has no runtime dependency on the server plugin, a database driver, or the worker. ### Use it [#use-it] **Browser** ```ts const { data, error } = await authClient.goodWebhooks.create({ url: 'https://receiver.example/webhooks', eventTypes: ['invoice.paid'], }) data?.secret // show once, then never again data?.endpoint.id const endpoints = await authClient.goodWebhooks.list({}) ``` **Server** Server-side calls go through `auth.api` with the request's session headers: ```ts const created = await auth.api.createWebhookEndpoint({ headers: request.headers, body: { url: 'https://receiver.example/webhooks', eventTypes: ['invoice.paid'] }, }) ``` An omitted `scope` means the signed-in user. Create and rotate responses include the signing secret; every other response omits it. Route responses use ISO timestamp strings. ## Options [#options] | Option | Default | Purpose | | ------------------ | ----------------- | ----------------------------------------------------------------------------------------------------- | | `eventTypes` | required | Configured event names. Pass a literal tuple for typed clients | | `authorizeScope` | deny | Policy for application scope and custom scope types. See [permissions](https://good-webhooks.dev/docs/better-auth/permissions.md) | | `allowLocalhost` | `false` | Permit HTTP loopback URLs in development | | `schema.modelName` | `webhookEndpoint` | Rename the model. Not a PostgreSQL namespace | | `schema.fields` | | Rename individual fields | Regenerate and apply the schema after changing `schema`. Where the model lives (database, schema, collection) is decided by your Better Auth database or ORM configuration, not by the plugin. ## Next steps [#next-steps] - [Permissions](https://good-webhooks.dev/docs/better-auth/permissions.md): Organization roles, application scope, and custom scope policies. - [Add delivery](https://good-webhooks.dev/docs/better-auth/delivery.md): Publish and send events with the PostgreSQL engine. - [Database adapters](https://good-webhooks.dev/docs/better-auth/database-adapters.md): Which adapters are verified and how. - [Plugin reference](https://good-webhooks.dev/docs/reference/better-auth.md): Routes, bodies, results, and error mapping. # Permissions > How the plugin authorizes personal, organization, application, and custom scopes on every route. Source: https://good-webhooks.dev/docs/better-auth/permissions Every plugin route requires a session, then checks whether the user may act in the requested [scope](https://good-webhooks.dev/docs/concepts/scopes.md): | Requested `scope` | Who may act | | ------------------------------ | -------------------------------------------------------------------- | | omitted | The signed-in user, in their personal scope | | `{ type: 'user', id }` | Only if `id` is the signed-in user | | `{ type: 'organization', id }` | Current members with the `webhookEndpoint` permission for the action | | `null` (application) | Only if your `authorizeScope` policy allows it. Denied by default | | any other `type` | Only if your `authorizeScope` policy allows it. Denied by default | Each route maps to one action: | Route under `/good-webhooks/` | Action | | -------------------------------------------- | -------- | | `create` | `create` | | `list`, `get` | `read` | | `update`, `pause`, `resume`, `rotate-secret` | `update` | | `remove` | `delete` | ## Organization endpoints [#organization-endpoints] Install Better Auth's organization plugin alongside `goodWebhooks` and add the `webhookEndpoint` resource to its access control: ```ts title="auth.ts" import { createAccessControl } from 'better-auth/plugins/access' import { organization } from 'better-auth/plugins/organization' import { adminAc, defaultStatements, memberAc, ownerAc, } from 'better-auth/plugins/organization/access' const ac = createAccessControl({ ...defaultStatements, webhookEndpoint: ['create', 'read', 'update', 'delete'] as const, // [!code highlight] }) export const auth = betterAuth({ // ... plugins: [ organization({ ac, roles: { owner: ac.newRole(ownerAc.statements), admin: ac.newRole({ ...adminAc.statements, webhookEndpoint: ['create', 'read', 'update', 'delete'], // [!code highlight] }), member: ac.newRole(memberAc.statements), }, }), goodWebhooks({ eventTypes }), ], }) ``` The organization's creator role (`owner` by default) gets full access automatically. Every other role needs an explicit grant. A role called `admin` has no webhook permissions just because of its name. Then pass the scope explicitly: ```ts await authClient.goodWebhooks.create({ scope: { type: 'organization', id: organizationId }, url: 'https://receiver.example/webhooks', eventTypes: ['invoice.created'], }) ``` Each request re-checks membership and permission. Removing a member removes their access; it does not pause the organization's endpoints. ## Application and custom scopes [#application-and-custom-scopes] Supply `authorizeScope` to allow application scope (`null`) or your own scope types. It receives the exact scope, the action, the user, and the session: ```ts goodWebhooks({ eventTypes, async authorizeScope({ scope, action, user, session }) { if (scope === null) return isPlatformAdmin(user) if (scope.type === 'project') return canManageProject(user, scope.id, action) return false }, }) ``` Return `false` for anything you do not recognize. The policy cannot loosen personal-scope or organization checks; those run regardless. Organization API keys do not authenticate these routes. User API-key session impersonation is not a supported path in this version. ## Trusted server access [#trusted-server-access] `createBetterAuthManagement(auth)` returns the provider the plugin uses internally. Its methods skip route authorization, which is what a worker needs: ```ts import { createBetterAuthManagement } from 'good-webhooks/better-auth' const management = await createBetterAuthManagement(auth) await management.list({ type: 'user', id: userId }) // no session required ``` Keep it in server code. If you expose any of it through your own API, authorize each request first. ## Owner deletion [#owner-deletion] Deleting a user retires their personal endpoints. Deleting an organization through the organization plugin retires its endpoints. The sender also checks that an owner still exists before delivering; a failed owner lookup is an error, not an empty result. # Better Auth quick start > Create a user, register a personal endpoint through authenticated routes, and list it. One file, SQLite, no PostgreSQL. Source: https://good-webhooks.dev/docs/better-auth/quick-start One script runs the plugin end to end against a SQLite file. No server, no PostgreSQL, and no webhook is sent: What the script checks: 1. **No session**: Listing endpoints without signing in returns `401`. 2. **Create**: A signed-in user creates an endpoint and gets its secret once. 3. **List**: The endpoint is listed, without its secret. 4. **Other scope**: Asking for application scope returns `403`. You need Node.js 24 and npm. ### Create a project [#create-a-project] ```sh mkdir good-webhooks-auth-quickstart && cd good-webhooks-auth-quickstart npm init -y ``` npm pnpm yarn bun ```bash npm install good-webhooks@beta better-auth@1.7.7 ``` ```bash pnpm add good-webhooks@beta better-auth@1.7.7 ``` ```bash yarn add good-webhooks@beta better-auth@1.7.7 ``` ```bash bun add good-webhooks@beta better-auth@1.7.7 ``` Generate an auth secret for this shell session: ```sh export BETTER_AUTH_SECRET="$(node -e 'console.log(require("node:crypto").randomBytes(32).toString("base64"))')" ``` ### Write the script [#write-the-script] Save as `quick-start.mjs`. It applies Better Auth's schema programmatically because this is a disposable SQLite database. A real application uses its [normal schema workflow](https://good-webhooks.dev/docs/better-auth.md#generate-and-apply-the-schema). ```js title="quick-start.mjs" import assert from 'node:assert/strict' import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import { betterAuth } from 'better-auth' import { getMigrations } from 'better-auth/db/migration' import { goodWebhooks } from 'good-webhooks/better-auth' const origin = 'http://localhost:3000' const options = { database: new DatabaseSync('./quick-start.sqlite'), baseURL: origin, secret: process.env.BETTER_AUTH_SECRET, emailAndPassword: { enabled: true }, plugins: [goodWebhooks({ eventTypes: ['invoice.created', 'invoice.paid'] })], } await (await getMigrations(options)).runMigrations() const auth = betterAuth(options) // Call a plugin route the way a browser would, with a JSON body and cookies. async function call(operation, body = {}, cookie = '') { return auth.handler( new Request(`${origin}/api/auth/good-webhooks/${operation}`, { method: 'POST', headers: { origin, 'content-type': 'application/json', cookie }, body: JSON.stringify(body), }), ) } // Sign up and keep the session cookie. const signup = await auth.api.signUpEmail({ body: { name: 'Quick start', email: `${randomUUID()}@example.com`, password: randomUUID() }, asResponse: true, }) const cookie = signup.headers .getSetCookie() .map((value) => value.split(';')[0]) .join('; ') // No session: rejected. assert.equal((await call('list')).status, 401) console.log('Anonymous list rejected with 401') // Create a personal endpoint. const created = await ( await call( 'create', { url: 'https://receiver.example/webhooks', eventTypes: ['invoice.created'] }, cookie, ) ).json() assert.match(created.secret, /^whsec_/) console.log('Created endpoint', created.endpoint.id, 'with a one-time secret') // List it. Secrets never appear in reads. const endpoints = await (await call('list', {}, cookie)).json() assert.equal(endpoints.length, 1) assert.equal('secret' in endpoints[0], false) console.log('Listed 1 endpoint without its secret') // Application scope needs an explicit policy. assert.equal((await call('list', { scope: null }, cookie)).status, 403) console.log('Application scope rejected with 403') await call('remove', { id: created.endpoint.id }, cookie) options.database.close() ``` ### Run it [#run-it] ```sh node quick-start.mjs ``` ```text Anonymous list rejected with 401 Created endpoint with a one-time secret Listed 1 endpoint without its secret Application scope rejected with 403 ``` `auth.handler()` receives real `Request` objects, so there is no HTTP server to start. On repeated runs Better Auth's SQLite inspector may warn that array fields use `TEXT` storage. That is expected. ### Clean up [#clean-up] Delete `quick-start.sqlite` when you are done. ## Next steps [#next-steps] - [Add the plugin to your app](https://good-webhooks.dev/docs/better-auth.md): Server plugin, schema, and client plugin in an existing auth instance. - [Permissions](https://good-webhooks.dev/docs/better-auth/permissions.md): Organization roles and custom scope policies. - [Add delivery](https://good-webhooks.dev/docs/better-auth/delivery.md): Send the events these endpoints subscribe to. # Delivery guarantees > At least once, no ordering. What a successful publish promises, how retries work, and how endpoint changes affect queued work. Source: https://good-webhooks.dev/docs/concepts/delivery-guarantees ## The rules [#the-rules] 1. **Accepted means stored.** When `publish()` returns, the event and its recipients are committed (or written in your transaction, committed when you commit). 2. **At least once.** The worker keeps trying each delivery until it succeeds, fails for good, or runs out of retries. A receiver may get the same event more than once. 3. **No ordering.** Events can arrive in any order. Retries, parallel workers, and replays all reorder. 4. **Recipients are fixed at publish.** Subscription changes affect later events only. 5. **Endpoint state is read fresh.** URL, secrets, and pause or delete take effect at the next attempt. Receivers handle rules 2 and 3 by [processing each `event.id` once](https://good-webhooks.dev/docs/guides/receive.md#process-each-event-once). ## A delivery's life [#a-deliverys-life] Delivery statuses: 1. **pending**: Waiting for a worker, or for its next retry time. 2. **in_flight**: A worker holds it and is sending the request. 3. **succeeded / failed / cancelled**: A `2xx`; a permanent failure or retries used up; or the endpoint was deleted. What happens after each attempt depends on the response: | Response | Result | | ----------------------------------------- | ------------------------------------------- | | `2xx` | `succeeded` | | Network error, `408`, `425`, `429`, `5xx` | Back to `pending` until the next retry time | | Any other `4xx` | `failed`, no retry | | `3xx` | `failed`. Redirects are never followed | ### Retry schedule [#retry-schedule] By default a delivery gets up to seven attempts within 24 hours: | Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | | ------- | --- | --- | --- | ---- | --- | ---- | ---- | | Waits | now | 1 s | 5 s | 30 s | 2 m | 10 m | 30 m | Change it with `retry: { delaysMs, maxAgeMs }` on `createWebhooks()` or `createDelivery()`. Every retry keeps the event ID and body and gets a fresh timestamp and signature. ## Publication [#publication] `matchRecipients()` runs once, when you publish. It returns every subscribed endpoint in the scope, paused ones included. The recipient list is stored with the event and never recomputed. * If endpoint management can't be reached, `publish()` throws. An outage never turns into "no recipients". * Endpoint edits made at the same moment may or may not be seen. There is no scope-wide snapshot. ### Idempotency [#idempotency] Publishing again with the same scope and `idempotencyKey` returns the original event with `duplicate: true`, without looking up recipients again. That works even if management storage is down. The same key with a different type or payload throws `IDEMPOTENCY_CONFLICT`. Keys stay reserved until the event is [pruned](https://good-webhooks.dev/docs/operations/retention.md). If you need deduplication for longer, keep your own record. ## Endpoint changes while work is queued [#endpoint-changes-while-work-is-queued] The worker reads current endpoint state before every attempt: | Change | Effect on queued deliveries | | --------------------- | -------------------------------------------------------------------------- | | Pause | Held until resume, or until the delivery passes its age limit | | Resume | Eligible deliveries send again | | Delete | Pending work is cancelled when the worker sees it. Secrets are cleared | | Change URL | The next attempt uses the new URL | | Change subscriptions | Later events only. Already accepted recipients stay | | Rotate signing secret | The next attempt signs with the current secrets (both, during the overlap) | A request that was already being sent when you paused or deleted can still arrive. Management calls can't recall it. If endpoint management is unreachable at attempt time, the delivery waits without using up an attempt, and the worker reports the error. An outage is never treated as a deletion. ## Transactions [#transactions] `publish(input, { transaction })` writes inside your PostgreSQL transaction, in a savepoint, so a failed publish doesn't break it. It covers delivery storage only. Finding recipients is a separate read, so a Better Auth or second-database write is not part of the commit. See [publish inside your transaction](https://good-webhooks.dev/docs/guides/publish.md#publish-inside-your-transaction). # How it works > Endpoint management decides who receives events. Delivery sends them. Your application connects the two and owns the processes. Source: https://good-webhooks.dev/docs/concepts/how-it-works Good Webhooks has two parts with separate storage: * **Endpoint management** decides *who* receives an event: URLs, subscriptions, status, and encrypted signing secrets. * **Delivery** decides *how* events get there: it stores events, sends signed requests from a worker, retries, and keeps history. Who does what: 1. **Endpoint management** (Better Auth or PostgreSQL): Your users register URLs and choose event types. 2. **EndpointSource** (the link): Two reads: who subscribes to this event, and where to send it now. 3. **Delivery** (PostgreSQL + worker): Stores events and deliveries, signs, sends, and retries. 4. **Receiver** (your customer): Verifies the signature and processes each event once. The split lets you mix and match. Manage endpoints with Better Auth and deliver with the PostgreSQL worker. Or keep your own sender and use only the endpoint management. ## Endpoint management [#endpoint-management] Two providers offer the same lifecycle API: create, update, pause, resume, rotate secret, remove. | Provider | Stores endpoints in | Who calls it | | ----------------------------------------------- | -------------------------------------- | ----------------------------------- | | [Better Auth plugin](https://good-webhooks.dev/docs/better-auth.md) | Your Better Auth database, any adapter | Signed-in users through HTTP routes | | [Standalone PostgreSQL](https://good-webhooks.dev/docs/guides/endpoints.md) | A `webhook_endpoints` table | Your server code | Management never creates events or deliveries. ## The link: EndpointSource [#the-link-endpointsource] Both providers expose `management.source` with the two reads a sender needs: | Method | Called | Returns | | ------------------------------ | -------------------------------- | -------------------------------------------------- | | `matchRecipients(scope, type)` | Once, when an event is published | Every subscribed endpoint ID, paused ones included | | `resolveEndpoint(scope, id)` | Before each delivery attempt | Current status, URL, and signing secrets | Because delivery asks again before every attempt, a paused, moved, or deleted endpoint takes effect at the next attempt. `resolveEndpoint` returns plaintext secrets. Keep `source` in trusted server code. ## Delivery [#delivery] The [delivery engine](https://good-webhooks.dev/docs/reference/delivery.md) works with four records: | Record | One per | Example | | ------------ | --------------------------- | -------------------------------------------------- | | **Event** | `publish()` call | `invoice.paid` for `inv_123`, with a UUID | | **Delivery** | event × subscribed endpoint | That event, bound for one customer's URL | | **Attempt** | HTTP request | The first try got a `503`; the second got `200` | | **Replay** | manual re-send | A new delivery of the same event, same ID and body | Delivery storage is always PostgreSQL. Management storage is separate: the same database, another schema, another database, or for Better Auth even SQLite, MySQL, or MongoDB. ## Putting them together [#putting-them-together] `createWebhooks()` builds both parts on one PostgreSQL database. That's the default setup. To combine them differently, build delivery yourself and hand it a source. This is also all a separate worker process needs: ```ts import { createBetterAuthManagement } from 'good-webhooks/better-auth' import { createDelivery } from 'good-webhooks/delivery' import { auth } from './auth.js' import { events } from './events.js' import { pool } from './db.js' const management = await createBetterAuthManagement(auth) const delivery = createDelivery({ database: pool, events, source: management.source }) ``` ## What you run [#what-you-run] Nothing starts by itself. Creating a client opens no connections, runs no migrations, and starts no workers. Your application: * applies the [migrations](https://good-webhooks.dev/docs/operations/migrations.md), * owns the PostgreSQL pool and closes it on shutdown, * runs the [worker](https://good-webhooks.dev/docs/operations/workers.md) and schedules [cleanup](https://good-webhooks.dev/docs/operations/retention.md), * checks who may act for which customer before choosing a [scope](https://good-webhooks.dev/docs/concepts/scopes.md). Next, see what a successful publish promises in [delivery guarantees](https://good-webhooks.dev/docs/concepts/delivery-guarantees.md). # Scopes > A scope keeps one customer's endpoints and events apart from everyone else's. It never grants access by itself. Source: https://good-webhooks.dev/docs/concepts/scopes Every endpoint and every event belongs to exactly one scope. An event only reaches endpoints in its own scope. That is how one installation serves many customers without one customer's events reaching another's endpoints. | Scope | Value | Typical owner | | ----------------- | ------------------------------ | ------------------------------ | | Application scope | `null` | Your platform itself | | Named scope | `{ type: 'user', id }` | One signed-in user | | Named scope | `{ type: 'organization', id }` | One organization or team | | Named scope | `{ type: 'project', id }` | Any grouping your domain needs | ## Who receives what [#who-receives-what] An event published in one scope reaches endpoints in that scope only. There is no inheritance and no fallback: | Event published in ↓ · Endpoint in → | application | user `u_1` | organization `o_1` | | ------------------------------------ | :---------: | :--------: | :----------------: | | application | ✓ | | | | user `u_1` | | ✓ | | | organization `o_1` | | | ✓ | Application scope is not a parent of the named scopes. To reach a customer's endpoints, publish in that customer's exact scope. ## Choosing a scope in code [#choosing-a-scope-in-code] Each API selects the scope in its own way: **createWebhooks** The root client uses application scope. `forScope()` returns the same methods bound to a named scope. ```ts import { webhooks } from './webhooks.js' await webhooks.publish({ type: 'invoice.paid', data }) // application scope const customer = webhooks.forScope({ type: 'organization', id: organizationId }) await customer.endpoints.create({ url, eventTypes: ['invoice.paid'] }) await customer.publish({ type: 'invoice.paid', data }) // reaches that organization's endpoints ``` **Management provider** Provider methods take the scope as their first argument. Pass `null` for application scope. ```ts await management.create(null, input) await management.create({ type: 'organization', id: organizationId }, input) ``` **Better Auth client** An omitted `scope` means the signed-in user. Other scopes are checked against the session. See [permissions](https://good-webhooks.dev/docs/better-auth/permissions.md). ```ts await authClient.goodWebhooks.create({ url, eventTypes }) // the signed-in user await authClient.goodWebhooks.create({ scope: { type: 'organization', id: organizationId }, url, eventTypes, }) ``` ## Scopes don't authorize [#scopes-dont-authorize] > **Warning.** `forScope()` and the provider methods trust the scope you pass. Check that the caller may act for > that customer **before** choosing the scope. Never pass an unchecked ID from a request body. The Better Auth routes are the exception: they check the session, organization membership, and permissions on every request. The trusted provider from `createBetterAuthManagement()` skips those checks, which is what a worker needs. ## Rules and limits [#rules-and-limits] * `type` is 1 to 64 characters and `id` is 1 to 200. Neither may be blank or contain null bytes. * The same `id` under two `type` values is two different scopes. * A scope holds at most 1,000 live endpoints. * Idempotency keys are unique per scope, not globally. ## When an owner goes away [#when-an-owner-goes-away] The Better Auth plugin retires a user's endpoints when the user is deleted, and an organization's endpoints when the organization is deleted. With standalone management, call `removeScope(scope)` on a `createPostgresManagement()` provider from your own deletion workflow. See [manage endpoints](https://good-webhooks.dev/docs/guides/endpoints.md#when-a-scope-owner-leaves). # Security model > Signing secrets, the storage encryption key, destination checks, and the boundaries your application must enforce. Source: https://good-webhooks.dev/docs/concepts/security ## Two kinds of secrets [#two-kinds-of-secrets] | Secret | Held by | Used for | | ------------------------------ | ------------------------------------------------- | --------------------------------------------------- | | **Signing secret** (`whsec_…`) | The receiver, and endpoint management (encrypted) | Signing each request. The receiver verifies with it | | **Storage encryption key** | Your app and worker processes | Encrypting signing secrets in management storage | Standalone PostgreSQL management takes a 32-byte storage encryption key from `generateEncryptionKey()`. Better Auth management uses the auth instance's `secret` or versioned `secrets` instead. `create()` and `rotateSecret()` return the plaintext signing secret once. Reads never include it. Give it to the receiver over a secure channel and never log it. Rotating a signing secret changes what the receiver verifies, with an overlap. Rotating the storage encryption key re-encrypts stored values and leaves receivers alone. See [key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md). ## How a request is protected [#how-a-request-is-protected] 1. **Register** (management): HTTPS only, no credentials in the URL, no unsafe IP literals. 2. **Connect** (worker): Resolve DNS again, reject private addresses, pin the checked address. 3. **Sign** (worker): HMAC-SHA256 over the ID, timestamp, and exact body. Redirects refused. 4. **Verify** (receiver): Signature must match and the timestamp must be recent (five minutes by default). ### Signing [#signing] Requests follow the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) specification: ```http webhook-id: 7a0c3f3e-5b2c-4f8e-9d1a-2e6b8c4d9f10 webhook-timestamp: 1760000000 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj6D2hFcTjqRVo= ``` The signature is HMAC-SHA256 over `{id}.{timestamp}.{body}`. During a secret rotation the header carries one signature per usable secret, and `parseWebhook()` accepts the request if any of them matches. ### Destination checks [#destination-checks] Standalone PostgreSQL management also resolves DNS when an endpoint is registered. Registration checks alone are not enough, because DNS can change later. That's why the worker resolves the hostname again before every connection, rejects private and otherwise unsafe addresses, and connects to the address it checked. `allowLocalhost: true` permits plain-HTTP loopback destinations for local development. It opens no other private ranges. [Custom senders](https://good-webhooks.dev/docs/guides/custom-senders.md) must implement the same protection themselves. ## What you must enforce [#what-you-must-enforce] * **Authorization.** Scopes keep customers apart; they don't check permissions. Check the caller before `forScope()` or any provider method. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md). * **Trusted sources.** `management.source` returns plaintext signing secrets. Keep it in server code. * **Receiver body limits.** Bound the request body before buffering it. `parseWebhook()` rejects bodies over 512 KiB, but only after your server has read them. * **Receiver deduplication.** Verification proves who sent the bytes. It doesn't record which events you've already processed. * **Delivery history.** Stored payloads and response bodies may contain customer data. Treat them like any other application data. ## Migrations [#migrations] Constructors and `check()` never change the schema. The shipped SQL creates `webhook_`-prefixed tables in `public`, or in a schema you choose. Better Auth management uses Better Auth's own schema workflow. See [migrations](https://good-webhooks.dev/docs/operations/migrations.md). # Examples > Two complete applications that install the published package and show the full path from a business action to a verified webhook. Source: https://good-webhooks.dev/docs/examples Both applications live in the repository under [`examples/`](https://github.com/pplytas/good-webhooks/tree/main/examples). They are independent npm projects that install `good-webhooks` from npm, so you can copy either one into your own repository. - [Order fulfillment](https://github.com/pplytas/good-webhooks/tree/main/examples/order-fulfillment): A shop sends orders to a warehouse. Transactional publication, retries against a failing receiver, replay, and shipment deduplication. - [Better Auth billing](https://github.com/pplytas/good-webhooks/tree/main/examples/better-auth-billing): Users sign in, register their own endpoints, and receive invoice events. Real sessions, per-user scopes, and a separate worker. ## Order fulfillment [#order-fulfillment] Northstar Supply is a shop connected to a warehouse. Place an order in the browser and watch the worker deliver it. * The shop commits the order row and the webhook publication in **one PostgreSQL transaction**. * The warehouse receiver verifies the signature and records the event ID with its shipment. * Controls let you make the warehouse return `503`, inspect retries, restore it, and **replay** a delivery. Replaying an accepted event leaves the shipment count unchanged. * A rollback control shows that a failed business transaction produces neither an order nor a queued event. ## Better Auth billing [#better-auth-billing] A billing application that combines Better Auth endpoint management with PostgreSQL delivery. * Sign in, register a personal endpoint, create invoice events, and inspect delivery attempts. * The browser uses the typed client plugin. The server derives the publisher's scope from the session. * A separate worker process reads the same management records and sends signed events to the local receiver. ## Run locally [#run-locally] Use Node.js 24, npm, and Docker with Compose. In the example directory: ```sh npm ci docker compose up -d --wait npm run setup npm run dev ``` Open `http://127.0.0.1:4311` for order fulfillment or `http://127.0.0.1:4312` for billing. The two applications use separate ports and databases, so they can run at the same time. > **Note.** Keep the generated `.env` file between restarts. It holds the encryption key that protects > registered endpoint secrets. Setup applies migrations explicitly; starting the app never changes > the schema. Each README includes a walkthrough, per-process commands, and test instructions. The examples bind to loopback and permit local HTTP receivers. Before deploying an adapted version, configure authentication, secrets, migrations, worker supervision, and retention for your environment. ## Smaller checked examples [#smaller-checked-examples] The library package ships short, type-checked examples that the docs include verbatim: | Example | Shows | | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | [`examples/basic`](https://github.com/pplytas/good-webhooks/tree/main/packages/good-webhooks/examples/basic) | Rollback, retry, replay, and receiver deduplication | | [`examples/transactions`](https://github.com/pplytas/good-webhooks/tree/main/packages/good-webhooks/examples/transactions) | Payload-dependent recipient selection in a transaction | | [`examples/better-auth`](https://github.com/pplytas/good-webhooks/tree/main/packages/good-webhooks/examples/better-auth) | Auth instance, delivery engine, and worker split | | [`examples/operations`](https://github.com/pplytas/good-webhooks/tree/main/packages/good-webhooks/examples/operations) | Supervised worker and bounded cleanup entrypoints | # Custom senders > Keep your own delivery pipeline and use Good Webhooks endpoint management as its source of recipients and signing secrets. Source: https://good-webhooks.dev/docs/guides/custom-senders Already have a queue that sends HTTP requests? Keep it, and use Good Webhooks only for endpoint management. Both providers expose `management.source`, an `EndpointSource` with the two reads a sender needs: ```ts import type { EndpointSource } from 'good-webhooks/management' // interface EndpointSource { // matchRecipients(scope, eventType): Promise // resolveEndpoint(scope, endpointId): Promise< // | { status: 'active'; url: string; secrets: readonly string[] } // | { status: 'paused' | 'deleted' } // > // } ``` `scope` is `null` for application scope or `{ type, id }`. The source returns plaintext signing secrets, so keep it in trusted server code. ## What your sender must do [#what-your-sender-must-do] 1. **Capture recipients** (on publish): Call `matchRecipients()` once and store the full list with the event. 2. **Resolve** (before each attempt): Call `resolveEndpoint()` for the current URL, status, and secrets. 3. **Sign and send** (each attempt): Standard Webhooks signature, safe connection, no redirects. ### Capture recipients once [#capture-recipients-once] When you accept an event, call `matchRecipients()` and store the **complete** list with it, paused endpoints included. On a repeat of the same event, reuse the stored list instead of asking again. A rejected lookup is a failure, not "no recipients". Never store a partial list. ### Resolve before every attempt [#resolve-before-every-attempt] ```ts const endpoint = await source.resolveEndpoint(scope, endpointId) if (endpoint.status === 'active') { // Send to endpoint.url, signing with every secret in endpoint.secrets. } else if (endpoint.status === 'paused') { // Keep the delivery queued, within your expiry policy. } else { // Deleted or missing: cancel this delivery. } ``` Do this for retries too. The URL and secrets from publish time may be stale. ### Sign and send safely [#sign-and-send-safely] * Sign with the [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) format, once per secret in `secrets`, so receivers keep working during a rotation. * Keep the event ID and body identical across retries and replays. * Before each connection, resolve the hostname, reject private and otherwise unsafe addresses, connect to the checked address with TLS hostname checks, and refuse redirects. Checks at registration don't cover later DNS changes. Durable queuing, bounded retries, attempt history, retention, and concurrency limits are also yours. If that list is longer than you want, connect the [built-in delivery](https://good-webhooks.dev/docs/better-auth/delivery.md) to the same source instead. ## What the source guarantees [#what-the-source-guarantees] * `matchRecipients()` returns every matching endpoint, or rejects. Concurrent edits may or may not be seen; there's no scope-wide snapshot. * The Better Auth provider also checks that the owning user or organization still exists. A missing owner means no recipients, and `resolveEndpoint()` returns `deleted`. A failed owner lookup rejects. * During a signing-secret rotation, `secrets` lists the new secret first and the old one until the overlap ends. ## Custom storage instead [#custom-storage-instead] To keep the endpoint lifecycle API but store endpoints somewhere else, implement the [`ManagementRepository` contract](https://good-webhooks.dev/docs/reference/management.md#managementrepository) and pass it to `createManagement()`. A sender only ever needs `EndpointSource`. # Manage endpoints > Register, update, pause, resume, rotate, and remove endpoints with the combined client or the standalone PostgreSQL provider. Source: https://good-webhooks.dev/docs/guides/endpoints An endpoint is a URL in one [scope](https://good-webhooks.dev/docs/concepts/scopes.md) that subscribes to some of your event types. Endpoint management stores it with its status and its encrypted signing secret. To let signed-in users manage their own endpoints over HTTP, use the [Better Auth plugin](https://good-webhooks.dev/docs/better-auth.md) instead. ## Register an endpoint [#register-an-endpoint] **Combined client** The `webhooks` client from [installation](https://good-webhooks.dev/docs/installation.md#create-the-client) has an `endpoints` API. The root client works in application scope; `forScope()` selects a customer's scope. ```ts import { webhooks } from './webhooks.js' const customer = webhooks.forScope({ type: 'organization', id: organizationId }) const { endpoint, secret } = await customer.endpoints.create({ url: 'https://receiver.example/webhooks', description: 'Invoice notifications', eventTypes: ['invoice.paid'], }) ``` **Standalone provider** `createPostgresManagement()` stores endpoints without the delivery tables. Use it with a [custom sender](https://good-webhooks.dev/docs/guides/custom-senders.md), or when endpoint management and delivery live in different databases. ```ts import { createPostgresManagement } from 'good-webhooks/management/postgres' import { pool } from './db.js' const management = createPostgresManagement({ database: pool, eventTypes: ['invoice.created', 'invoice.paid'], encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!, }) await management.check() const { endpoint, secret } = await management.create( { type: 'organization', id: organizationId }, { url: 'https://receiver.example/webhooks', eventTypes: ['invoice.paid'] }, ) ``` It needs only the management table: ```sh node -e 'import("good-webhooks/migrations").then(m => process.stdout.write(m.getPostgresMigration({ component: "management" })))' > 001-webhook-management.sql psql "$DATABASE_URL" --single-transaction -v ON_ERROR_STOP=1 -f 001-webhook-management.sql ``` Both need the storage encryption key from [installation](https://good-webhooks.dev/docs/installation.md#create-a-storage-key). Every process that reads these endpoints needs the **same** key. ### Hand over the signing secret [#hand-over-the-signing-secret] `create()` returns the plaintext signing secret once; no read returns it again. Show it to the customer once, or send it over a secure channel. Their receiver passes it to [`parseWebhook()`](https://good-webhooks.dev/docs/guides/receive.md). ## Change an endpoint [#change-an-endpoint] Endpoint status: 1. **active**: Receives new events and sends queued deliveries. 2. **paused**: Still subscribed. Queued deliveries wait for `resume()`. 3. **deleted**: Permanent. Secrets cleared, queued deliveries cancelled. ```ts await customer.endpoints.update(id, { eventTypes: ['invoice.created', 'invoice.paid'] }) await customer.endpoints.pause(id) await customer.endpoints.resume(id) const { secret } = await customer.endpoints.rotateSecret(id, { graceMs: 3_600_000 }) await customer.endpoints.remove(id) ``` | Method | Effect | | -------------- | ------------------------------------------------------------------------------------------ | | `update` | Changes the URL, description, or subscriptions. A new URL is checked again | | `pause` | Keeps subscriptions and queued deliveries. The worker holds them until resume or expiry | | `resume` | Checks the URL again, then queued deliveries send | | `rotateSecret` | Returns a new signing secret. Both sign requests during the overlap (default and max 24 h) | | `remove` | Permanent. Clears stored secrets, cancels queued deliveries, and keeps a deleted record | These calls return as soon as the record changes. A request the worker was already sending can still arrive after a pause or remove. See [delivery guarantees](https://good-webhooks.dev/docs/concepts/delivery-guarantees.md#endpoint-changes-while-work-is-queued). ## Limits [#limits] | Limit | Value | | ------------------------ | -------------------------------------------------------------------- | | Live endpoints per scope | 1,000 | | Subscribed event types | 1 to 100 per endpoint, each one of your configured names | | URL | Up to 2,048 characters, HTTPS, no credentials, no unsafe IP literals | | Description | Up to 2,000 characters | The standalone provider also resolves the URL's DNS at registration. ## When a scope owner leaves [#when-a-scope-owner-leaves] Standalone management doesn't know about your users. Call `removeScope(scope)` from your own deletion workflow to remove every endpoint in that scope: ```ts import { createPostgresManagement } from 'good-webhooks/management/postgres' const management = createPostgresManagement({ database: pool, eventTypes, encryptionKey }) await management.removeScope({ type: 'organization', id: organizationId }) ``` The combined client doesn't expose `removeScope`. Either build the provider separately as above, with the same database, schema, and key, or create it first, pass it to `createWebhooks({ management })`, and keep a reference. ## Next steps [#next-steps] - [Receive webhooks](https://good-webhooks.dev/docs/guides/receive.md): What the customer does with the signing secret. - [Key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md): Rotate signing secrets and the storage encryption key. - [Management reference](https://good-webhooks.dev/docs/reference/management.md): The full contract, limits, and errors. # Publish events > Define a typed event catalog, publish with idempotency keys, reach the right customer, and commit events with your own database writes. Source: https://good-webhooks.dev/docs/guides/publish `publish()` records an event and one delivery per subscribed endpoint. It sends nothing itself; a [worker](https://good-webhooks.dev/docs/operations/workers.md) does. The examples use the `webhooks` client from [installation](https://good-webhooks.dev/docs/installation.md#create-the-client). ```ts import { webhooks } from './webhooks.js' const { eventId, deliveryCount } = await webhooks.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_123', amount: 4200 }, }) ``` `deliveryCount` is how many endpoints will receive it. Zero is not an error: nobody subscribed. ## Define your events [#define-your-events] The event map is your public webhook catalog: names and payload schemas. Any [Standard Schema](https://standardschema.dev) validator works. Keep it in its own module so receivers can import it too. ```ts title="events.ts" import { z } from 'zod' export const events = { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int().nonnegative(), }), 'account.activated': z.object({ accountId: z.string() }), } ``` Pass it to `createWebhooks({ events })`. TypeScript then checks every `publish()`, every endpoint subscription, and every `parseWebhook()` against it: ```ts await webhooks.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_1', amount: '42' } }) // ^ type error ``` Payloads are also validated at runtime before anything is stored. **Payload and name rules** * Validated output must be plain JSON: at most 256 KiB, 64 levels deep, finite numbers, no cycles. * Event names match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$`. * Prefer schemas that keep values as they are instead of transforming them, so receivers can reuse the same map. Convert dollars to cents before publishing, not inside the schema. ## Make retries of your code safe [#make-retries-of-your-code-safe] Your own code can run twice: a retried job, a double-clicked button. Add an `idempotencyKey` built from the business fact: ```ts const result = await webhooks.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_123', amount: 4200 }, idempotencyKey: 'invoice.paid:inv_123', // [!code highlight] }) result.duplicate // true when this key was already accepted ``` | You publish again with | Result | | ----------------------------------- | ------------------------------------------------------- | | Same key, same type and payload | The original event, `duplicate: true`. Nothing new sent | | Same key, different type or payload | Throws `IDEMPOTENCY_CONFLICT` | | A new key | A new event | Keys are unique per [scope](https://good-webhooks.dev/docs/concepts/scopes.md) and are released when the event is [pruned](https://good-webhooks.dev/docs/operations/retention.md) (seven days by default). ## Publish to a customer [#publish-to-a-customer] The root client publishes in **application scope**: endpoints your platform registered for itself. To reach a customer's endpoints, publish in their scope: ```ts const customer = webhooks.forScope({ type: 'organization', id: organizationId }) await customer.publish({ type: 'invoice.paid', data, idempotencyKey }) ``` A scope only reaches endpoints registered in that exact scope. It does not check permissions: decide in your app that this action belongs to this customer first. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md). ## Publish inside your transaction [#publish-inside-your-transaction] When your tables and the webhook tables share a database, pass your transaction client. The event then commits or rolls back with your data: 1. **BEGIN** (your app): Open a transaction on one client. 2. **Your write**: `UPDATE invoices SET paid = true …` 3. **publish()**: Same client, passed as `{transaction}`. 4. **COMMIT or ROLLBACK**: Commit: event and data exist. Rollback: neither does. ```ts import { pool, webhooks } from './webhooks.js' const client = await pool.connect() try { await client.query('BEGIN') await client.query('UPDATE invoices SET paid = true WHERE id = $1', ['inv_123']) await webhooks.publish( { type: 'invoice.paid', data: { invoiceId: 'inv_123', amount: 4200 } }, { transaction: client }, // [!code highlight] ) await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK') throw error } finally { client.release() } ``` What to know: * Workers can't see the event until you commit. * A failed `publish()` doesn't break your transaction. It runs in a savepoint. * Pass a checked-out client, not the pool, and don't run other queries on it at the same time. * Finding recipients is a separate read from endpoint management. Passing a transaction does not pull a Better Auth database or a second PostgreSQL database into the commit. Do recipients depend on the payload or on rows you haven't committed yet? Use [transactional recipient selection](https://good-webhooks.dev/docs/guides/recipient-selection.md). ## Limit load per endpoint [#limit-load-per-endpoint] By default each endpoint gets at most 2 requests at a time, across all workers. Raise or lower it per endpoint: ```ts await customer.deliverySettings.set(endpointId, { maxInFlight: 4 }) ``` See [`deliverySettings`](https://good-webhooks.dev/docs/reference/delivery.md#deliverysettings). ## Next steps [#next-steps] - [Run workers](https://good-webhooks.dev/docs/operations/workers.md): Send what you published. - [Delivery guarantees](https://good-webhooks.dev/docs/concepts/delivery-guarantees.md): What a successful publish() promises. # Receive webhooks > Verify the raw request, decode a typed event, and process each event exactly once. Examples for Next.js, Hono, Express, and Node.js. Source: https://good-webhooks.dev/docs/guides/receive A receiver checks that a request really came from the sender, then acts on it once. `parseWebhook()` from `good-webhooks/verify` does the checking. That import loads no database or worker code, so it fits in any Node.js service. What a receiver does: 1. **Read the raw body**: Bounded in size, as bytes. Not parsed JSON. 2. **Verify and parse**: `parseWebhook()` checks the signature and returns a typed event. 3. **Process once**: Record `event.id` with your change in one transaction. 4. **Respond**: `2xx` when done. `400` for bad requests, `500` to get a retry. ## Add a webhook route [#add-a-webhook-route] Each handler is complete. Put your event map in `events.ts` ([shown below](#share-the-event-map)) and the endpoint's signing secret in `WEBHOOK_SECRET`. **Next.js** ```ts title="app/webhooks/route.ts" import { parseWebhook, WebhookError } from 'good-webhooks/verify' import { events } from '@/lib/events' export async function POST(request: Request) { // Reject oversized bodies before buffering. Your platform's body limit also applies. if (Number(request.headers.get('content-length')) > 512 * 1024) { return new Response('Webhook body is too large', { status: 413 }) } try { const event = await parseWebhook({ body: new Uint8Array(await request.arrayBuffer()), headers: request.headers, secret: process.env.WEBHOOK_SECRET!, events, }) console.log(event.type, event.id) // Process once: see below. return new Response(null, { status: 204 }) } catch (error) { if (error instanceof WebhookError && isRejection(error)) { return new Response('Invalid webhook', { status: 400 }) } throw error // 500: the sender retries. } } const isRejection = (error: WebhookError) => ['SIGNATURE_INVALID', 'SIGNATURE_EXPIRED', 'PAYLOAD_INVALID'].includes(error.code) ``` **Hono** ```ts title="server.ts" import { Hono } from 'hono' import { bodyLimit } from 'hono/body-limit' import { parseWebhook, WebhookError } from 'good-webhooks/verify' import { events } from './events.js' const app = new Hono() app.post('/webhooks', bodyLimit({ maxSize: 512 * 1024 }), async (c) => { try { const event = await parseWebhook({ body: new Uint8Array(await c.req.arrayBuffer()), headers: c.req.raw.headers, secret: process.env.WEBHOOK_SECRET!, events, }) console.log(event.type, event.id) // Process once: see below. return c.body(null, 204) } catch (error) { if (error instanceof WebhookError && isRejection(error)) return c.text('Invalid webhook', 400) throw error // 500: the sender retries. } }) const isRejection = (error: WebhookError) => ['SIGNATURE_INVALID', 'SIGNATURE_EXPIRED', 'PAYLOAD_INVALID'].includes(error.code) export default app ``` **Express** ```ts title="server.ts" import express from 'express' import { parseWebhook, WebhookError } from 'good-webhooks/verify' import { events } from './events.js' const app = express() // Raw bytes, not parsed JSON: the signature covers the exact body. app.post( '/webhooks', express.raw({ type: 'application/json', limit: '512kb' }), async (request, response) => { try { const event = await parseWebhook({ body: request.body, headers: request.headers, secret: process.env.WEBHOOK_SECRET!, events, }) console.log(event.type, event.id) // Process once: see below. response.sendStatus(204) } catch (error) { if (error instanceof WebhookError && isRejection(error)) { response.status(400).send('Invalid webhook') return } throw error // Express 5 turns this into a 500: the sender retries. } }, ) const isRejection = (error: WebhookError) => ['SIGNATURE_INVALID', 'SIGNATURE_EXPIRED', 'PAYLOAD_INVALID'].includes(error.code) app.listen(3000) ``` Register `express.raw()` on this route before any global `express.json()`. Parsed JSON cannot be verified. **Node.js http** The plain `node:http` version needs a bounded reader. These helpers come from the checked examples: ```ts title="examples/basic/receiver.ts" import type { IncomingMessage, ServerResponse } from 'node:http' import { WebhookError } from 'good-webhooks/verify' class BodyTooLargeError extends Error {} /** Bound HTTP input while streaming, before allocating the complete body. */ export async function readBody(request: IncomingMessage): Promise { const limit = 512 * 1024 if (Number(request.headers['content-length']) > limit) throw new BodyTooLargeError() const chunks: Buffer[] = [] let size = 0 // Keep the socket open long enough to return 413 if the stream exceeds the limit. for await (const chunk of request.iterator({ destroyOnReturn: false })) { const bytes = Buffer.from(chunk) size += bytes.length if (size > limit) throw new BodyTooLargeError() chunks.push(bytes) } return Buffer.concat(chunks, size) } /** Expected request failures are terminal. Unexpected server failures must remain retryable. */ export function respondToRequestError(error: unknown, response: ServerResponse): void { if (error instanceof BodyTooLargeError) { response.writeHead(413, { Connection: 'close' }).end('Webhook body is too large') } else if ( error instanceof WebhookError && ['SIGNATURE_INVALID', 'SIGNATURE_EXPIRED', 'PAYLOAD_INVALID'].includes(error.code) ) { response.writeHead(400).end('Invalid webhook') } else { respondToProcessingError(error, response) } } /** Call for business failures regardless of their error class or code. */ export function respondToProcessingError(error: unknown, response: ServerResponse): void { console.error('Receiver could not process the webhook:', error) response.writeHead(500).end('Webhook processing failed') } ``` ```ts title="server.ts" import { createServer } from 'node:http' import { parseWebhook } from 'good-webhooks/verify' import { events } from './events.js' import { readBody, respondToRequestError } from './receiver.js' createServer(async (request, response) => { try { const event = await parseWebhook({ body: await readBody(request), headers: request.headers, secret: process.env.WEBHOOK_SECRET!, events, }) console.log(event.type, event.id) // Process once: see below. response.writeHead(204).end() } catch (error) { respondToRequestError(error, response) } }).listen(3000) ``` During a [secret rotation](https://good-webhooks.dev/docs/operations/key-rotation.md), pass both secrets: `secret: [newSecret, oldSecret]`. ## Share the event map [#share-the-event-map] Use the same map as the sender, from a module that imports no server configuration or secrets: ```ts title="events.ts" import { z } from 'zod' export const events = { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int().nonnegative() }), } ``` `event` is a union over the map. Checking `event.type` narrows `event.data`: ```ts if (event.type === 'invoice.paid') { event.data.amount // number } ``` An event type that is not in your map is rejected as `PAYLOAD_INVALID`. There is no "ignore unknown events" mode. **My sender's schema transforms values** Receiver schemas may decode JSON into local values such as `Date`. But if a sender schema *transforms* its input, reusing it here would transform the already-transformed value again. Keep a separate receiver map that accepts the sender's output. **My Zod schema has an async transform** Zod 4's Standard Schema implementation tries synchronously first, then retries asynchronously, which can run an async transform twice. Wrap such schemas with the [`asyncZod` helper](https://github.com/pplytas/good-webhooks/blob/main/packages/good-webhooks/examples/basic/async-zod.ts) from the examples. Synchronous schemas need nothing. ## Respond with the right status [#respond-with-the-right-status] The sender retries `5xx` and network errors, and gives up on most `4xx`. Return `400` only when the request itself is bad: | Error code | Meaning | Respond | | ------------------- | ------------------------------------------------------------------ | ------- | | `SIGNATURE_INVALID` | Missing, malformed, or wrong signature headers | `400` | | `SIGNATURE_EXPIRED` | Timestamp outside the tolerance (five minutes by default) | `400` | | `PAYLOAD_INVALID` | Bad encoding, JSON, or envelope, an unknown event, or schema error | `400` | | `INVALID_CONFIG` | Your event map or secret configuration is wrong | `500` | | Anything else | Your database, your business logic, a thrown validator | `500` | Expected errors carry no payload values in their messages. Unexpected errors keep a `cause` for your logs; don't send it back. ## Process each event once [#process-each-event-once] The sender delivers **at least once**. A retry or a [replay](https://good-webhooks.dev/docs/operations/retries-and-replay.md) repeats the same `event.id` and body. Make the change and record the ID in one transaction, and only then respond. A table with a unique ID column is enough: ```sql title="receipts.sql" CREATE TABLE webhook_receipts ( event_id text PRIMARY KEY, received_at timestamptz NOT NULL DEFAULT now() ); ``` ```ts await client.query('BEGIN') const receipt = await client.query( 'INSERT INTO webhook_receipts (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id', [event.id], ) if (receipt.rowCount) { // First time we see this event: apply the change. await client.query('INSERT INTO invoices (id, amount) VALUES ($1, $2)', [ event.data.invoiceId, event.data.amount, ]) } await client.query('COMMIT') // Respond 2xx whether or not the event was new. ``` The [basic example](https://github.com/pplytas/good-webhooks/blob/main/packages/good-webhooks/examples/basic/demo.ts) runs exactly this and checks that a retry and a replay produce one invoice. ## Keep old events decodable [#keep-old-events-decodable] Retries and replays can deliver an event published before your latest deploy. Keep existing event names backward compatible, and add `invoice.paid.v2` for a breaking payload change. Deploy receiver support before the sender starts publishing a new event. ## Verify another sender's format [#verify-another-senders-format] `verifyWebhook()` authenticates any [Standard Webhooks](https://www.standardwebhooks.com) request, then leaves parsing to you: ```ts import { verifyWebhook } from 'good-webhooks/verify' verifyWebhook({ body: rawBody, headers: request.headers, secret }) // Parse the body only after this returns. ``` See the [verify reference](https://good-webhooks.dev/docs/reference/verify.md) for header rules, secret formats, and every option. # Transactional recipient selection > Choose recipients from your own routing table inside the same PostgreSQL transaction that commits the business write. Source: https://good-webhooks.dev/docs/guides/recipient-selection Normally, an event goes to every endpoint subscribed to its type. Use this recipe when **who receives it depends on the payload** or on rows you haven't committed yet. For example: send `invoice.paid` only to endpoints that handle the invoice's currency. 1. **BEGIN** (your app): Open a transaction on one client. 2. **Write and route**: Update the invoice, then query your routing table on the same client. 3. **publish()**: Pass the frozen recipient list through a one-off `EndpointSource`. 4. **COMMIT**: The invoice, the event, and its deliveries commit together. Your tables and the delivery tables must share one PostgreSQL database. The recipe uses only the public API and starts no worker. ## Routing rules in your schema [#routing-rules-in-your-schema] The example routes `invoice.paid` to endpoints whose currency and minimum amount match the invoice. An `invoice_routes` table owned by your application decides recipients for this event. Include every eligible endpoint, paused ones too. Check endpoint ownership when you create routes. If you also require managed subscriptions, add that to the routing query. ```sql title="examples/transactions/schema.sql" -- Application-owned example tables. Apply separately from the Good Webhooks migration. CREATE SCHEMA transaction_example; CREATE TABLE transaction_example.invoices ( organization_id text NOT NULL, invoice_id text NOT NULL, amount integer NOT NULL CHECK (amount >= 0), currency text NOT NULL, paid boolean NOT NULL DEFAULT false, PRIMARY KEY (organization_id, invoice_id) ); -- The host checks endpoint ownership and maintains these invoice.paid routing rules. -- Paused endpoints retain their routes. The worker resolves current endpoint lifecycle. CREATE TABLE transaction_example.invoice_routes ( organization_id text NOT NULL, endpoint_id text NOT NULL, currency text NOT NULL, minimum_amount integer NOT NULL DEFAULT 0 CHECK (minimum_amount >= 0), PRIMARY KEY (organization_id, endpoint_id, currency) ); ``` ## Capture recipients, then publish [#capture-recipients-then-publish] The function updates the invoice and queries routes on the transaction client. It then builds an inert delivery instance whose `matchRecipients` returns the frozen list. Each call gets its own source, so concurrent requests cannot overwrite each other. ```ts title="examples/transactions/invoices.ts" import type { Database, SqlClient } from 'good-webhooks' import { createDelivery, type EndpointSource } from 'good-webhooks/delivery' import { z } from 'zod' export const events = { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int().nonnegative(), currency: z.string(), }), } export const deliverySchema = 'public' /** The host authorizes the organization and supplies a client inside BEGIN. */ export async function recordInvoicePayment(options: { database: Database pgClient: SqlClient source: EndpointSource organizationId: string invoiceId: string }) { const { database, pgClient, source, organizationId, invoiceId } = options const scope = Object.freeze({ type: 'organization', id: organizationId }) const invoice = await pgClient.query<{ invoice_id: string amount: number currency: string }>( `UPDATE transaction_example.invoices SET paid = true WHERE organization_id = $1 AND invoice_id = $2 RETURNING invoice_id, amount, currency`, [organizationId, invoiceId], ) const row = invoice.rows[0] if (!row) throw new Error('Invoice not found in the authorized organization.') const data = { invoiceId: row.invoice_id, amount: row.amount, currency: row.currency } // Host-owned routes define eligible endpoints for this payload, including paused endpoints. // The host must check endpoint ownership when creating routes. Lookup errors must reject. const selected = await pgClient.query<{ endpoint_id: string }>( `SELECT endpoint_id FROM transaction_example.invoice_routes WHERE organization_id = $1 AND currency = $2 AND minimum_amount <= $3 ORDER BY endpoint_id`, [organizationId, data.currency, data.amount], ) const recipientIds = Object.freeze(selected.rows.map(({ endpoint_id }) => endpoint_id)) const publicationSource: EndpointSource = { async matchRecipients(requestedScope, eventType) { if ( requestedScope?.type !== scope.type || requestedScope.id !== scope.id || eventType !== 'invoice.paid' ) throw new Error('This recipient selection belongs to one scope and event type.') return recipientIds }, // Resolution still uses current management state, never this transaction's client. resolveEndpoint: (requestedScope, id) => source.resolveEndpoint(requestedScope, id), } // Construction opens no connections and starts no worker. Never mutate a shared source. const publication = createDelivery({ database, schema: deliverySchema, events, source: publicationSource, }) // The result is provisional until the caller commits. On retry, call this function again // inside a fresh transaction so it repeats the business reads and recipient selection. return publication .forScope(scope) .publish( { type: 'invoice.paid', data, idempotencyKey: `invoice.paid:${invoiceId}` }, { transaction: pgClient }, ) } ``` Two details are easy to miss: * `matchRecipients()` receives only the scope and event type. The closure supplies the selection and rejects any other scope or type. * `resolveEndpoint()` delegates to the normal source. It must keep returning current URLs, secrets, and status. Never capture the transaction client in it. ## Commit from the caller [#commit-from-the-caller] ```ts title="pay-invoice.ts" import { recordInvoicePayment } from './invoices.js' const pgClient = await pool.connect() try { await pgClient.query('BEGIN') const publication = await recordInvoicePayment({ database: pool, pgClient, source, organizationId: 'org_123', invoiceId: 'inv_123', }) await pgClient.query('COMMIT') // Only now is the publication durable. } catch (error) { await pgClient.query('ROLLBACK').catch(() => {}) throw error } finally { pgClient.release() } ``` A later rollback removes the invoice update, the event, and its pending deliveries together. The routing query runs at your transaction's isolation level. Default `READ COMMITTED` sees your earlier writes and a per-statement snapshot. Choose stronger isolation only if the business rule needs it. ## Retrying and running the worker [#retrying-and-running-the-worker] If the transaction aborts, retry the whole operation with a fresh transaction and a fresh call. Do not reuse a captured selection. After a commit, repeating the same key, type, and payload returns the original event with its original recipients. The wrapper still queries routes first, so it can fail on a routing-storage outage even though the publication is already accepted. Different content with the same key throws `IDEMPOTENCY_CONFLICT`. The worker needs only the normal source and the same events, database, and schema: ```ts title="worker.ts" import { createDelivery } from 'good-webhooks/delivery' import { deliverySchema, events } from './invoices.js' const delivery = createDelivery({ database: pool, schema: deliverySchema, events, source }) await delivery.worker.run({ signal: shutdown.signal }) ``` It uses the committed recipient IDs and resolves current endpoint state before each attempt. It never reruns your routing query. > **Warning.** This recipe cannot pull a separate management database into the PostgreSQL transaction. If > recipient authorization lives elsewhere, define how that state is synchronized or use a durable > recovery workflow. # Introduction > Good Webhooks is a TypeScript library that adds outbound webhooks to your application, using your PostgreSQL database. Source: https://good-webhooks.dev/docs Good Webhooks lets your application send webhooks to your customers. You publish an event; it handles the rest: who should receive it, signing, retries, history, and replay. It runs inside your app and stores everything in your PostgreSQL database. There is no extra service to host. The life of one event: 1. **Publish** (your app): `publish()` validates the payload and finds subscribed endpoints. 2. **Store** (PostgreSQL): The event and one delivery per endpoint commit with your data. 3. **Deliver** (worker): A worker signs each request, sends it, and retries failures. 4. **Verify** (receiver): The receiver checks the signature and gets a typed event. ## In code [#in-code] **Publish** ```ts title="app.ts" import { createWebhooks } from 'good-webhooks' import { Pool } from 'pg' import { z } from 'zod' export const webhooks = createWebhooks({ database: new Pool({ connectionString: process.env.DATABASE_URL }), encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!, events: { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }), }, }) await webhooks.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_123', amount: 4200 }, // type-checked against the schema }) ``` **Deliver** ```ts title="worker.ts" import { webhooks } from './app.js' const stop = new AbortController() process.once('SIGTERM', () => stop.abort()) // Sends due deliveries, retries failures, and stops cleanly on SIGTERM. await webhooks.worker.run({ signal: stop.signal }) ``` **Receive** ```ts title="receiver.ts" import { parseWebhook } from 'good-webhooks/verify' import { events } from './events.js' // the same event map the sender uses const event = await parseWebhook({ body: rawBody, // the request body exactly as received headers: request.headers, secret: process.env.WEBHOOK_SECRET!, events, }) event.data.amount // number ``` ## What it handles for you [#what-it-handles-for-you] Most teams start with a `fetch()` in a request handler. Then a customer's server goes down and events are lost, a deploy sends duplicates, or a URL points at an internal service. Good Webhooks covers those cases: | Good Webhooks handles | You decide | | ------------------------------------------------------ | --------------------------------------------- | | Endpoint storage, encrypted secrets, URL safety checks | Who may manage which endpoints | | Payload validation, idempotency, recipient selection | When to publish, and in which transaction | | Signing, retries, attempt history, replay | Where the worker runs and how it's supervised | | Migration SQL and schema checks | When migrations run | | Signature verification and typed parsing for receivers | What the receiver does with the event | Nothing starts on its own: creating the client opens no connections, runs no migrations, and starts no workers. > **Note: Beta.** The public API is frozen except for correctness and security fixes. Pin the exact version and read > the [stability policy](https://good-webhooks.dev/docs/operations/compatibility.md#stability-policy) and the [release > notes](https://github.com/pplytas/good-webhooks/releases) before upgrading. ## Where to go next [#where-to-go-next] - [Try it in five minutes](https://good-webhooks.dev/docs/quick-start.md): One file sends a signed webhook to a local receiver. - [Add it to your app](https://good-webhooks.dev/docs/installation.md): Pick your setup and install what it needs. - [Use Better Auth?](https://good-webhooks.dev/docs/better-auth.md): Let signed-in users manage their own endpoints. - [Understand the model](https://good-webhooks.dev/docs/concepts/how-it-works.md): Management, delivery, scopes, and guarantees. # Installation > Add Good Webhooks to an existing application. Install, create a key and the tables, and connect. Source: https://good-webhooks.dev/docs/installation Install the dependencies for your setup below. Better Auth endpoint management uses your existing database adapter. Built-in delivery and standalone PostgreSQL management require PostgreSQL 16 or 17. **Requirements:** Node.js 24 or later. TypeScript consumers need version 5.9.3 or later with `moduleResolution` set to `NodeNext` or `Bundler`. Edge runtimes are not supported. ### Install [#install] **PostgreSQL** npm pnpm yarn bun ```bash npm install good-webhooks@beta pg zod ``` ```bash pnpm add good-webhooks@beta pg zod ``` ```bash yarn add good-webhooks@beta pg zod ``` ```bash bun add good-webhooks@beta pg zod ``` npm pnpm yarn bun ```bash npm install --save-dev @types/pg ``` ```bash pnpm add --save-dev @types/pg ``` ```bash yarn add --dev @types/pg ``` ```bash bun add --dev @types/pg ``` **Better Auth** npm pnpm yarn bun ```bash npm install good-webhooks@beta better-auth@1.7.7 ``` ```bash pnpm add good-webhooks@beta better-auth@1.7.7 ``` ```bash yarn add good-webhooks@beta better-auth@1.7.7 ``` ```bash bun add good-webhooks@beta better-auth@1.7.7 ``` Add `pg zod` too if the same app will [send events](https://good-webhooks.dev/docs/better-auth/delivery.md). Supported range: `better-auth >=1.7.7 <1.8.0`. Then follow the [Better Auth plugin](https://good-webhooks.dev/docs/better-auth.md) guide; the steps below are for the PostgreSQL setup. **Receiver only** You only receive webhooks from a Good Webhooks sender: npm pnpm yarn bun ```bash npm install good-webhooks@beta zod ``` ```bash pnpm add good-webhooks@beta zod ``` ```bash yarn add good-webhooks@beta zod ``` ```bash bun add good-webhooks@beta zod ``` Skip the steps below and go to [receive webhooks](https://good-webhooks.dev/docs/guides/receive.md). `good-webhooks/verify` has no database dependency. The `beta` tag installs the latest beta. Commit your lockfile. Payload schemas can use any [Standard Schema](https://standardschema.dev) library; these docs use Zod. ### Create a storage key [#create-a-storage-key] Endpoint signing secrets are encrypted in your database with this key. Generate it once: ```sh node -e 'import("good-webhooks").then(m => console.log(m.generateEncryptionKey()))' ``` Store the output in your secret manager or `.env` as `WEBHOOK_ENCRYPTION_KEY`. Every process that sends or manages webhooks needs the **same** key. ### Create the tables [#create-the-tables] Write the SQL to a migration file: ```sh node -e 'import("good-webhooks/migrations").then(m => process.stdout.write(m.getPostgresMigration()))' > 001-webhooks.sql ``` Apply it with the migration tool you already use, or with `psql`: ```sh psql "$DATABASE_URL" --single-transaction -v ON_ERROR_STOP=1 -f 001-webhooks.sql ``` The tables are prefixed `webhook_` and go in `public`. To use another schema, see [migrations](https://good-webhooks.dev/docs/operations/migrations.md). ### Create the client [#create-the-client] ```ts title="webhooks.ts" import { createWebhooks } from 'good-webhooks' import { Pool } from 'pg' import { z } from 'zod' export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) export const webhooks = createWebhooks({ database: pool, encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!, events: { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }), }, }) ``` Check the setup once at startup. It throws `SCHEMA_MISMATCH` if the tables are missing or outdated: ```ts await webhooks.check() ``` You're set up. Nothing is sent yet: that takes a published event and a running worker. - [1. Publish events](https://good-webhooks.dev/docs/guides/publish.md): Define your event catalog and publish from your app. - [2. Run a worker](https://good-webhooks.dev/docs/operations/workers.md): Start the process that sends and retries. - [3. Manage endpoints](https://good-webhooks.dev/docs/guides/endpoints.md): Register where events go. - [4. Receive webhooks](https://good-webhooks.dev/docs/guides/receive.md): Give your customers a verified receiver. ## Entry points [#entry-points] Import only what a process needs. A receiver never loads database code. | Import | Use it for | | ----------------------------------- | -------------------------------------------------------------------- | | `good-webhooks` | `createWebhooks()`: management and delivery together on PostgreSQL | | `good-webhooks/delivery` | `createDelivery()`: the sender alone, fed by any `EndpointSource` | | `good-webhooks/management` | The management contracts and `createManagement()` for custom storage | | `good-webhooks/management/postgres` | Standalone endpoint management without delivery tables | | `good-webhooks/better-auth` | The Better Auth server plugin and trusted management provider | | `good-webhooks/better-auth/client` | The Better Auth client plugin for browsers | | `good-webhooks/verify` | `parseWebhook()` and `verifyWebhook()` for receivers | | `good-webhooks/migrations` | `getPostgresMigration()` and the shipped SQL files | The package ships ESM and also loads through CommonJS `require()` on Node.js 24. If you use the Better Auth plugin, enable `skipLibCheck` for its upstream declarations; your own types stay fully checked. # Compatibility > Supported runtimes and databases, what each release promises, and how to upgrade. Source: https://good-webhooks.dev/docs/operations/compatibility ## Supported configurations [#supported-configurations] | Component | Supported | Verified in CI | | ----------- | ---------------- | ---------------------------------------------------------------------- | | Node.js | 24 or later | Node.js 24, ESM and CommonJS consumers | | TypeScript | 5.9.3 or later | 5.9.3 and 7.0, with NodeNext and Bundler resolution | | PostgreSQL | 16 and 17 | Full integration suite on both | | Better Auth | `>=1.7.7 <1.8.0` | 1.7.7 across the [adapter matrix](https://good-webhooks.dev/docs/better-auth/database-adapters.md) | Standalone consumers are checked with `strict: true` and `skipLibCheck: false`. Better Auth consumers use `skipLibCheck` for upstream declarations while keeping application-level inference checks. Other JavaScript runtimes are not verified. ## Stability policy [#stability-policy] Good Webhooks is in beta. The beta series leads to the stable `0.1.0` release; progress is tracked in [issue #22](https://github.com/pplytas/good-webhooks/issues/22). ### Public surface [#public-surface] The [entry points](https://good-webhooks.dev/docs/installation.md#entry-points) and every name they export are the public API, together with the shipped SQL files. Imports from `dist/` or `src/` paths, and anything not reachable from an entry point, are internal and can change without notice. `Internal` in a type name means the same. ### During beta [#during-beta] * Pin the exact version and commit your lockfile. Only the latest beta receives fixes. * The public API is frozen. A breaking change is allowed only to fix a correctness or security defect. The changelog labels it **Breaking**, states the migration, and the release is a new beta. * Behavior that consumers can observe is part of the API: option defaults, error codes, the request format, and the signature scheme. * The schema is at version `4`. Any schema change ships upgrade SQL and a rollout order for producers and workers, never fresh-install SQL alone. * Test producers, workers, and receivers together before rolling out. ### From 0.1.0 [#from-010] * Releases follow [semantic versioning](https://semver.org). While the major version is `0`, a minor release may contain breaking changes; a patch release never does. * Breaking means a passing consumer type-check fails, observable runtime behavior changes, a supported runtime or database version is dropped, or SQL must run before upgrading. * Every breaking change is deprecated in at least one earlier minor with a runtime or type-level warning, and the release notes state the migration. * The latest minor receives all fixes. Security fixes also reach the previous minor for three months after the next minor ships. * `0.1.0` claims a stable API and upgrade path. It does not claim verified behavior at high throughput beyond the [measured profile](https://good-webhooks.dev/docs/operations/performance.md); the [production checklist](https://good-webhooks.dev/docs/operations/production.md) and release notes state what has been measured. ## Package versions and schema versions [#package-versions-and-schema-versions] They are independent. Installing a package never runs SQL. The current schema version is `4` for both components, and `check()` refuses to start against any other. Each schema change ships upgrade SQL through `getPostgresUpgrade()` and a rollout order in the [migrations guide](https://good-webhooks.dev/docs/operations/migrations.md#upgrades). Do not assume old and new workers can share a changed schema unless the release notes say so, and never assume downgrading is safe. ## What stays constant [#what-stays-constant] Delivery is at least once with no ordering guarantee. Signing follows Standard Webhooks. Receivers keep verifying and deduplicating across upgrades. Store signing and encryption keys outside the deployment and retain whatever existing endpoints need. # Failure behavior > What happens when a worker crashes, the database drops, a receiver hangs, or a deploy restarts everything. Each statement here is checked by a test in the repository. Source: https://good-webhooks.dev/docs/operations/failure-behavior Delivery is at least once. Every failure below ends in one of two places: the delivery is retried with the same event ID, or the process exits so a supervisor restarts it. Nothing is lost silently, and nothing is sent without an attempt record. | Situation | What you observe | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Worker killed mid-request | The attempt is `abandoned` when its lease expires. The delivery goes back to `pending` and another worker retries it. | | Worker killed after the receiver accepted | Same as above. The receiver sees the same `event.id` twice; its deduplication makes the second a no-op. | | Receiver hangs | The request is cancelled at `timeoutMs`, recorded as `retry`, and the delivery waits for its next retry time. | | Receiver returns `5xx`, `408`, `425`, `429` | Recorded as `retry`. Retries continue until the schedule runs out, then `failed`. | | Receiver returns another `4xx` or any `3xx` | Recorded as `failed`. No retry. Redirects are never followed. | | Database connection drops mid-batch | `run()` rejects. The attempt record stays `started` until its lease expires, then it is `abandoned` and retried. | | Database unreachable at startup | `check()` or the first query fails within your connection timeout. The process exits before claiming anything. | | Startup query blocked by a lock | The statement timeout cancels it. The process exits and the pool closes. | | Wrong encryption key | The worker exits without consuming an attempt. Deliveries stay `pending` with their attempt count unchanged. | | `SIGTERM` during an active request | The request gets up to `shutdownGraceMs` to finish, its outcome is recorded, and `run()` resolves with exit code `0`. | | Schema older or newer than the package | `check()` throws `SCHEMA_MISMATCH` naming the SQL to apply, before any work is claimed. | ## Leases and crashes [#leases-and-crashes] A worker claims a delivery by writing an attempt record with a lease token before it sends anything. The signed body is exactly the persisted body. If the worker dies, the lease expires after `leaseMs` (60 seconds by default, at least twice `timeoutMs`). The next worker to start a batch recovers expired leases: the attempt becomes `abandoned`, and the delivery returns to `pending` if retry budget remains, or becomes `failed` if it does not. Lease tokens are fenced. A worker that wakes up late and tries to complete an attempt whose lease already expired gets `stale`; the result is discarded even if a replacement worker has since claimed and finished the same delivery. Two workers can never both record success for one attempt. The recovered delivery is sent again with the same `event.id` and a fresh timestamp and signature. If the receiver accepted the first request and the worker died before recording it, the receiver sees a duplicate. This is the normal case the [receiver deduplication](https://good-webhooks.dev/docs/guides/receive.md#process-each-event-once) exists for. ## Database failures [#database-failures] The library opens no connections of its own. It uses the pool you pass in, so pool-level failures surface as rejections from `run()`, `runOnce()`, `publish()`, or `check()`. * **Mid-batch disconnect.** If PostgreSQL terminates the connection while a transaction owns the client, `run()` rejects with the driver error. Nothing is retried inside the library; the supervisor restarts the process and lease recovery handles the half-finished attempt. The example entrypoint logs `webhooks.worker.failed` and exits with code `1` without an unhandled `error` event. * **Idle disconnect.** A connection terminated while the worker waits between polls produces the same exit. The pool's error is caught and reported, not thrown asynchronously. * **Blocked startup.** A startup query waiting on a lock is cancelled by the statement timeout you configure. The process exits within that bound and closes its connection. * **Accepted TCP, no handshake.** The connection timeout bounds a database that accepts the socket but never completes startup. Set `connectionTimeoutMillis`, `statement_timeout`, and `idle_in_transaction_session_timeout` on the pool. The [worker guide](https://good-webhooks.dev/docs/operations/workers.md#bound-database-waits) shows the example configuration. ## Restarts and deploys [#restarts-and-deploys] * **Graceful stop.** Abort the worker's signal on `SIGTERM`. Claims stop immediately, active requests get the grace period, outcomes are written, then `run()` resolves. The operations rehearsal confirms that a delivery accepted by the receiver during shutdown is recorded `succeeded` with one attempt, not retried. * **Hard kill.** `SIGKILL` leaves leases behind. They are recovered by the next worker after `leaseMs`. The rehearsal kills a worker during a backlog and checks that a replacement completes every delivery with no gaps and no duplicate receipts beyond what deduplication absorbs. * **Rolling deploy with a schema upgrade.** Apply the upgrade SQL first. Processes on the previous package keep running when the release notes say so, and a restarted old process fails at `check()`. See the [rollout order](https://good-webhooks.dev/docs/operations/migrations.md#rollout-order). * **Multiple workers.** Workers coordinate through row locks and per-endpoint in-flight caps. A locked delivery is skipped, not waited for, so one slow endpoint does not stall a batch for other endpoints. ## What is not covered [#what-is-not-covered] These behaviors are verified on a single PostgreSQL instance with local receivers. They do not establish throughput limits, behavior under database failover, or internet-path delivery characteristics. Treat the [production checklist](https://good-webhooks.dev/docs/operations/production.md) as the list of decisions that remain yours. ## Where each claim is checked [#where-each-claim-is-checked] | Claim | Test | | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | Crash after acceptance delivers again | `test/worker.test.ts`, "recovers a crash after receiver acceptance" | | Expired tokens are fenced | `test/worker.test.ts`, "fences an expired token before and after a replacement claim" | | Retry and permanent-failure status codes | `test/worker.test.ts`, "retries HTTP" and "fails permanently on HTTP" | | Timeout and stalled responses retry | `test/worker.test.ts`, "retries a timed out request" and the partial-response cases | | Wrong key consumes no attempt | `test/operations.test.ts`, "exits nonzero on a worker failure without consuming an attempt" | | Mid-transaction and idle disconnects | `test/operations.test.ts`, "handles a database disconnect" and "idle-pool connection error" | | Blocked startup and stalled handshake bound | `test/operations.test.ts`, "bounds a blocked startup statement" and "never completes startup" | | `SIGTERM` finishes the active delivery | `test/operations.test.ts`, "finishes an active delivery on SIGTERM before closing the pool" | | Grace period and cancellation timing | `test/worker-lifecycle.test.ts`, "worker shutdown deadlines" | | `SIGKILL` recovery with a backlog | `scripts/check-operations.mjs` (`pnpm test:operations`) | | Killed connections, exclusive lock, failing and hanging receivers | `scripts/check-chaos.mjs` (`pnpm test:chaos`) | | Old and new packages across an upgrade | `scripts/check-upgrade-compat.mjs` (`pnpm test:compat`) | # Key rotation > Rotate a receiver's signing secret with an overlap, and rotate storage encryption keys without changing what receivers verify. Source: https://good-webhooks.dev/docs/operations/key-rotation Two different keys, two different procedures: | Key | Rotating it changes | Receivers must | | -------------------------- | -------------------------------- | ---------------------- | | Signing secret (`whsec_…`) | What the receiver verifies | Install the new secret | | Storage encryption key | Ciphertext in management storage | Nothing | ## Rotate a signing secret [#rotate-a-signing-secret] **Combined API** ```ts const { secret } = await customer.endpoints.rotateSecret(endpointId, { graceMs: 3_600_000 }) ``` **Management provider** ```ts const { secret } = await management.rotateSecret(scope, endpointId, { graceMs: 3_600_000 }) ``` **Better Auth client** ```ts const { data } = await authClient.goodWebhooks.rotateSecret({ id: endpointId, graceMs: 3_600_000 }) ``` Signing secret rotation with a one-hour overlap: 1. **Rotate** (your app): `rotateSecret()` returns the new secret once. 2. **Overlap** (worker): Every request carries signatures from both secrets. 3. **Install** (receiver): The customer switches to the new secret. 4. **Overlap ends**: Only the new secret signs. During the grace period both secrets are usable: delivery signs with both, and `parseWebhook()` accepts either. Hand the new secret to the receiver before the period ends. * Default and maximum `graceMs` is 24 hours. * A second rotation during an active overlap fails, unless you pass `graceMs: 0`, which replaces the current secret immediately and drops the old one. ## Rotate the standalone storage key [#rotate-the-standalone-storage-key] The standalone PostgreSQL provider decrypts with the current key first, then any `decryptionKeys`. Reads never rewrite ciphertext, so rotation is explicit: ### Generate and store the new key [#generate-and-store-the-new-key] ```sh node -e 'import("good-webhooks").then(m => console.log(m.generateEncryptionKey()))' ``` ### Let every process read both keys [#let-every-process-read-both-keys] Deploy the new key as a `decryptionKeys` entry on every app and worker while `encryptionKey` stays the old one. ### Switch the writer [#switch-the-writer] Once all readers accept both keys, make the new key `encryptionKey` and move the old one into `decryptionKeys`: ```ts const management = createPostgresManagement({ database, eventTypes, encryptionKey: newKey, decryptionKeys: [oldKey], }) ``` Pass this provider to `createWebhooks({ management })` or its `source` to `createDelivery()`. The plain `encryptionKey` option on `createWebhooks()` has no `decryptionKeys`. ### Re-encrypt each scope [#re-encrypt-each-scope] ```ts const rewritten = await management.reencrypt(scope) // including null for application scope ``` You need an inventory of scopes with live endpoints; the library does not enumerate owners. Re-encryption covers paused endpoints and is safe under concurrent edits. A failed run can leave a mix of old and new ciphertext. Keep both keys and run again. ### Retire the old key [#retire-the-old-key] Only after every affected scope reports success and active endpoints resolve under the new key. ## Rotate Better Auth secrets [#rotate-better-auth-secrets] Better Auth 1.7.7 supports [versioned secrets](https://better-auth.com/docs/reference/options#secrets). The first entry encrypts new values; the others decrypt values written under their version. > **Warning: The first move to versioned secrets needs coordination.** Ciphertext written under a single `secret` has no version. Keep that value in `secret` when you > add `secrets`; moving it into the array alone does not decrypt old records. The first process that > uses `secrets` also starts writing a versioned envelope that single-secret processes cannot read. > Pause writes, drain old processes, deploy the new configuration everywhere, then resume. Start by preloading the new key behind the old one on every app and worker: ```ts const auth = betterAuth({ ...authOptions, secret: oldSecret, // still required for unversioned ciphertext secrets: [ { version: 1, value: oldSecret }, { version: 2, value: newSecret }, ], }) ``` When every reader accepts version 2, promote it: ```ts const auth = betterAuth({ ...authOptions, secret: oldSecret, secrets: [ { version: 2, value: newSecret }, { version: 1, value: oldSecret }, ], }) ``` Then rewrite endpoint records: ```ts const management = await createBetterAuthManagement(auth) await management.reencrypt(scope) ``` This rewrites webhook endpoint records only. Other Better Auth encrypted state (TOTP secrets, OAuth tokens) follows Better Auth's own procedure. Keep `secret` and old versions until all of it is rewritten. Never reuse a version number for a different key. Receiver-facing signing secrets are unchanged throughout. # Migrations > Pick the SQL for the components you run, apply it with your own migration runner, and keep runtime schema names in sync. Source: https://good-webhooks.dev/docs/operations/migrations Good Webhooks never applies DDL. `getPostgresMigration()` returns SQL text; the shipped `.sql` files are the same output for the default `public` schema. Your migration runner applies it once. ## Which SQL do I need? [#which-sql-do-i-need] | Setup | Management | Delivery | | -------------------------------------------------- | ---------------------------------------- | -------------------------------------- | | Standalone management and delivery, same schema | `001-initial.sql` (both components) | | | Standalone management only | `management.sql` | none | | Better Auth management only | Better Auth or ORM schema workflow | none | | Better Auth management with Good Webhooks delivery | Better Auth or ORM schema workflow | `delivery.sql` | | Management and delivery in separate schemas | generated with `component: 'management'` | generated with `component: 'delivery'` | Never apply the combined file and a component file to the same installation. ## Generate SQL [#generate-sql] ```ts import { getPostgresMigration } from 'good-webhooks/migrations' const sql = getPostgresMigration() // both components, schema 'public' const deliveryOnly = getPostgresMigration({ component: 'delivery', schema: 'notifications' }) ``` Or from the shell: ```sh node -e 'import("good-webhooks/migrations").then(m => process.stdout.write(m.getPostgresMigration()))' > 001-webhooks.sql psql "$DATABASE_URL" --single-transaction -v ON_ERROR_STOP=1 -f 001-webhooks.sql ``` The SQL has no `BEGIN` or `COMMIT`. Wrap it in one transaction with your migration ledger entry. Every script starts with `CREATE SCHEMA IF NOT EXISTS`; table creation itself is not idempotent. The shipped files are also package exports, resolvable with `import.meta.resolve('good-webhooks/migrations/delivery.sql')` for migration tools that read files. ## Match the runtime schema [#match-the-runtime-schema] Whatever schema you generate for must be the schema you configure at runtime: ```ts import { createWebhooks } from 'good-webhooks' import { getPostgresMigration } from 'good-webhooks/migrations' const schema = 'notifications' const sql = getPostgresMigration({ schema }) // apply with your migration tool const webhooks = createWebhooks({ database: pool, schema, encryptionKey, events }) ``` Management and delivery can use different schemas or different databases: ```ts const management = createPostgresManagement({ database: managementDatabase, schema: 'management', eventTypes: Object.keys(events), encryptionKey, }) const delivery = createDelivery({ database: deliveryDatabase, schema: 'delivery', source: management.source, events, }) ``` An injected management provider keeps its own schema. Delivery's `schema` option never moves it. For Better Auth, the model's placement comes from your Better Auth database or ORM configuration; generate only the delivery SQL. ## Check before you start [#check-before-you-start] ```ts await webhooks.check() ``` `check()` verifies that the expected tables and columns exist and that each component records schema version `4`. It throws `SCHEMA_MISMATCH` otherwise, with a message naming the SQL to apply next, and never changes anything. Call it at startup in the app and in every worker. ## Upgrades [#upgrades] Package versions and schema versions are independent: installing a new package never runs SQL. When a release needs a newer schema, its notes say so, and `check()` on the new package refuses to start until the upgrade is applied. Generate the upgrade from the version your database records: ```ts import { getPostgresUpgrade } from 'good-webhooks/migrations' const sql = getPostgresUpgrade({ from: 3 }) // both components in 'public' ``` Or read the shipped file, for example `good-webhooks/migrations/upgrades/004-all.sql`. Separate namespaces take `component: 'management'` and `component: 'delivery'` with their own `schema`. The SQL checks the recorded version first and raises inside your transaction if it differs, so applying it twice or against the wrong version rolls back. ### Rollout order [#rollout-order] 1. Deploy nothing yet. Apply the upgrade SQL with your migration runner in one transaction. Processes on the previous package keep running when the release notes say the upgrade is compatible with them; the 3 to 4 upgrade is. 2. Restart producers on the new package. Their `check()` now passes. 3. Restart workers and cleanup jobs on the new package. A restarted process on the old package fails fast with `SCHEMA_MISMATCH`, so finish the rollout rather than mixing versions for long. Never run a new package against an old schema or an old package against a schema it does not know: both refuse at `check()`. If a process starts without calling `check()`, it may run unsupported queries. The repository rehearses this order against the previously published package in CI. ### Prototype storage [#prototype-storage] Storage from before version `3` was never published and has no upgrade path. Use fresh storage. See [compatibility](https://good-webhooks.dev/docs/operations/compatibility.md) and the [migration reference](https://good-webhooks.dev/docs/reference/migrations.md). # Performance > Measured publish and delivery throughput for a fixed profile on one machine, how it was measured, and what the numbers do not say. Source: https://good-webhooks.dev/docs/operations/performance These numbers come from `pnpm benchmark` in the repository: a fixed profile, local receivers, one PostgreSQL instance. They show what the library costs on its own. They are not a capacity guarantee for your receivers, network, or database. ## Profile [#profile] | Setting | Value | | -------------------------- | -------------------------------------------------------------------------------------- | | Endpoints | 20, each in its own scope | | Events | 250 per endpoint, 5,000 in total | | Deliveries | 1 per event, 5,000 in total | | Workers | 2 processes, `concurrency: 32` each | | Per-endpoint in-flight cap | 8 | | Receiver | Local HTTP, verifies the signature, 5 ms delay, `204` | | Pool | 20 connections per process | | Publish | 20 endpoints in parallel, sequential per endpoint, each publish in its own transaction | ## Results [#results] Measured on 12 October 2026. Three consecutive runs; the table shows the middle run. | Measurement | Result | | ------------------------------------------ | --------------------------------------- | | Publish rate | 3,350 events/s | | Drain rate, 5,000 queued deliveries | 1,550 deliveries/s | | Time to drain the backlog | 3.2 s | | Attempt latency, claim to recorded outcome | p50 17 ms, p95 23 ms, p99 28 ms | | Time in queue, publish to success | p50 2.5 s, p95 3.2 s, max 3.4 s | | Attempts | 5,000 succeeded, 0 retried, 0 abandoned | Time in queue is dominated by backlog depth: every event was published before the first worker started, so the median event waited for half the backlog. With workers already running, time in queue for a single event is close to the attempt latency plus the poll interval. | Environment | | | ----------- | ----------------------------------- | | Machine | Apple M3 Pro, 12 cores, 18 GB | | Node.js | 24.21 | | PostgreSQL | 16.15 in Docker on the same machine | ## What the numbers do not say [#what-the-numbers-do-not-say] * **Receiver time dominates in production.** The receiver here answers in 5 ms. A receiver that takes 300 ms makes delivery throughput a function of `concurrency` and `maxInFlight`, not of the library. * **One machine.** Workers, receivers, PostgreSQL, and the benchmark share CPU. Separate hosts change both directions: less contention, more network latency. * **No failures.** The profile has no retries, timeouts, or lease recoveries. [Failure behavior](https://good-webhooks.dev/docs/operations/failure-behavior.md) and the operations rehearsal cover those paths; they are slower by design. * **Small history.** The tables start empty. Delivery claims use partial indexes on status, so a large succeeded history should not slow claims, but this profile does not prove it. ## Run it yourself [#run-it-yourself] ```sh export TEST_DATABASE_URL='postgres://postgres:webhooks_dev_only@127.0.0.1:55439/webhooks' pnpm benchmark ``` The script creates a unique schema, runs the profile, prints a JSON report, and drops the schema. It fails if any delivery does not succeed exactly once. Change `scripts/benchmark/config.mjs` to model your own receiver delay or concurrency, and compare runs on the same machine only. # Production checklist > Everything to settle before real customers receive webhooks, with a link to the details for each item. Source: https://good-webhooks.dev/docs/operations/production Work through this list once before launch. Each item links to the page that explains it. ## Keys and secrets [#keys-and-secrets] * [ ] The storage encryption key is generated once, kept in a secret manager, and loaded by every app and worker process. Never generate it at startup. [Manage endpoints](https://good-webhooks.dev/docs/guides/endpoints.md) * [ ] Signing secrets are shown to the customer once, over a secure channel, and never logged. [Security model](https://good-webhooks.dev/docs/concepts/security.md) * [ ] You know how to rotate both a signing secret and the storage key. [Key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md) ## Database [#database] * [ ] Migrations run through your migration tool, not at app startup. [Migrations](https://good-webhooks.dev/docs/operations/migrations.md) * [ ] Every process calls `check()` at startup and fails fast on `SCHEMA_MISMATCH`. * [ ] Pool sizes and statement timeouts are budgeted across app, worker, and cleanup processes. [Run workers](https://good-webhooks.dev/docs/operations/workers.md#bound-database-waits) ## Publishing [#publishing] * [ ] Events are published inside the transaction that changes your data, where the tables share a database. [Publish events](https://good-webhooks.dev/docs/guides/publish.md#publish-inside-your-transaction) * [ ] Every `publish()` that can repeat has an `idempotencyKey` derived from the business fact. * [ ] Your app authorizes the caller before choosing a scope. Scopes don't check permissions. [Scopes](https://good-webhooks.dev/docs/concepts/scopes.md) * [ ] `allowLocalhost` is off. ## Worker [#worker] * [ ] At least one worker runs as its own supervised process, restarted with backoff on failure. [Run workers](https://good-webhooks.dev/docs/operations/workers.md) * [ ] `SIGTERM` aborts the worker's signal, and the supervisor's kill deadline is longer than `shutdownGraceMs`. * [ ] Worker failures are logged and alerted on: either `run()` rejects and the process exits, or your `onError` logs and you watch that deliveries keep moving. [Handle failures](https://good-webhooks.dev/docs/operations/workers.md#handle-failures) * [ ] You have read what a crash, a dropped connection, and a hard kill do to in-flight work. [Failure behavior](https://good-webhooks.dev/docs/operations/failure-behavior.md) * [ ] You have compared your receiver latency and volume against the measured profile and sized `concurrency` and `maxInFlight` accordingly. [Performance](https://good-webhooks.dev/docs/operations/performance.md) ## History [#history] * [ ] A cleanup job calls `prune()` on a schedule. Workers never delete history. [Retention](https://good-webhooks.dev/docs/operations/retention.md) * [ ] Delivery history is treated as customer data: it holds payloads and response bodies. * [ ] Support staff know how to inspect deliveries and replay one. [Retries and replay](https://good-webhooks.dev/docs/operations/retries-and-replay.md) ## Receivers [#receivers] * [ ] Receivers verify the raw body and record `event.id` with their change, so retries and replays are harmless. [Receive webhooks](https://good-webhooks.dev/docs/guides/receive.md) * [ ] Event names stay backward compatible. Breaking payload changes get a new name, such as `invoice.paid.v2`. ## Upgrades [#upgrades] * [ ] The package version is pinned exactly, and you read the release notes before upgrading. [Compatibility](https://good-webhooks.dev/docs/operations/compatibility.md) # Retention and cleanup > Prune expired events, deliveries, and attempts on a schedule you control. Source: https://good-webhooks.dev/docs/operations/retention Events are retained for seven days by default (`retentionMs`). After that they are eligible for deletion. Nothing deletes them automatically: neither `worker.run()` nor `worker.runOnce()` prunes. Schedule a cleanup job. ```ts const removed = await webhooks.worker.prune() // at most 100 events per call ``` `prune()` deletes up to 100 expired events with their deliveries and attempts. Call it in a loop until it returns zero or your budget runs out. ## A bounded cleanup job [#a-bounded-cleanup-job] The package ships a cleanup entrypoint that runs up to 20 batches or 30 seconds, then exits: ```ts title="examples/operations/cleanup.ts" import { createWebhooks } from 'good-webhooks' import { Pool } from 'pg' import { errorDetails, readOperationsConfig } from './config.ts' const stop = new AbortController() let pool: Pool | undefined let failed = false let phase = 'configuration' let summary: { batches: number; deleted: number; reason: string } | undefined const requestStop = () => stop.abort() const fail = (error: unknown, at: string) => { failed = true process.exitCode = 1 console.error( JSON.stringify({ event: 'webhooks.cleanup.failed', phase: at, ...errorDetails(error) }), ) stop.abort() } process.on('SIGTERM', requestStop) process.on('SIGINT', requestStop) try { const config = readOperationsConfig('cleanup') pool = new Pool(config.database) pool.on('error', (error) => fail(error, 'pool')) const clientError = (error: Error) => fail(error, 'database') pool.on('acquire', (client) => client.on('error', clientError)) pool.on('release', (_error, client) => client.off('error', clientError)) const webhooks = createWebhooks({ database: pool, ...config.webhooks }) phase = 'startup' await webhooks.check() phase = 'prune' const deadline = performance.now() + config.cleanupMaxDurationMs let batches = 0 let deleted = 0 let reason = 'batch_budget' while (batches < config.cleanupMaxBatches) { if (stop.signal.aborted) { reason = 'signal' break } if (performance.now() >= deadline) { reason = 'time_budget' break } const count = await webhooks.worker.prune() batches += 1 deleted += count // A short batch can reflect contention. Continue until zero progress or a budget ends. if (count === 0) { reason = 'no_progress' break } } summary = { batches, deleted, reason } if (reason === 'batch_budget' || reason === 'time_budget') { console.warn(JSON.stringify({ event: 'webhooks.cleanup.budget_exhausted', ...summary })) } } catch (error) { fail(error, phase) } finally { stop.abort() try { await pool?.end() } catch (error) { fail(error, 'close') } process.off('SIGTERM', requestStop) process.off('SIGINT', requestStop) if (!failed && summary) console.log(JSON.stringify({ event: 'webhooks.cleanup.finished', ...summary })) } ``` It uses the same [configuration](https://good-webhooks.dev/docs/operations/workers.md#a-supervised-worker-process) as the worker plus `WEBHOOK_CLEANUP_MAX_BATCHES` and `WEBHOOK_CLEANUP_MAX_DURATION_MS`. Run it from a checkout with `node packages/good-webhooks/examples/operations/cleanup.ts`. It closes its pool, then logs `webhooks.cleanup.finished` with the batch count, deletion count, and stop reason. Budget exhaustion also logs `webhooks.cleanup.budget_exhausted` and still exits zero. ## Size the schedule [#size-the-schedule] Twenty batches per minute remove at most 2,000 events per minute. Locks and active leases lower that. Pick a cadence whose observed throughput exceeds the rate at which history expires, avoid overlapping runs, and alert on repeated budget exhaustion and nonzero exits. `no_progress` means the last batch deleted nothing. That can be because everything eligible is gone, or because locks and leases deferred some rows. ## What pruning removes [#what-pruning-removes] * The event, so it can no longer be replayed. * Its publication idempotency key, so the key can be accepted again with new content. If your business needs longer deduplication, keep your own record. Cleanup runs across every scope in its delivery database and schema. It never touches Better Auth management records or receiver receipts. # Retries and replay > Inspect delivery history, tell receiver failures from provider failures, and replay an event without changing its identity. Source: https://good-webhooks.dev/docs/operations/retries-and-replay History and replay are server operations on the delivery instance in the endpoint's scope. They are not exposed as Better Auth routes. ## Retry policy [#retry-policy] | Response | Outcome | | ----------------------------------------- | ------------------------------------ | | `2xx` | Succeeded | | Network error, `408`, `425`, `429`, `5xx` | Retried on the schedule | | Any other `4xx` | Failed, no retry | | `3xx` | Failed. Redirects are never followed | Defaults: delays of 1s, 5s, 30s, 2m, 10m, and 30m (seven attempts in total), and a maximum delivery age of 24 hours. Configure both through `retry` on the factory. A retry keeps the event ID and body and gets a fresh timestamp and signature. Two things look like failures but are not receiver failures: * **Paused endpoint.** The delivery waits until resume or expiry. * **Provider lookup failure.** The delivery is deferred without consuming an attempt, and the error goes to the worker's `onError`. ## Inspect deliveries [#inspect-deliveries] History and replay work on the client for the endpoint's scope: ```ts import { webhooks } from './webhooks.js' const customer = webhooks.forScope({ type: 'organization', id: organizationId }) const page = await customer.deliveries.list({ eventId: publication.eventId, limit: 25 }) for (const item of page.items) { const detail = await customer.deliveries.get(item.id) console.log(detail.status, detail.lastStatus, detail.attempts.length) } ``` Filter by `eventId`, `endpointId`, or `status`. Results are newest first; pass `page.nextCursor` as `before` for the next page. `get()` includes the attempt list with status codes, captured response bodies (up to `maxResponseBytes`), and errors. Response bodies and payloads can contain customer data. Treat history as sensitive. ## Replay [#replay] 1. **Fix the cause** (you): The receiver is back, or its bug is fixed. 2. **replay()** (your app): Creates a new delivery of the same event. 3. **Send** (worker): Same event ID and body, fresh retry budget. 4. **Dedupe** (receiver): Already processed? The receiver skips it. After fixing the cause, replay the original delivery: ```ts const detail = await customer.deliveries.get(deliveryId) if (detail.replayOf === null && ['succeeded', 'failed'].includes(detail.status)) { const replay = await customer.deliveries.replay(detail.id) } ``` Rules: * Only original `succeeded` or `failed` deliveries are eligible. A replay cannot be replayed. * One pending or in-flight replay per original at a time, otherwise `REPLAY_IN_PROGRESS`. * The endpoint must be active and the event still [retained](https://good-webhooks.dev/docs/operations/retention.md). * The replay gets a new delivery ID and a fresh retry budget, the same event ID and body, and the endpoint's current URL and secrets. * The worker still has to run. Because the event ID is unchanged, a [deduplicating receiver](https://good-webhooks.dev/docs/guides/receive.md#process-each-event-once) ignores a replay of an event it already processed. That is the intended way to replay safely. # Run workers > Start delivery explicitly, supervise it, and shut it down without losing in-flight work. Source: https://good-webhooks.dev/docs/operations/workers Nothing is sent until a worker runs. A worker is a process you start: it claims due deliveries, sends them, records each attempt, and schedules retries. You own the process, its database pool, and its supervision. ## The smallest worker [#the-smallest-worker] ```ts title="worker.ts" import { pool, webhooks } from './webhooks.js' const stop = new AbortController() process.once('SIGTERM', () => stop.abort()) process.once('SIGINT', () => stop.abort()) await webhooks.check() // fails fast if migrations are missing await webhooks.worker.run({ signal: stop.signal }) // returns after a clean shutdown await pool.end() ``` Run it as its own process next to your app, and apply [migrations](https://good-webhooks.dev/docs/operations/migrations.md) first. `run()` also takes `pollIntervalMs`, `shutdownGraceMs`, and `onError`; see the [worker reference](https://good-webhooks.dev/docs/reference/delivery.md#worker). What run() does in a loop: 1. **Claim**: Lease due deliveries, respecting each endpoint's in-flight limit. 2. **Resolve**: Read each endpoint's current URL, status, and secrets. 3. **Send**: Sign and POST. No database transaction is held open meanwhile. 4. **Record**: Store the attempt; mark succeeded, failed, or due for retry. After a batch that made at least one attempt, `run()` starts the next batch at once. After an empty batch, it waits `pollIntervalMs` (default 1 second). Retries keep their own schedule regardless. Workers handle every scope in their delivery database and schema. Run as many as you like: they coordinate through PostgreSQL leases and share the per-endpoint limits. ## One batch instead of a loop [#one-batch-instead-of-a-loop] `runOnce()` processes one bounded batch and returns counts. Use it from a scheduler or in tests: ```ts const result = await webhooks.worker.runOnce() // { claimed, succeeded, retried, failed, stale } ``` ## A supervised worker process [#a-supervised-worker-process] The package ships a complete entrypoint. It validates configuration, listens for signals, checks the schema, runs, and exits nonzero on failure after closing its pool. ```ts title="examples/operations/worker.ts" import { createWebhooks } from 'good-webhooks' import { Pool } from 'pg' import { errorDetails, readOperationsConfig } from './config.ts' const stop = new AbortController() let pool: Pool | undefined let failed = false let phase = 'configuration' const requestStop = () => stop.abort() const fail = (error: unknown, at: string) => { failed = true process.exitCode = 1 console.error( JSON.stringify({ event: 'webhooks.worker.failed', phase: at, ...errorDetails(error) }), ) stop.abort() } // Register process handlers before reading configuration or starting database I/O. process.on('SIGTERM', requestStop) process.on('SIGINT', requestStop) try { const config = readOperationsConfig('worker') pool = new Pool(config.database) // The pool is lazy. This listener exists before the first connection is opened. pool.on('error', (error) => fail(error, 'pool')) // Pool errors cover idle connections. Transactions also need a checked-out client listener. const clientError = (error: Error) => fail(error, 'database') pool.on('acquire', (client) => client.on('error', clientError)) pool.on('release', (_error, client) => client.off('error', clientError)) const webhooks = createWebhooks({ database: pool, ...config.webhooks }) phase = 'startup' await webhooks.check() if (!stop.signal.aborted) { console.log(JSON.stringify({ event: 'webhooks.worker.started' })) phase = 'run' // Unexpected failures stop the process. A supervisor owns restart backoff. await webhooks.worker.run({ signal: stop.signal, pollIntervalMs: config.pollIntervalMs, shutdownGraceMs: 30_000, }) } } catch (error) { fail(error, phase) } finally { stop.abort() try { await pool?.end() } catch (error) { fail(error, 'close') } process.off('SIGTERM', requestStop) process.off('SIGINT', requestStop) if (!failed) console.log(JSON.stringify({ event: 'webhooks.worker.stopped' })) } ``` Configure it through the environment: | Variable | Required | Purpose | | --------------------------------- | -------- | ------------------------------------------------------------ | | `DATABASE_URL` | yes | PostgreSQL connection string | | `WEBHOOK_ENCRYPTION_KEY` | yes | The key that encrypted the registered endpoints | | `WEBHOOK_SCHEMA` | | Must match the migrations and the producer. Default `public` | | `WEBHOOK_POLL_INTERVAL_MS` | | Wait after an empty batch. Default 1000 | | `WEBHOOK_DB_CONNECT_TIMEOUT_MS` | | Default 5000 | | `WEBHOOK_DB_STATEMENT_TIMEOUT_MS` | | Default 10000. The client query timeout is one second longer | | `WEBHOOK_ALLOW_LOCALHOST` | | `true` permits loopback HTTP receivers. Development only | Run it from a checkout with `node packages/good-webhooks/examples/operations/worker.ts`, or copy it and replace the event map with yours. > **Warning.** Never generate a fresh encryption key on startup. A wrong but well-formed key fails when the > worker first decrypts an endpoint secret, not necessarily at startup. For Better Auth management, the worker also constructs the auth instance and provider. See [add delivery to Better Auth](https://good-webhooks.dev/docs/better-auth/delivery.md#run-a-separate-worker). ## Handle failures [#handle-failures] Without `onError`, an internal failure rejects `run()` and the process exits. Let your supervisor restart it with backoff. If you pass `onError` to log and continue, watch that deliveries keep making progress. Receiver failures are not internal failures; they follow the retry policy. The example entrypoint logs JSON lines (`webhooks.worker.started`, `webhooks.worker.failed`, `webhooks.worker.stopped`) without configuration, payloads, or secrets. The library itself writes no logs. [Failure behavior](https://good-webhooks.dev/docs/operations/failure-behavior.md) lists what each kind of failure does to in-flight work. ## Shut down cleanly [#shut-down-cleanly] Abort the signal to stop: 1. **Stop claiming**: No new deliveries; pending endpoint lookups are abandoned. 2. **Grace period**: Requests already being sent get up to `shutdownGraceMs` (30 s default). 3. **Cancel**: Remaining requests are cancelled and their outcome recorded. 4. **Return**: `run()` resolves after its database writes finish. Await the worker before closing the pool. Then give your supervisor a final deadline that covers the grace period, database writes, pool closure, and your own cleanup. The grace period is not a process exit deadline. A cancelled HTTP request may still have been processed by the receiver. Such attempts are retried, which is one more reason [receivers deduplicate](https://good-webhooks.dev/docs/guides/receive.md#process-each-event-once). ## Bound database waits [#bound-database-waits] The example configuration sets connection and statement timeouts, an idle-in-transaction timeout, and ten pool connections per process: ```ts title="examples/operations/config.ts" import { events } from '../basic/events.ts' function required(env: NodeJS.ProcessEnv, name: string): string { const value = env[name] if (!value?.trim()) throw new Error(`${name} is required.`) return value } function integer( env: NodeJS.ProcessEnv, name: string, fallback: number, min: number, max: number, ): number { const raw = env[name] const value = raw === undefined ? fallback : /^\d+$/.test(raw) ? Number(raw) : NaN if (!Number.isSafeInteger(value) || value < min || value > max) { throw new Error(`${name} must be an integer between ${min} and ${max}.`) } return value } /** Importing this module reads no environment, opens no connection, and starts no worker. */ export function readOperationsConfig( role: 'worker' | 'cleanup', env: NodeJS.ProcessEnv = process.env, ) { const connectionString = required(env, 'DATABASE_URL') try { const url = new URL(connectionString) if (!['postgres:', 'postgresql:'].includes(url.protocol) || !url.hostname) throw new Error() } catch { throw new Error('DATABASE_URL must be a PostgreSQL connection URL.') } const encryptionKey = required(env, 'WEBHOOK_ENCRYPTION_KEY') const localhost = env.WEBHOOK_ALLOW_LOCALHOST if (localhost !== undefined && localhost !== 'true' && localhost !== 'false') { throw new Error('WEBHOOK_ALLOW_LOCALHOST must be true or false.') } const statementTimeout = integer(env, 'WEBHOOK_DB_STATEMENT_TIMEOUT_MS', 10_000, 100, 120_000) return { database: { connectionString, application_name: `webhooks-example-${role}`, max: 10, connectionTimeoutMillis: integer(env, 'WEBHOOK_DB_CONNECT_TIMEOUT_MS', 5_000, 100, 60_000), statement_timeout: statementTimeout, query_timeout: statementTimeout + 1_000, idle_in_transaction_session_timeout: statementTimeout, idleTimeoutMillis: 30_000, }, webhooks: { events, encryptionKey, schema: env.WEBHOOK_SCHEMA ?? 'public', allowLocalhost: localhost === 'true', }, pollIntervalMs: integer(env, 'WEBHOOK_POLL_INTERVAL_MS', 1_000, 10, 60_000), cleanupMaxBatches: integer(env, 'WEBHOOK_CLEANUP_MAX_BATCHES', 20, 1, 1_000), cleanupMaxDurationMs: integer(env, 'WEBHOOK_CLEANUP_MAX_DURATION_MS', 30_000, 100, 3_600_000), } } /** Log operational errors without dumping configuration, payloads, or signing material. */ interface ErrorDetails { name: string message: string code?: string causes?: ErrorDetails[] } export function errorDetails(error: unknown): ErrorDetails { const details = singleErrorDetails(error) if (error instanceof AggregateError) details.causes = error.errors.slice(0, 10).map(singleErrorDetails) return details } function singleErrorDetails(error: unknown): ErrorDetails { return { name: error instanceof Error ? error.name : 'Error', message: error instanceof Error ? error.message : 'Unexpected non-Error failure.', ...(error !== null && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? { code: error.code } : {}), } } ``` Budget pool connections across producers, workers, and cleanup jobs. A client-side timeout does not guarantee server-side cancellation, and pool closure waits for checked-out clients. Workers never prune history. Schedule [cleanup](https://good-webhooks.dev/docs/operations/retention.md) separately. # Quick start > Send your first signed webhook in about five minutes. One database, one file, one command. Source: https://good-webhooks.dev/docs/quick-start You will publish an `invoice.paid` event and watch a local receiver verify it. The script does four things: What the quick-start script does: 1. **Register** (your app): Create an endpoint for a local receiver. Get its signing secret once. 2. **Publish** (your app): `publish()` validates the payload and stores one delivery per endpoint. 3. **Deliver** (worker): The worker signs the event and POSTs it to the receiver. 4. **Verify** (receiver): `parseWebhook()` checks the signature and returns a typed event. You need **Node.js 24** and **Docker**. Using Better Auth? Follow the [Better Auth quick start](https://good-webhooks.dev/docs/better-auth/quick-start.md) instead. ### Create a project [#create-a-project] ```sh mkdir good-webhooks-quickstart && cd good-webhooks-quickstart npm init -y ``` npm pnpm yarn bun ```bash npm install good-webhooks@beta pg zod ``` ```bash pnpm add good-webhooks@beta pg zod ``` ```bash yarn add good-webhooks@beta pg zod ``` ```bash bun add good-webhooks@beta pg zod ``` ### Start PostgreSQL [#start-postgresql] A throwaway database on port 55439: ```sh docker run --name good-webhooks-quickstart -d \ -e POSTGRES_PASSWORD=dev_only -e POSTGRES_DB=webhooks \ -p 127.0.0.1:55439:5432 postgres:16 until docker exec good-webhooks-quickstart pg_isready -h 127.0.0.1 -U postgres -d webhooks; do sleep 1; done ``` ### Write the script [#write-the-script] Save this as `quick-start.mts`. The numbered comments match the four stages above. ```ts title="quick-start.mts" import { once } from 'node:events' import { createServer } from 'node:http' import { Pool } from 'pg' import { z } from 'zod' import { createWebhooks, generateEncryptionKey } from 'good-webhooks' import { getPostgresMigration } from 'good-webhooks/migrations' import { parseWebhook } from 'good-webhooks/verify' // Your event catalog. The publisher and the receiver share it. const events = { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }), } const pool = new Pool({ connectionString: 'postgres://postgres:dev_only@127.0.0.1:55439/webhooks', }) // Throwaway database only: create the tables once. Real apps use their migration runner. const { rows } = await pool.query(`SELECT to_regclass('webhook_events') AS found`) if (!rows[0].found) await pool.query(getPostgresMigration()) const webhooks = createWebhooks({ database: pool, events, encryptionKey: generateEncryptionKey(), // Real apps load a stored key. allowLocalhost: true, // Development only: allows http://127.0.0.1 receivers. }) // A receiver. Normally this is someone else's server. let secret = '' const receiver = createServer(async (request, response) => { const chunks: Buffer[] = [] for await (const chunk of request) chunks.push(chunk) try { // 4. Verify: authenticate the raw bytes, then decode a typed event. const event = await parseWebhook({ body: Buffer.concat(chunks), headers: request.headers, secret, events, }) console.log(`✓ Receiver verified ${event.type} for ${event.data.invoiceId}`) response.writeHead(200).end() } catch (error) { console.error('✗ Receiver rejected the request:', error) response.writeHead(400).end() } }) receiver.listen(0, '127.0.0.1') await once(receiver, 'listening') const { port } = receiver.address() as { port: number } try { // 1. Register: the secret is returned once. Hand it to the receiver. const created = await webhooks.endpoints.create({ url: `http://127.0.0.1:${port}/webhooks`, eventTypes: ['invoice.paid'], }) secret = created.secret // 2. Publish: stores the event and one delivery per subscribed endpoint. const published = await webhooks.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_123', amount: 4200 }, }) console.log(`✓ Published to ${published.deliveryCount} endpoint`) // 3. Deliver: one batch. Production workers call run() and keep going. const result = await webhooks.worker.runOnce() console.log(`✓ Worker delivered ${result.succeeded}, failed ${result.failed}`) await webhooks.endpoints.remove(created.endpoint.id) } finally { receiver.close() await pool.end() } ``` ### Run it [#run-it] ```sh node quick-start.mts ``` You should see: ```text ✓ Published to 1 endpoint ✓ Receiver verified invoice.paid for inv_123 ✓ Worker delivered 1, failed 0 ``` That's a signed [Standard Webhooks](https://www.standardwebhooks.com) request, sent by the worker and verified against the endpoint's secret. ### Clean up [#clean-up] ```sh docker rm -f good-webhooks-quickstart ``` ## From script to application [#from-script-to-application] The script cut five corners. Each one has a guide: | In the script | In your application | Guide | | -------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------ | | `pool.query(getPostgresMigration())` | Your migration runner applies the SQL once | [Migrations](https://good-webhooks.dev/docs/operations/migrations.md) | | `generateEncryptionKey()` on every run | Generate once, store in a secret manager, load in every process | [Manage endpoints](https://good-webhooks.dev/docs/guides/endpoints.md) | | `worker.runOnce()` in the same file | A separate worker process calling `worker.run({ signal })` | [Run workers](https://good-webhooks.dev/docs/operations/workers.md) | | `publish()` on its own | `publish()` inside the transaction that changes your data | [Publish events](https://good-webhooks.dev/docs/guides/publish.md) | | The receiver only logs | The receiver records `event.id` with its change, so retries are harmless | [Receive webhooks](https://good-webhooks.dev/docs/guides/receive.md) | Drop `allowLocalhost` outside development. The [production checklist](https://good-webhooks.dev/docs/operations/production.md) collects the rest. ## Next steps [#next-steps] - [Publish events](https://good-webhooks.dev/docs/guides/publish.md): Typed events, idempotency keys, and transactional publish. - [Receive webhooks](https://good-webhooks.dev/docs/guides/receive.md): Verify in Express, Next.js, or Hono, and process each event once. - [How it works](https://good-webhooks.dev/docs/concepts/how-it-works.md): The pieces behind the four stages above. - [Complete examples](https://good-webhooks.dev/docs/examples.md): Two runnable apps with retries, replay, and a browser UI. # Better Auth client > Client methods, event-name inference, and JSON result types. Source: https://good-webhooks.dev/docs/reference/better-auth-client `good-webhooks/better-auth/client` gives a Better Auth client typed methods for the plugin's endpoint management routes. ```ts import { goodWebhooksClient } from 'good-webhooks/better-auth/client' ``` ## goodWebhooksClient [#goodwebhooksclient] ```ts goodWebhooksClient( options?: { eventTypes: EventTypes }, ) ``` ```ts title="auth-client.ts" import { createAuthClient } from 'better-auth/client' import { goodWebhooksClient } from 'good-webhooks/better-auth/client' const eventTypes = ['invoice.created', 'invoice.paid'] as const export const authClient = createAuthClient({ plugins: [goodWebhooksClient({ eventTypes })], }) ``` Returns a Better Auth client plugin for `createAuthClient`. | Option | Contract | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `eventTypes` | Optional tuple that carries the server's event-name literals into client types. Omitting it leaves event names typed as `string`. It does not change the server's configured names or authorize requests. | The dedicated client entry point has no runtime import of the server plugin, database driver, or delivery worker. It configures all Good Webhooks route methods as POST and preserves JSON values with `JSON.parse`, including descriptions that look like dates. ## Client methods [#client-methods] ```ts import { authClient } from './auth-client.js' const { data, error } = await authClient.goodWebhooks.create({ url: 'https://receiver.example/webhooks', eventTypes: ['invoice.paid'], }) if (error) throw new Error(error.message) console.log(data.secret) // Show once. No later response contains it. ``` All methods return Better Auth's client result containing `data` and `error`. Request bodies and data shapes match the [server route reference](https://good-webhooks.dev/docs/reference/better-auth.md#authenticated-routes). | Client method | Server method | `data` | | --------------------------------------------- | ----------------------------- | ---------------------- | | `authClient.goodWebhooks.create(input)` | `createWebhookEndpoint` | `{ endpoint, secret }` | | `authClient.goodWebhooks.list(input)` | `listWebhookEndpoints` | `WebhookEndpointDTO[]` | | `authClient.goodWebhooks.get(input)` | `getWebhookEndpoint` | `WebhookEndpointDTO` | | `authClient.goodWebhooks.update(input)` | `updateWebhookEndpoint` | `WebhookEndpointDTO` | | `authClient.goodWebhooks.pause(input)` | `pauseWebhookEndpoint` | `WebhookEndpointDTO` | | `authClient.goodWebhooks.resume(input)` | `resumeWebhookEndpoint` | `WebhookEndpointDTO` | | `authClient.goodWebhooks.remove(input)` | `removeWebhookEndpoint` | `WebhookEndpointDTO` | | `authClient.goodWebhooks.rotateSecret(input)` | `rotateWebhookEndpointSecret` | `{ endpoint, secret }` | Each input accepts optional `scope`. Omission selects the signed-in user's personal scope. `scope: null` requests application scope and requires server policy. The client does not weaken server authorization. See [scope authorization](https://good-webhooks.dev/docs/reference/better-auth.md#scope-authorization). Endpoint timestamps remain ISO strings. ## Inference example [#inference-example] This compile-only example checks the public server and client types. It omits the database and session setup required by a running application. The [Better Auth quick start](https://good-webhooks.dev/docs/better-auth/quick-start.md) provides that setup. ```ts title="Typed Better Auth client" const eventTypes = ['invoice.created', 'invoice.paid'] as const const auth = betterAuth({ plugins: [goodWebhooks({ eventTypes })] }) const client = createAuthClient({ plugins: [goodWebhooksClient({ eventTypes })] }) const result = await client.goodWebhooks.create({ url: 'https://receiver.example/hook', eventTypes: ['invoice.created'], }) ``` The same event-name tuple restricts both APIs to `invoice.created` and `invoice.paid`. Unknown names fail typechecking. # Better Auth server > Authenticated endpoint routes and the trusted management provider. Source: https://good-webhooks.dev/docs/reference/better-auth `good-webhooks/better-auth` adds authenticated endpoint management routes to Better Auth and exposes the same storage as a trusted provider. ```ts import { goodWebhooks, createBetterAuthManagement } from 'good-webhooks/better-auth' import type { GoodWebhooksOptions, WebhookEndpointDTO, ManagementAction, ScopeAuthorization, ScopePolicy, } from 'good-webhooks/better-auth' ``` The documented and tested baseline is Better Auth `1.7.7` on Node.js 24. The optional peer range is `>=1.7.7 <1.8.0`. Database support depends on the host adapter and its migration workflow. The [adapter compatibility page](https://good-webhooks.dev/docs/better-auth/database-adapters.md) lists tested combinations and limits. For setup steps, see the [Better Auth plugin guide](https://good-webhooks.dev/docs/better-auth.md). ## goodWebhooks [#goodwebhooks] ```ts goodWebhooks( options: GoodWebhooksOptions, ) ``` ```ts title="auth.ts" import { betterAuth } from 'better-auth' import { goodWebhooks } from 'good-webhooks/better-auth' import { database } from './database.js' // Your existing database or adapter. export const auth = betterAuth({ database, secret: process.env.BETTER_AUTH_SECRET!, plugins: [goodWebhooks({ eventTypes: ['invoice.created', 'invoice.paid'] as const })], }) ``` Returns the `good-webhooks` Better Auth server plugin. It adds one management model and eight authenticated POST routes. It does not publish events, create delivery storage, or start a worker. | Option | Contract | | ------------------ | ------------------------------------------------------------------------------------------------------- | | `eventTypes` | Required configured event names. A literal tuple preserves names in server and client types. | | `allowLocalhost` | Optional boolean, default `false`. Permits development HTTP loopback destinations. | | `schema.modelName` | Optional physical model name, default `webhookEndpoint`. This is not a PostgreSQL namespace. | | `schema.fields` | Optional mapping from logical fields to physical field names. | | `authorizeScope` | Optional policy required for application and custom scopes. Cannot override user or organization rules. | ### Storage model [#storage-model] Schema field names are `scopeKey`, `allocationKey`, `creationToken`, `url`, `description`, `eventTypes`, `additionalEventTypes`, `status`, `encryptedSecret`, `previousEncryptedSecret`, `previousSecretExpiresAt`, `revision`, `createdAt`, and `updatedAt`. The plugin declares a unique `allocationKey` index for the per-scope endpoint limit. Generated fields and indexes are part of the storage contract. The host runs Better Auth's migration or schema-generation workflow. Standalone `management.sql` does not create this model. Signing secrets are encrypted with the auth instance's secret configuration. Unlike the [PostgreSQL provider](https://good-webhooks.dev/docs/reference/postgres.md#destination-checks), this provider does not resolve hostnames at registration. The worker still checks each destination before connecting. ## Authenticated routes [#authenticated-routes] ```ts import { auth } from './auth.js' export async function createEndpoint(request: Request) { return auth.api.createWebhookEndpoint({ headers: request.headers, body: { url: 'https://receiver.example/webhooks', eventTypes: ['invoice.paid'] }, }) } ``` Each route lives under Better Auth's configured base path. Every route uses sensitive session middleware and disables response storage. Server calls use `auth.api.({ body, headers })` with the caller's session headers. Browsers use the [client plugin](https://good-webhooks.dev/docs/reference/better-auth-client.md). | Server method | POST path | Body besides `scope?` | Result | Action | | ----------------------------- | ------------------------------ | ------------------------------------------- | ---------------------- | -------- | | `createWebhookEndpoint` | `/good-webhooks/create` | `url`, `eventTypes`, `description?` | `{ endpoint, secret }` | `create` | | `listWebhookEndpoints` | `/good-webhooks/list` | None | `WebhookEndpointDTO[]` | `read` | | `getWebhookEndpoint` | `/good-webhooks/get` | `id` | `WebhookEndpointDTO` | `read` | | `updateWebhookEndpoint` | `/good-webhooks/update` | `id`, `url?`, `description?`, `eventTypes?` | `WebhookEndpointDTO` | `update` | | `pauseWebhookEndpoint` | `/good-webhooks/pause` | `id` | `WebhookEndpointDTO` | `update` | | `resumeWebhookEndpoint` | `/good-webhooks/resume` | `id` | `WebhookEndpointDTO` | `update` | | `removeWebhookEndpoint` | `/good-webhooks/remove` | `id` | `WebhookEndpointDTO` | `delete` | | `rotateWebhookEndpointSecret` | `/good-webhooks/rotate-secret` | `id`, `graceMs?` | `{ endpoint, secret }` | `update` | `scope` accepts `{ type, id }`, `null`, or omission. Bodies reject unknown fields. Creation and updates use the [management input limits](https://good-webhooks.dev/docs/reference/management.md#inputs-and-limits). An update accepts `description: null` to clear the value. Rotation accepts an integer `graceMs` from 0 through 86,400,000, defaulting to 86,400,000. `WebhookEndpointDTO` contains `id`, `url`, nullable `description`, `eventTypes`, `status`, `createdAt`, and `updatedAt`. Its timestamps are ISO strings, unlike trusted provider results, which contain `Date` values. Ordinary route results contain no signing material. Creation and rotation return the new secret separately. ## Scope authorization [#scope-authorization] | Requested scope | Authorization | | --------------- | ------------------------------------------------------------------------------------------------- | | Omitted | Resolves to `{ type: 'user', id: session.user.id }`. | | `user` | The ID must exactly match the current user. | | `organization` | Requires the organization plugin, current membership, and the requested `webhookEndpoint` action. | | `null` | Application scope. Requires `authorizeScope` to allow the exact action. | | Another type | Requires `authorizeScope` to allow that scope and action. | ```ts type ManagementAction = 'create' | 'read' | 'update' | 'delete' interface ScopeAuthorization { scope: ManagementScope action: ManagementAction user: User session: Session } type ScopePolicy = (input: ScopeAuthorization) => boolean | Promise ``` ```ts import { goodWebhooks, type ScopePolicy } from 'good-webhooks/better-auth' const authorizeScope: ScopePolicy = ({ scope, action, user }) => scope === null && action === 'read' && user.emailVerified export const plugin = goodWebhooks({ eventTypes: ['invoice.paid'] as const, authorizeScope }) ``` The policy defaults to denial for application and custom scopes. It does not run as an override for user or organization authorization. The [permissions guide](https://good-webhooks.dev/docs/better-auth/permissions.md) shows organization access control. **Organization roles, API keys, and owner deletion** The organization plugin's configured creator role has full endpoint access. Other roles need explicit grants. A role named `admin` does not receive grants by name alone. Organization-owned API keys are not an authentication path for these routes. User API-key session impersonation is not a promised compatibility path in this version. Deleting a user retires their personal endpoints. Deleting an organization through the organization endpoint retires its endpoints. Trusted sender lookups also check owner existence. Removing a member revokes that member's management access without retiring the organization's endpoints. ## Route errors [#route-errors] | Condition | HTTP status | | ------------------------------- | --------------------------------------------------- | | Missing session | `401` | | Unauthorized scope or action | `403` | | `INVALID_INPUT` or `UNSAFE_URL` | `400` | | `NOT_FOUND` | `404` | | `INVALID_STATE` | `409` | | Other `WebhookError` | `500` with the message `Webhook management failed.` | Better Auth validates request bodies and handles errors outside this mapping. The Good Webhooks error code remains in mapped error bodies. ## createBetterAuthManagement [#createbetterauthmanagement] ```ts createBetterAuthManagement(auth: { $context: Promise }): Promise ``` ```ts import { createBetterAuthManagement } from 'good-webhooks/better-auth' import { auth } from './auth.js' const management = await createBetterAuthManagement(auth) await management.removeScope({ type: 'organization', id: 'org_123' }) ``` Returns the same trusted management provider installed by `goodWebhooks`. A missing plugin produces `INVALID_CONFIG`. This helper needs neither a session nor an HTTP listener. Direct calls bypass route authorization. It uses Better Auth's adapter and encryption helpers with the auth instance's secret configuration. App and worker must share compatible configuration, storage, and decryption keys. See [add delivery to Better Auth](https://good-webhooks.dev/docs/better-auth/delivery.md). The provider exposes every [EndpointManagement operation](https://good-webhooks.dev/docs/reference/management.md#endpointmanagement), including `removeScope` and `reencrypt`, plus `source` for a sender. There are no HTTP routes for `removeScope` or `reencrypt`. # Core > Compose endpoint management and PostgreSQL delivery. Source: https://good-webhooks.dev/docs/reference/core The root entry point combines endpoint management and PostgreSQL delivery in one client. ```ts import { createWebhooks, createDelivery, generateEncryptionKey, verifyWebhook, WebhookError, } from 'good-webhooks' import type { WebhookOptions, Webhooks, WebhookClient, EndpointInput, EndpointPatch, WebhookErrorCode, } from 'good-webhooks' ``` The root also exports the shared types listed under [other root exports](#other-root-exports). For the step-by-step setup, see [installation](https://good-webhooks.dev/docs/installation.md). ## createWebhooks [#createwebhooks] ```ts createWebhooks(options: WebhookOptions): Webhooks ``` ```ts title="webhooks.ts" import { createWebhooks } from 'good-webhooks' import { Pool } from 'pg' import { z } from 'zod' export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) export const webhooks = createWebhooks({ database: pool, encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!, events: { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }), }, }) ``` `WebhookOptions` accepts every [delivery option](https://good-webhooks.dev/docs/reference/delivery.md#createdelivery) except `source`, plus these fields: | Field | Type | Meaning | | --------------- | ---------------------- | ---------------------------------------------------------------------------------------------------- | | `encryptionKey` | `string \| Uint8Array` | Required for the default PostgreSQL management provider. Base64-encoded 32 bytes or a 32-byte array. | | `management` | `EndpointManagement` | Optional existing provider. Its source supplies recipients and current endpoint state. | Construction does not open connections, apply migrations, or start a worker. * **Without `management`**, the constructor creates standalone PostgreSQL management from `database`, `schema`, the configured event names, `encryptionKey`, and `allowLocalhost`. `schema` then applies to both the management and delivery tables. * **With `management`**, that provider keeps its own database, schema, encryption, and URL-validation configuration. Core options do not reconfigure it, and `encryptionKey` is unused. Invalid options throw `INVALID_CONFIG`: a missing event map, a malformed key, a database without `query()` and `connect()`, or any [delivery option](https://good-webhooks.dev/docs/reference/delivery.md#createdelivery) out of range. **Rotating the storage encryption key with createWebhooks** The default provider has no `decryptionKeys` option through this constructor. Create a provider with [`createPostgresManagement`](https://good-webhooks.dev/docs/reference/postgres.md), which accepts old keys, and pass it as `management`. See [key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md). ## Returned client [#returned-client] The client exposes `publish`, `deliveries`, `deliverySettings`, and `worker` from the [delivery engine](https://good-webhooks.dev/docs/reference/delivery.md), plus these members: | Member | Behavior | | ------------------------------------------ | ------------------------------------------------------------------------------------------------ | | `endpoints.create(input)` | Creates an endpoint and returns `{ endpoint, secret }`. | | `endpoints.list()` | Returns all nondeleted endpoints in application scope. | | `endpoints.get(id)` | Returns one endpoint, including its deleted state. | | `endpoints.update(id, patch)` | Changes URL, description, or subscribed event names. | | `endpoints.pause(id)` | Records paused state. | | `endpoints.resume(id)` | Records active state after URL validation. | | `endpoints.remove(id)` | Records permanent deletion and clears stored signing secrets. | | `endpoints.rotateSecret(id, { graceMs? })` | Returns a new signing secret and the endpoint. | | `check()` | Checks the default management provider, if constructed here, then delivery schema compatibility. | | `forScope(scope)` | Returns the same publication, history, settings, and endpoint methods bound to one named scope. | ### endpoints [#endpoints] ```ts import { webhooks } from './webhooks.js' const { endpoint, secret } = await webhooks.endpoints.create({ url: 'https://receiver.example/webhooks', eventTypes: ['invoice.paid'], }) await webhooks.endpoints.rotateSecret(endpoint.id, { graceMs: 60 * 60 * 1000 }) ``` Endpoint methods follow the [management contract and limits](https://good-webhooks.dev/docs/reference/management.md#endpointmanagement), including its errors. `EndpointInput` and `EndpointPatch` restrict subscriptions to event names in `E`. `removeScope` and `reencrypt` are direct provider operations, not members of this client. The [endpoints guide](https://good-webhooks.dev/docs/guides/endpoints.md) covers the lifecycle. ### forScope [#forscope] ```ts import { webhooks } from './webhooks.js' const customer = webhooks.forScope({ type: 'organization', id: 'org_123' }) await customer.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_1', amount: 4200 } }) await customer.endpoints.list() ``` `forScope` validates and snapshots its scope, so later changes to the argument have no effect. An invalid scope throws `INVALID_INPUT`. It does not authorize a caller. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md). The scoped client has no `worker` or `check`: those operate at installation level. `WebhookClient` is its type. ### check [#check] ```ts import { webhooks } from './webhooks.js' await webhooks.check() // Throws SCHEMA_MISMATCH if tables are missing or outdated. ``` `check()` never applies migrations. When a provider is injected, it does not call that provider's optional health checks. The host stays responsible for checking its management storage. ## generateEncryptionKey [#generateencryptionkey] ```ts generateEncryptionKey(): string ``` ```ts import { generateEncryptionKey } from 'good-webhooks' console.log(generateEncryptionKey()) // Store once in your secret manager. ``` Returns 32 cryptographically random bytes encoded as base64. The key is the storage encryption key for standalone management signing secrets. It is distinct from the endpoint's `whsec_` signing secret. Keep it outside the database. ## Other root exports [#other-root-exports] | Export | Documented at | | ---------------- | ------------------------------------------------ | | `createDelivery` | [Delivery entry point](https://good-webhooks.dev/docs/reference/delivery.md) | | `verifyWebhook` | [Receiver entry point](https://good-webhooks.dev/docs/reference/verify.md) | | `WebhookError` | [Errors](https://good-webhooks.dev/docs/reference/errors.md) | `parseWebhook` is available only from `good-webhooks/verify`. The root also exports these types: | Group | Types | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- | | Events | `EventDefinitions`, `EventName`, `JsonValue`, `PublishInput`, `PublishOptions`, `PublishResult` | | Database | `SqlClient`, `DatabaseClient`, `Database` | | Endpoints | `Endpoint`, `EndpointStatus`, `EndpointWithSecret`, `EndpointInput`, `EndpointPatch` | | Delivery | `Delivery`, `DeliveryDetail`, `DeliveryStatus`, `DeliveryQuery`, `Attempt`, `Page`, `WorkerResult`, `DeliveryOptions`, `DeliveryEngine` | | Composition | `WebhookOptions`, `Webhooks`, `WebhookClient`, `EndpointManagement`, `EndpointSource`, `EndpointResolution`, `ManagementScope`, `Scope` | | Errors | `WebhookErrorCode` | # Delivery > Publication, delivery history, worker execution, and PostgreSQL sender options. Source: https://good-webhooks.dev/docs/reference/delivery `good-webhooks/delivery` is the PostgreSQL sender: it publishes events, records deliveries, and runs workers against any endpoint source. ```ts import { createDelivery } from 'good-webhooks/delivery' import type { DeliveryEngine, DeliveryOptions, EndpointSource, EndpointResolution, ManagementScope, } from 'good-webhooks/delivery' ``` Shared event, database, query, and result types come from [`good-webhooks`](https://good-webhooks.dev/docs/reference/core.md#other-root-exports). Every method on this page is also on the [`createWebhooks()`](https://good-webhooks.dev/docs/reference/core.md) client. ## createDelivery [#createdelivery] ```ts createDelivery(options: DeliveryOptions): DeliveryEngine ``` ```ts title="delivery.ts" import { createDelivery } from 'good-webhooks/delivery' import { createPostgresManagement } from 'good-webhooks/management/postgres' import { Pool } from 'pg' import { z } from 'zod' const database = new Pool({ connectionString: process.env.DATABASE_URL }) const events = { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }), } const management = createPostgresManagement({ database, eventTypes: Object.keys(events), encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!, }) export const delivery = createDelivery({ database, events, source: management.source }) ``` Any [`EndpointSource`](https://good-webhooks.dev/docs/reference/management.md#endpointsource) works as `source`, including [Better Auth management](https://good-webhooks.dev/docs/better-auth/delivery.md). The constructor starts no work. | Option | Default | Contract | | ---------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `database` | Required | PostgreSQL executor with `query()` and `connect()`. A checked-out client has `query()` and `release()`. `pg.Pool` satisfies this shape. | | `events` | Required | Nonempty map of event names to Standard Schema validators. | | `source` | Required | Trusted `EndpointSource` with `matchRecipients` and `resolveEndpoint`. | | `schema` | `'public'` | PostgreSQL schema containing delivery tables. | | `retry.delaysMs` | `[1000, 5000, 30000, 120000, 600000, 1800000]` | At most 20 integer delays, each from 1 through 86,400,000 ms. An empty array disables retries. | | `retry.maxAgeMs` | `86400000` | Delivery age limit from 100 through 2,592,000,000 ms. | | `retentionMs` | `604800000` | Event retention from 1,000 through 31,536,000,000 ms. Must be at least `retry.maxAgeMs`. | | `allowLocalhost` | `false` | Permits HTTP loopback destinations for development. Other private addresses stay forbidden. | | `delivery` | See below | Per-attempt and per-batch limits. | Event names match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$`. The constructor validates each event's Standard Schema interface. Any invalid option throws `INVALID_CONFIG`. [Delivery guarantees](https://good-webhooks.dev/docs/concepts/delivery-guarantees.md) explains how `retry` plays out. ### The delivery object [#the-delivery-object] The `delivery` object has the following fields. This table is generated from the public `DeliveryOptions` type. | Field | Type | Default | Description | | --- | --- | --- | --- | | `timeoutMs?` | `number` | | | | `concurrency?` | `number` | | | | `leaseMs?` | `number` | | | | `maxResponseBytes?` | `number` | | | | Field | Default | Limits | | ------------------ | ------- | ------------------------------------------------------------------ | | `timeoutMs` | `10000` | Integer from 10 through 120,000 ms. | | `concurrency` | `10` | Integer from 1 through 100 claims per batch. | | `leaseMs` | `60000` | Integer from 100 through 3,600,000 ms, at least twice `timeoutMs`. | | `maxResponseBytes` | `4096` | Integer from 0 through 65,536 captured response bytes. | ## publish [#publish] ```ts publish(input: PublishInput, options?: PublishOptions): Promise ``` ```ts import { delivery } from './delivery.js' const { eventId, deliveryCount, duplicate } = await delivery.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_123', amount: 4200 }, idempotencyKey: 'invoice.paid:inv_123', }) ``` | Input or result | Contract | | ---------------------- | ---------------------------------------------------------------------------------------------------------------- | | `input.type` | A configured event name. | | `input.data` | The event validator's input type. The validator runs before anything is stored. | | `idempotencyKey` | Nonblank string of at most 200 characters without null bytes. Unique within a scope while the event is retained. | | `options.transaction` | Optional PostgreSQL client inside an active `BEGIN` transaction. It must access the configured delivery tables. | | `result.eventId` | Accepted event UUID. | | `result.deliveryCount` | Number of original deliveries created for the accepted recipient set, including paused recipients. Can be zero. | | `result.duplicate` | `true` when the same key already identifies the same event type and validated payload. | The stored body is `{ id, type, occurredAt, data }`. `id` is the event UUID and `occurredAt` is an ISO timestamp assigned during publication. The event and its original deliveries commit atomically within delivery storage. A caller-owned transaction stays uncommitted when `publish` returns. The [transaction and recipient guarantees](https://good-webhooks.dev/docs/concepts/delivery-guarantees.md) also apply to duplicate publication. The [publish guide](https://good-webhooks.dev/docs/guides/publish.md) shows idempotency keys and transactions in context. | Error | When | | ---------------------- | ---------------------------------------------------------------------------------------------------------- | | `INVALID_INPUT` | Unknown event type, payload rejected by the validator, payload outside the JSON limits, or an invalid key. | | `IDEMPOTENCY_CONFLICT` | The key already identifies a different event type or payload in this scope. | | `TRANSACTION_REQUIRED` | `options.transaction` has no active transaction. | | Other errors | Validator-thrown errors, source failures, and database failures propagate unchanged. | **Payload normalization and limits** Payload normalization sorts object keys. It rejects nonfinite numbers, cycles, non-JSON values, nonplain objects, nesting beyond 64 levels, and JSON larger than 256 KiB. A publication validator can transform input, but its output must meet those JSON limits. **Publishing inside a caller-owned transaction** `publish` runs in a savepoint on the supplied client, so a failed publication rolls back only its own writes. Workers cannot see the event until you commit. Recipient matching is a separate read through `source`, so it is not part of your transaction. See [publish inside your transaction](https://good-webhooks.dev/docs/guides/publish.md#publish-inside-your-transaction). ## deliveries [#deliveries] ```ts deliveries.list(query?: DeliveryQuery): Promise> deliveries.get(id: string): Promise deliveries.replay(id: string): Promise ``` ```ts import { delivery } from './delivery.js' const page = await delivery.deliveries.list({ status: 'failed', limit: 25 }) const detail = await delivery.deliveries.get(page.items[0]!.id) if (detail.replayOf === null) await delivery.deliveries.replay(detail.id) ``` ### list [#list] | `DeliveryQuery` field | Contract | | --------------------- | ------------------------------------------------------------- | | `endpointId` | Only deliveries to this endpoint. | | `eventId` | Only deliveries of this event UUID. | | `status` | One delivery status. | | `limit` | Integer from 1 through 100. Default 25. | | `before` | Exclusive delivery-ID cursor. Pass the previous `nextCursor`. | `list` returns newest delivery IDs first. The result is `{ items, nextCursor }`; `nextCursor: null` marks the final page. Invalid query values throw `INVALID_INPUT`. ### get [#get] `get` returns the delivery with its `attempts` array in attempt-number order. Delivery IDs are positive PostgreSQL bigint strings. A missing record in the selected scope produces `NOT_FOUND`. ### replay [#replay] `replay` sends the original event to the same endpoint again as a new delivery. It keeps the event ID and body and gets a new delivery ID and retry budget. See [retries and replay](https://good-webhooks.dev/docs/operations/retries-and-replay.md). | Error | When | | -------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `NOT_FOUND` | No delivery with this ID in the selected scope. | | `INVALID_STATE` | The endpoint is not active, the delivery is a replay or not `succeeded` or `failed`, or the event is outside retention. | | `REPLAY_IN_PROGRESS` | The original delivery already has a pending or in-flight replay. | ### Delivery and attempt records [#delivery-and-attempt-records] | Delivery field | Type or meaning | | ----------------------------- | ------------------------------------------------------------------------ | | `id`, `endpointId`, `eventId` | String identifiers. | | `status` | `'pending'`, `'in_flight'`, `'succeeded'`, `'failed'`, or `'cancelled'`. | | `attemptCount` | Number of recorded attempts. | | `nextAttemptAt`, `createdAt` | `Date` values. | | `replayOf` | Original delivery ID, or `null`. | | `lastError` | Last error text, or `null`. | | `lastStatus` | Last HTTP status, or `null`. | | `attempts` | `Attempt[]`. Only on `DeliveryDetail`, the result of `get`. | | Attempt field | Type or meaning | | ---------------- | -------------------------------------------------------------------- | | `number` | Attempt number, starting at 1. | | `startedAt` | `Date`. | | `finishedAt` | `Date`, or `null` while the attempt runs. | | `outcome` | `'started'`, `'succeeded'`, `'retry'`, `'failed'`, or `'abandoned'`. | | `responseStatus` | HTTP status, or `null`. | | `responseBody` | Captured response text bounded by `maxResponseBytes`, or `null`. | | `error` | Error text, or `null`. | ## deliverySettings [#deliverysettings] ```ts deliverySettings.get(endpointId: string): Promise<{ maxInFlight: number }> deliverySettings.set(endpointId: string, settings: { maxInFlight: number }): Promise<{ maxInFlight: number }> ``` ```ts import { delivery } from './delivery.js' const endpointId = 'ENDPOINT_ID' // From endpoints.create() await delivery.deliverySettings.set(endpointId, { maxInFlight: 4 }) ``` | Setting | Default | Limits | | ------------- | ------- | -------------------------- | | `maxInFlight` | `2` | Integer from 1 through 50. | Stores sender-owned concurrency settings separately from endpoint records. Active and paused endpoints accept settings. Missing or deleted endpoints produce `NOT_FOUND`. An out-of-range value produces `INVALID_INPUT`. These settings apply to all workers sharing the same delivery database and schema. They are distinct from the `delivery.concurrency` limit on each batch. ## worker [#worker] ```ts worker.runOnce(options?: { signal?: AbortSignal shutdownGraceMs?: number }): Promise worker.run(options: { signal: AbortSignal onError?: (error: unknown) => void pollIntervalMs?: number shutdownGraceMs?: number }): Promise worker.prune(): Promise ``` ```ts title="worker.ts" import { delivery } from './delivery.js' const stop = new AbortController() process.once('SIGTERM', () => stop.abort()) await delivery.worker.run({ signal: stop.signal, onError: (error) => console.error('Webhook worker batch failed', error), }) ``` | Option | Applies to | Default | Contract | | ----------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------ | | `signal` | `run` (required), `runOnce` | None | Abort to stop. An already-aborted signal returns without database work. | | `onError` | `run` | None | Called with an internal batch failure. Without it, the failure stops `run`. | | `pollIntervalMs` | `run` | `1000` | Integer from 10 through 60,000 ms. The wait after an empty batch. | | `shutdownGraceMs` | `run`, `runOnce` | `30000` | Integer from 0 through 2,147,483,647 ms. Time for prepared attempts to finish after abort. | All worker methods cover every scope in the configured delivery database and schema. Workers own neither the host's process signal handlers nor its database pool. The [workers guide](https://good-webhooks.dev/docs/operations/workers.md) shows a supervised process. ### runOnce [#runonce] `runOnce` recovers expired leases and processes one bounded batch. It does not drain the queue. Its `WorkerResult` contains these counts: | Count | Meaning | | ----------- | ---------------------------------------------------------- | | `claimed` | Deliveries claimed in this batch. | | `succeeded` | Attempts that got a `2xx`. | | `retried` | Attempts that failed and were scheduled for a retry. | | `failed` | Attempts that failed permanently or used up their retries. | | `stale` | Attempts whose claim no longer owns completion. | ### run [#run] `run` processes batches until its signal aborts. After a batch completes at least one prepared attempt as a success, retry, or terminal failure, the next batch starts immediately. Empty batches and stale-only results wait for `pollIntervalMs`. Receiver retries keep their configured schedule. Recovery and reservation cleanup alone do not trigger an immediate batch. Without `onError`, an internal batch failure stops `run`. With `onError`, the loop awaits the callback, then waits for `pollIntervalMs` before continuing. A callback that throws or rejects stops the loop. ### Attempt outcomes [#attempt-outcomes] | Response | Attempt outcome | | ---------------------------------------------------------- | --------------------------------------------------- | | `2xx` | `succeeded` | | Network, DNS, or timeout error, `408`, `425`, `429`, `5xx` | `retry` while retries remain, otherwise `failed` | | Any other status, including `3xx` | `failed`. Redirects are never followed | | URL resolves to an unsafe address at send time | `failed`, no retry | | Worker stopped or lease expired mid-request | `abandoned`. The delivery retries if budget remains | Each retry waits its configured delay plus up to 20% random jitter. A retry that would land after `retry.maxAgeMs` is not scheduled. ### prune [#prune] `prune` deletes at most 100 expired events and their dependent deliveries and attempts per call. It returns the number of removed events. Live leases or contended locks can defer deletion. It does not run automatically. See [retention](https://good-webhooks.dev/docs/operations/retention.md). ### Worker errors [#worker-errors] | Error | When | | ---------------- | ----------------------------------------------------------------------- | | `INVALID_INPUT` | `pollIntervalMs` or `shutdownGraceMs` is out of range. | | `INVALID_STATE` | This instance already has an active `run` loop or batch. | | `AggregateError` | Internal failures in a batch. Thrown after all sibling attempts settle. | **Shutdown and the grace period** When the signal aborts, the worker stops taking new work and stops waiting for endpoint-provider results. Already prepared attempts can finish during the grace period. When the period expires, the worker requests cancellation of remaining sends. A value of `0` requests immediate cancellation. The grace period starts when the signal aborts. It does not extend delivery leases or bound database cleanup. Both methods await owned work and completion writes before returning. Underlying provider calls can continue after the worker stops waiting for them. The host controls database timeouts, pool closure, and the final process deadline. Forced cancellation can leave the receiver's outcome unknown and require another attempt. **Running several workers** One worker instance cannot run overlapping `runOnce` calls or mix `runOnce` with an active `run` loop, including during shutdown. Separate instances coordinate through PostgreSQL leases and share each endpoint's `maxInFlight` limit. ## check and forScope [#check-and-forscope] ```ts check(): Promise forScope(scope: Scope): Pick, 'publish' | 'deliveries' | 'deliverySettings'> ``` ```ts import { delivery } from './delivery.js' await delivery.check() const customer = delivery.forScope({ type: 'organization', id: 'org_123' }) await customer.deliveries.list() ``` `check()` verifies delivery schema version `4` and throws `SCHEMA_MISMATCH` otherwise, naming the SQL to apply. It never applies migrations and does not check management storage. `forScope(scope)` returns `publish`, `deliveries`, and `deliverySettings` bound to one named scope. An invalid scope throws `INVALID_INPUT`. The root methods use application scope. Scope selection does not authorize access. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md). # Errors > Expected operation errors and failures recorded in delivery history. Source: https://good-webhooks.dev/docs/reference/errors Every expected failure throws a `WebhookError` with a stable `code`. ```ts import { WebhookError } from 'good-webhooks' import type { WebhookErrorCode } from 'good-webhooks' ``` The receiver entry point `good-webhooks/verify` also exports the class and code type. ## WebhookError [#webhookerror] ```ts class WebhookError extends Error { readonly name: 'WebhookError' readonly code: WebhookErrorCode constructor(code: WebhookErrorCode, message: string, options?: ErrorOptions) } ``` ```ts import { WebhookError } from 'good-webhooks' import { webhooks } from './webhooks.js' try { await webhooks.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_123', amount: 4200 }, idempotencyKey: 'invoice.paid:inv_123', }) } catch (error) { if (error instanceof WebhookError && error.code === 'IDEMPOTENCY_CONFLICT') { console.error('Idempotency key reused for a different payload:', error.message) } throw error } ``` `WebhookError` extends `Error`. It has `name: 'WebhookError'`, a stable `code`, a descriptive `message`, and an optional `cause`. ## Codes [#codes] | Code | Meaning | Thrown by | | ---------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `INVALID_CONFIG` | Invalid constructor, encryption, migration, or receiver-validator configuration. | Constructors, `getPostgresMigration`, `parseWebhook`, secret decryption | | `INVALID_INPUT` | Invalid operation arguments or producer payload. | Most operations | | `UNSAFE_URL` | Destination fails URL or resolved-address validation. | Endpoint create, update, and resume | | `NOT_FOUND` | Record or owner is absent in the selected scope. | Endpoint and delivery lookups | | `INVALID_STATE` | Operation conflicts with lifecycle, limits, retention, or concurrent state. | Endpoint lifecycle, `replay`, worker overlap | | `IDEMPOTENCY_CONFLICT` | An accepted key identifies different event content. | `publish` | | `REPLAY_IN_PROGRESS` | An original delivery already has a pending or in-flight replay. | `replay` | | `SCHEMA_MISMATCH` | Expected storage is missing or incompatible. | `check()`, Better Auth record reads | | `SIGNATURE_INVALID` | Webhook headers or signature are invalid. | `verifyWebhook`, `parseWebhook` | | `SIGNATURE_EXPIRED` | Webhook timestamp falls outside the configured tolerance. | `verifyWebhook`, `parseWebhook` | | `PAYLOAD_INVALID` | An authenticated webhook body fails envelope or receiver-payload validation. | `parseWebhook` | | `TRANSACTION_REQUIRED` | A supplied publication client has no active PostgreSQL transaction. | `publish` with `options.transaction` | Each reference page lists the exact conditions for its operations. ## Failures outside WebhookError [#failures-outside-webhookerror] Database errors, provider lookup failures, and unexpected validator failures can propagate as other error types. Code that handles `WebhookError` still needs a path for unexpected failures. An HTTP recipient failure normally becomes a delivery and attempt record. It does not reject `publish`, because publication queues work without sending it. Worker internal failures can reject `runOnce` with an `AggregateError`. See [retries and replay](https://good-webhooks.dev/docs/operations/retries-and-replay.md). The Better Auth plugin [maps expected management errors to HTTP responses](https://good-webhooks.dev/docs/reference/better-auth.md#route-errors). That mapping does not change the direct provider API. # API reference > Every public entry point, what it exports, and where its contract is documented. Source: https://good-webhooks.dev/docs/reference The package has eight code entry points and three SQL assets; this section holds the exact contract for each. ```ts import { createWebhooks, generateEncryptionKey, WebhookError } from 'good-webhooks' import { createDelivery } from 'good-webhooks/delivery' import { createManagement } from 'good-webhooks/management' import { createPostgresManagement, createPostgresManagementRepository, } from 'good-webhooks/management/postgres' import { goodWebhooks, createBetterAuthManagement } from 'good-webhooks/better-auth' import { goodWebhooksClient } from 'good-webhooks/better-auth/client' import { parseWebhook, verifyWebhook } from 'good-webhooks/verify' import { getPostgresMigration } from 'good-webhooks/migrations' ``` Better Auth is an optional peer; only the two `better-auth` entry points import it. - [good-webhooks](https://good-webhooks.dev/docs/reference/core.md): createWebhooks, generateEncryptionKey, WebhookError, and the shared types. - [good-webhooks/delivery](https://good-webhooks.dev/docs/reference/delivery.md): createDelivery, publication, history, replay, settings, and the worker. - [good-webhooks/management](https://good-webhooks.dev/docs/reference/management.md): The EndpointManagement, EndpointSource, repository, and cipher contracts. - [good-webhooks/management/postgres](https://good-webhooks.dev/docs/reference/postgres.md): createPostgresManagement and its storage encryption options. - [good-webhooks/better-auth](https://good-webhooks.dev/docs/reference/better-auth.md): The server plugin, its routes, scope authorization, and the trusted provider. - [good-webhooks/better-auth/client](https://good-webhooks.dev/docs/reference/better-auth-client.md): The client plugin and its typed methods. - [good-webhooks/verify](https://good-webhooks.dev/docs/reference/verify.md): parseWebhook and verifyWebhook for receivers. - [good-webhooks/migrations](https://good-webhooks.dev/docs/reference/migrations.md): getPostgresMigration and the shipped SQL files. - [Errors](https://good-webhooks.dev/docs/reference/errors.md): Every WebhookError code and what it means. ## Which process imports what [#which-process-imports-what] | Process | Imports | | ---------------------------- | -------------------------------------------------------------------------------- | | Producer and worker | `good-webhooks`, or `good-webhooks/delivery` with a separate management provider | | Endpoint management only | `good-webhooks/management/postgres` or `good-webhooks/better-auth` | | Browser (Better Auth client) | `good-webhooks/better-auth/client` | | Receiver | `good-webhooks/verify`. It loads no database or worker code | | Migration tooling | `good-webhooks/migrations` or the `.sql` assets | ## Conventions [#conventions] * Constructors validate options and return. They never open connections, apply migrations, or start workers. * Expected failures throw [`WebhookError`](https://good-webhooks.dev/docs/reference/errors.md) with a stable `code`. Database and provider failures can propagate as other error types. * Trusted provider results use `Date` values. Better Auth route responses use ISO strings. * Scope arguments are `null` for application scope or `{ type, id }` for a named scope. Selecting a scope never authorizes the caller. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md). ## SQL assets [#sql-assets] | Export | Creates | | ------------------------------------------ | ------------------------------------------ | | `good-webhooks/migrations/001-initial.sql` | Management and delivery tables in `public` | | `good-webhooks/migrations/management.sql` | `webhook_endpoints` only | | `good-webhooks/migrations/delivery.sql` | Delivery tables only | Generate the same SQL for another schema with [`getPostgresMigration()`](https://good-webhooks.dev/docs/reference/migrations.md). # Management contracts > Shared endpoint lifecycle, sender reads, and custom storage interfaces. Source: https://good-webhooks.dev/docs/reference/management `good-webhooks/management` defines the endpoint management contract that every provider implements, and `createManagement` builds one over your own storage. ```ts import { createManagement } from 'good-webhooks/management' import type { EndpointManagement, EndpointSource, EndpointResolution, ManagementScope, ManagementOptions, ManagementRepository, SecretCipher, StoredEndpoint, StoredEndpointPatch, ManagedEndpoint, CreateManagedEndpoint, UpdateManagedEndpoint, ManagedEndpointWithSecret, } from 'good-webhooks/management' ``` Most applications use a ready provider: [PostgreSQL](https://good-webhooks.dev/docs/reference/postgres.md) or [Better Auth](https://good-webhooks.dev/docs/reference/better-auth.md). The [endpoints guide](https://good-webhooks.dev/docs/guides/endpoints.md) covers the lifecycle; [custom senders](https://good-webhooks.dev/docs/guides/custom-senders.md) covers building on these contracts. ## EndpointManagement [#endpointmanagement] ```ts interface EndpointManagement { readonly source: EndpointSource create(scope: ManagementScope, input: CreateManagedEndpoint): Promise list(scope: ManagementScope): Promise get(scope: ManagementScope, id: string): Promise update(scope: ManagementScope, id: string, patch: UpdateManagedEndpoint): Promise pause(scope: ManagementScope, id: string): Promise resume(scope: ManagementScope, id: string): Promise remove(scope: ManagementScope, id: string): Promise rotateSecret( scope: ManagementScope, id: string, options?: { graceMs?: number }, ): Promise removeScope(scope: ManagementScope): Promise reencrypt(scope: ManagementScope): Promise } ``` ```ts import { createPostgresManagement } from 'good-webhooks/management/postgres' import { Pool } from 'pg' const management = createPostgresManagement({ database: new Pool({ connectionString: process.env.DATABASE_URL }), eventTypes: ['invoice.paid'], encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!, }) const scope = { type: 'organization', id: 'org_123' } const { endpoint, secret } = await management.create(scope, { url: 'https://receiver.example/webhooks', eventTypes: ['invoice.paid'], }) await management.pause(scope, endpoint.id) ``` All direct calls are trusted server operations. They do not authorize the caller. The first argument is `ManagementScope`: `null` for application scope or `{ type, id }` for a named scope. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md). | Method | Result and behavior | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `create(scope, input)` | `Promise`. Creates an active endpoint and returns `{ endpoint, secret }`. | | `list(scope)` | `Promise`. Returns every nondeleted endpoint in the scope. | | `get(scope, id)` | `Promise`. Returns an endpoint, including its deleted state. | | `update(scope, id, patch)` | `Promise`. Changes the supplied mutable fields. | | `pause(scope, id)` | `Promise`. Changes the status to `'paused'`. | | `resume(scope, id)` | `Promise`. Validates the current URL and changes status to `'active'`. | | `remove(scope, id)` | `Promise`. Sets status to `'deleted'` and clears signing secrets. Removing an already deleted record is idempotent. | | `rotateSecret(scope, id, options?)` | `Promise`. Changes the signing secret. `options.graceMs` defaults to 86,400,000 and accepts 0 through 86,400,000. | | `removeScope(scope)` | `Promise`. Deletes the scope's currently registered endpoints. | | `reencrypt(scope)` | `Promise`. Rewrites live signing-secret ciphertext using the current storage encryption key and returns the count. | | `source` | Trusted sender reads. See [EndpointSource](#endpointsource). | ### Inputs and limits [#inputs-and-limits] `CreateManagedEndpoint` has `url`, optional `description`, and `eventTypes`. `UpdateManagedEndpoint` makes those fields optional and accepts `description: null` to clear the description. | Value | Limit | | ------------ | -------------------------------------------------------------------------------------------------- | | Scope `type` | 1 through 64 characters, nonblank, no null bytes. | | Scope `id` | 1 through 200 characters, nonblank, no null bytes. | | Endpoint ID | 1 through 255 characters, nonblank, no null bytes. | | URL | At most 2,048 characters. Must pass the configured destination validation, otherwise `UNSAFE_URL`. | | Description | At most 2,000 characters, no null bytes. | | `eventTypes` | 1 through 100 configured event names. Duplicates are collapsed. | | Endpoints | At most 1,000 nondeleted endpoints per scope. | | `graceMs` | Integer from 0 through 86,400,000. Default 86,400,000. | ### Results [#results] `ManagedEndpoint` is the shared `Endpoint` shape: `id`, `url`, nullable `description`, `eventTypes`, `status`, `createdAt`, and `updatedAt`. Timestamps are `Date` values. Ordinary results contain neither plaintext signing secrets nor ciphertext. Only `create` and `rotateSecret` return the new `secret`. ### Errors [#errors] | Error | When | | --------------- | ------------------------------------------------------------------------------------------------------------- | | `INVALID_INPUT` | An argument breaks a limit above, or names an event outside the configured list. | | `UNSAFE_URL` | The URL fails destination validation. | | `NOT_FOUND` | No endpoint with this ID in the scope, or the scope's owner no longer exists. | | `INVALID_STATE` | The endpoint is deleted, the scope is full, a signing-secret overlap is active, or the record keeps changing. | **Lifecycle rules for deleted endpoints and rotation** Deleted endpoints cannot be updated, paused, resumed, or rotated. A second rotation during an active overlap fails unless `graceMs: 0` explicitly replaces all current signing material. Re-encryption preserves signing values and includes paused endpoints. Conflicting updates retry with revision checks and eventually produce `INVALID_STATE` if the record keeps changing. See [key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md) for both rotation procedures. ## EndpointSource [#endpointsource] ```ts interface EndpointSource { matchRecipients(scope: ManagementScope, eventType: string): Promise resolveEndpoint(scope: ManagementScope, id: string): Promise } type EndpointResolution = { status: 'active'; url: string; secrets: readonly string[] } | { status: 'paused' | 'deleted' } ``` ```ts import { createDelivery } from 'good-webhooks/delivery' import type { EndpointSource } from 'good-webhooks/management' import { Pool } from 'pg' import { z } from 'zod' // A fixed, single-endpoint source. Real sources read your endpoint storage. const source: EndpointSource = { async matchRecipients() { return ['endpoint-1'] }, async resolveEndpoint() { return { status: 'active', url: 'https://receiver.example/webhooks', secrets: [process.env.WEBHOOK_SECRET!], } }, } const delivery = createDelivery({ database: new Pool({ connectionString: process.env.DATABASE_URL }), events: { 'invoice.paid': z.object({ invoiceId: z.string() }) }, source, }) ``` The sender calls `matchRecipients` once at publication and `resolveEndpoint` before every attempt. The source is not an API for untrusted callers. | Method | Contract | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `matchRecipients` | Returns the complete set of matching endpoint IDs, including paused endpoints. Lookup failures reject. A source must not convert an outage into an empty result. | | `resolveEndpoint` | Returns current state. Active results contain the current URL and usable Standard Webhooks signing secrets. Deleted or missing records return `'deleted'`. | The built-in source returns the new signing secret first and the previous secret only while the overlap remains active. It rejects an unknown event name with `INVALID_INPUT`. Lookup and decryption failures reject. **Consistency and missing owners** Concurrent edits can produce a `matchRecipients` result that reflects different moments; the contract does not promise a scope-wide snapshot. A missing Better Auth user or organization also makes its endpoints ineligible: matching returns no recipients and resolution returns `'deleted'`. Custom senders also own [recipient persistence and safe transport](https://good-webhooks.dev/docs/guides/custom-senders.md). ## createManagement [#createmanagement] ```ts createManagement(options: ManagementOptions): EndpointManagement ``` ```ts import { createManagement } from 'good-webhooks/management' import { createPostgresManagementRepository } from 'good-webhooks/management/postgres' import { Pool } from 'pg' import { cipher } from './cipher.js' // Your SecretCipher, for example backed by a KMS. export const management = createManagement({ repository: createPostgresManagementRepository( new Pool({ connectionString: process.env.DATABASE_URL }), ), cipher, eventTypes: ['invoice.paid', 'invoice.voided'], }) ``` Creates the shared endpoint lifecycle over custom persistence and encryption. | Option | Contract | | -------------------- | -------------------------------------------------------------------------------------------------------------------- | | `repository` | Required `ManagementRepository`. | | `cipher` | Required `SecretCipher`. | | `eventTypes` | Required nonempty array of names matching `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$`. No payload validators are required. | | `allowLocalhost` | Optional boolean, default `false`. Allows development HTTP loopback URLs. | | `validateUrl(url)` | Optional async host check after portable URL validation. | | `scopeExists(scope)` | Optional async ownership check. Returns `false` for a missing owner and rejects on lookup failure. | Invalid options throw `INVALID_CONFIG`. Portable URL validation requires HTTPS and rejects credentials, fragments, and private or reserved IP literals. Loopback hosts, including `localhost`, need `allowLocalhost`, which also permits HTTP for them. **How scopeExists changes behavior** With `scopeExists`, ordinary reads and writes reject missing owners with `NOT_FOUND`. `remove` and `removeScope` skip that check so an owner can still be cleaned up after deletion. Sender matching returns no recipients for a missing owner, and resolution returns `'deleted'`. Creation also checks ownership after the insert so a concurrent owner deletion cannot leave a live orphan. ## ManagementRepository [#managementrepository] ```ts interface ManagementRepository { list(scope: ManagementScope): Promise get(scope: ManagementScope, id: string): Promise create(scope: ManagementScope, endpoint: Omit): Promise update( scope: ManagementScope, id: string, revision: number, patch: StoredEndpointPatch, ): Promise } ``` | Method | Contract | | -------- | --------------------------------------------------------------------------------------------------------------------------------- | | `list` | Returns every live record in the scope without truncation. | | `get` | Returns the record, including a deleted one, or `null`. | | `create` | Atomically inserts the record and enforces the strict 1,000-record scope limit. | | `update` | Atomically guards on scope, ID, and revision, increments the revision, and applies the patch. A revision conflict returns `null`. | Repository errors propagate. [`createPostgresManagementRepository`](https://good-webhooks.dev/docs/reference/postgres.md#createpostgresmanagementrepository) is the built-in implementation. `StoredEndpoint` adds `revision`, `encryptedSecret`, nullable `previousEncryptedSecret`, and nullable `previousSecretExpiresAt` to the endpoint shape. `StoredEndpointPatch` is a partial stored record without `id`, `createdAt`, or `revision`. Stored records must never be sent to clients. ## SecretCipher [#secretcipher] ```ts interface SecretCipher { encrypt(value: string): Promise decrypt(value: string): Promise } ``` The cipher encrypts receiver-facing signing secrets at rest. A cipher that supports storage encryption key rotation writes with the current key and reads the retained keys until re-encryption finishes. # PostgreSQL migrations > Fresh-install and upgrade SQL, component selection, custom schemas, and exported SQL assets. Source: https://good-webhooks.dev/docs/reference/migrations `good-webhooks/migrations` generates the fresh-install and upgrade SQL for management and delivery tables; your migration tool applies it. ```ts import { getPostgresMigration, getPostgresUpgrade, POSTGRES_SCHEMA_VERSION, } from 'good-webhooks/migrations' import type { PostgresMigrationOptions, PostgresUpgradeOptions } from 'good-webhooks/migrations' ``` The [migrations guide](https://good-webhooks.dev/docs/operations/migrations.md) covers applying, upgrading, and splitting storage. ## getPostgresMigration [#getpostgresmigration] ```ts getPostgresMigration(options?: PostgresMigrationOptions): string ``` ```ts title="scripts/write-migration.ts" import { writeFile } from 'node:fs/promises' import { getPostgresMigration } from 'good-webhooks/migrations' await writeFile( 'migrations/001-webhooks.sql', getPostgresMigration({ schema: 'webhooks', component: 'all' }), ) ``` Returns fresh-install SQL. It never connects to a database or applies DDL. The host applies the SQL once in a transaction and records it in its migration ledger. | Field | Type | Default | Description | | --- | --- | --- | --- | | `schema?` | `string` | | PostgreSQL namespace, shared by both components unless generated separately. Defaults to public. | | `component?` | `"all" \| "delivery" \| "management"` | | Generate only the component you use, or both for standalone setup. Defaults to all. | | Option | Default | Meaning | | ----------- | ---------- | ---------------------------------------------- | | `schema` | `'public'` | Namespace for all tables emitted by this call. | | `component` | `'all'` | `'management'`, `'delivery'`, or `'all'`. | Every generated script includes `CREATE SCHEMA IF NOT EXISTS` for its target namespace before its tables. Table creation is not idempotent. Reapplying initial SQL is not an upgrade procedure. Invalid names or components produce `INVALID_CONFIG`. ## Components and SQL assets [#components-and-sql-assets] | Component | Created tables | Equivalent shipped asset for `public` | | ------------ | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | `management` | `webhook_schema_version` (if absent), `webhook_endpoints` | `good-webhooks/migrations/management.sql` | | `delivery` | `webhook_schema_version` (if absent), `webhook_endpoint_state`, `webhook_events`, `webhook_deliveries`, `webhook_attempts` | `good-webhooks/migrations/delivery.sql` | | `all` | Both groups | `good-webhooks/migrations/001-initial.sql` | Each component records its version in `webhook_schema_version`; components that share a namespace share the table. The `.sql` files are public package exports. They are text assets, not executable JavaScript imports. Resolve them with `import.meta.resolve` or `require.resolve` and read them with your migration tool: ```ts import { readFile } from 'node:fs/promises' const sql = await readFile( new URL(import.meta.resolve('good-webhooks/migrations/delivery.sql')), 'utf8', ) ``` The current schema version is `4`, exported as `POSTGRES_SCHEMA_VERSION`. Every `check()` expects exactly that version for its component. Fresh-install assets never upgrade existing data; use `getPostgresUpgrade` for that. ## getPostgresUpgrade [#getpostgresupgrade] ```ts getPostgresUpgrade(options: PostgresUpgradeOptions): string ``` ```ts title="scripts/write-upgrade.ts" import { writeFile } from 'node:fs/promises' import { getPostgresUpgrade } from 'good-webhooks/migrations' await writeFile( 'migrations/002-webhooks-schema-4.sql', getPostgresUpgrade({ schema: 'webhooks', component: 'all', from: 3 }), ) ``` Returns SQL that moves storage recorded at version `from` to the current version. It never connects to a database. The SQL first checks the recorded version and raises inside your transaction if it differs, so a wrong or repeated application rolls back cleanly. | Field | Type | Default | Description | | --- | --- | --- | --- | | `schema?` | `string` | | PostgreSQL namespace the installation uses. Defaults to public. | | `component?` | `"all" \| "delivery" \| "management"` | | Upgrade only the component in this namespace, or both for a shared namespace. Defaults to all. | | `from` | `number` | | The schema version currently recorded in the database. Only 3 can be upgraded. | | Option | Default | Meaning | | ----------- | ---------- | --------------------------------------------------------------------- | | `schema` | `'public'` | Namespace of the installation being upgraded. | | `component` | `'all'` | `'management'`, `'delivery'`, or `'all'` for a shared namespace. | | `from` | required | The version recorded in the database. `3` is the only upgradable one. | `from: 4` throws `INVALID_CONFIG` because no upgrade is needed. Versions before `3` were never published and need fresh storage. | Upgrade | Shipped asset for `public` | What it changes | | ------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------- | | 3 to 4 | `good-webhooks/migrations/upgrades/004-all.sql` | Records a version per component. Delivery rewrites its version row; management gains one. | | | `good-webhooks/migrations/upgrades/004-management.sql` | Management in its own namespace: creates the version table and records `management`. | | | `good-webhooks/migrations/upgrades/004-delivery.sql` | Delivery in its own namespace: rewrites the version row. | In a shared namespace apply `all`, or `delivery` before `management`; the management step needs the rewritten table and fails otherwise. No data tables change in this upgrade, so processes on the previous package keep working until they restart. The [migrations guide](https://good-webhooks.dev/docs/operations/migrations.md#upgrades) gives the rollout order. ## Schema names [#schema-names] A schema must be a nonblank, well-formed string of at most 63 UTF-8 bytes without null bytes. The generator and runtime quote names as PostgreSQL identifiers. Invalid names produce `INVALID_CONFIG`. The `schema` passed to a runtime must match the corresponding migration. Management and delivery can use separate namespaces by generating each component separately. An injected management provider keeps its own configured namespace regardless of delivery options. Better Auth management follows its adapter's schema workflow. These migrations cover the standalone PostgreSQL provider and delivery only. `goodWebhooks({ schema: { modelName, fields } })` changes model and field names, not PostgreSQL placement. # PostgreSQL management > Standalone management storage and encryption options. Source: https://good-webhooks.dev/docs/reference/postgres `good-webhooks/management/postgres` stores endpoints in one PostgreSQL table, with signing secrets encrypted by your storage encryption key. ```ts import { createPostgresManagement, createPostgresManagementRepository, } from 'good-webhooks/management/postgres' import type { PostgresManagement, PostgresManagementOptions, } from 'good-webhooks/management/postgres' ``` [`createWebhooks()`](https://good-webhooks.dev/docs/reference/core.md) builds this provider for you. Use it directly to run management without delivery, or to pass old keys during [key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md). ## createPostgresManagement [#createpostgresmanagement] ```ts createPostgresManagement(options: PostgresManagementOptions): PostgresManagement ``` ```ts title="management.ts" import { createPostgresManagement } from 'good-webhooks/management/postgres' import { Pool } from 'pg' export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) export const management = createPostgresManagement({ database: pool, eventTypes: ['invoice.paid'], encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!, }) ``` Returns the complete [EndpointManagement interface](https://good-webhooks.dev/docs/reference/management.md#endpointmanagement) plus `check()`. It uses only the configured schema's `webhook_endpoints` table. It creates no delivery records and starts no sender. | Option | Default | Contract | | ---------------- | ---------- | ----------------------------------------------------------------------------------------- | | `database` | Required | PostgreSQL `Database` with `query` and `connect`. `pg.Pool` satisfies this shape. | | `eventTypes` | Required | Nonempty array of configured event names. Management does not validate event payloads. | | `encryptionKey` | Required | Base64-encoded 32 bytes or a 32-byte `Uint8Array`. Encrypts newly stored signing secrets. | | `decryptionKeys` | `[]` | Older keys accepted when reading existing ciphertext. | | `schema` | `'public'` | PostgreSQL schema containing `webhook_endpoints`. | | `allowLocalhost` | `false` | Permits HTTP loopback destinations for development. | Invalid options, including a malformed key, throw `INVALID_CONFIG`. The host owns the pool and closes it on shutdown. ### Destination checks [#destination-checks] The provider resolves hostnames during endpoint creation, URL updates, and resume. It rejects destinations that resolve to unsafe addresses with `UNSAFE_URL`. A hostname that fails to resolve, or takes longer than 10 seconds, also produces `UNSAFE_URL`. This admission check does not replace the sender's checks before each connection. See [security](https://good-webhooks.dev/docs/concepts/security.md). ### Storage encryption [#storage-encryption] Signing secrets use AES-256-GCM storage encryption. Decryption tries the current key followed by the retained `decryptionKeys`. If no key decrypts a stored secret, the read rejects with `INVALID_CONFIG`. `reencrypt(scope)` writes live records with the current key while preserving receiver-facing signing values. The host must keep old keys until every affected scope has been rewritten. See [key rotation](https://good-webhooks.dev/docs/operations/key-rotation.md). ```ts import { createPostgresManagement } from 'good-webhooks/management/postgres' import { pool } from './management.js' const management = createPostgresManagement({ database: pool, eventTypes: ['invoice.paid'], encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!, decryptionKeys: [process.env.WEBHOOK_ENCRYPTION_KEY_OLD!], }) const rewritten = await management.reencrypt({ type: 'organization', id: 'org_123' }) ``` ### check [#check] ```ts check(): Promise ``` ```ts import { management } from './management.js' await management.check() ``` Reads the expected table columns without returning rows. Missing schema, table, or columns produce `SCHEMA_MISMATCH`. Other database failures propagate. This method neither executes migrations nor validates an injected delivery engine. `good-webhooks/migrations/management.sql` initializes this provider in `public`. [Generated migration SQL](https://good-webhooks.dev/docs/reference/migrations.md) can target a custom schema. ## createPostgresManagementRepository [#createpostgresmanagementrepository] ```ts createPostgresManagementRepository( database: Database, options?: { schema?: string }, ): ManagementRepository ``` ```ts import { createManagement } from 'good-webhooks/management' import { createPostgresManagementRepository } from 'good-webhooks/management/postgres' import { pool } from './management.js' import { cipher } from './cipher.js' // Your SecretCipher. const management = createManagement({ repository: createPostgresManagementRepository(pool, { schema: 'webhooks' }), cipher, eventTypes: ['invoice.paid'], }) ``` Creates the storage implementation without the lifecycle API or cipher. This is an extension point for [`createManagement`](https://good-webhooks.dev/docs/reference/management.md#createmanagement); most standalone applications use `createPostgresManagement`. | Option | Default | Contract | | -------- | ---------- | ----------------------------------------------------------------------------------- | | `schema` | `'public'` | Follows the [PostgreSQL identifier rules](https://good-webhooks.dev/docs/reference/migrations.md#schema-names). | Invalid database or schema configuration produces `INVALID_CONFIG`. The repository scopes every query and uses revision-guarded updates. Creation uses a transaction and an advisory lock to enforce the 1,000 nondeleted endpoint limit; a full scope produces `INVALID_STATE`. Enumeration rejects with `INVALID_STATE` if storage exceeds that limit rather than returning a partial recipient set. # Receiver verification > Authenticate raw request bytes and decode a typed webhook event. Source: https://good-webhooks.dev/docs/reference/verify `good-webhooks/verify` authenticates a webhook request from its raw bytes and decodes it into a typed event. ```ts import { verifyWebhook, parseWebhook, WebhookError } from 'good-webhooks/verify' import type { ParsedWebhook, WebhookErrorCode } from 'good-webhooks/verify' ``` This entry point does not load database or worker code. It remains a Node.js API. The [receiving guide](https://good-webhooks.dev/docs/guides/receive.md) shows complete handlers for Next.js, Hono, Express, and `node:http`. ## parseWebhook [#parsewebhook] ```ts parseWebhook( input: Parameters[0] & { events: E }, ): Promise> ``` ```ts import { parseWebhook } from 'good-webhooks/verify' import { z } from 'zod' const events = { 'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }), } export async function POST(request: Request) { const event = await parseWebhook({ body: new Uint8Array(await request.arrayBuffer()), headers: request.headers, secret: process.env.WEBHOOK_SECRET!, events, }) if (event.type === 'invoice.paid') console.log(event.data.amount) return new Response(null, { status: 204 }) } ``` Verifies the signature, validates the Good Webhooks JSON envelope, and decodes its payload with the selected Standard Schema validator. It accepts every [`verifyWebhook` input](#verifywebhook) plus `events`, which maps accepted event names to receiver validators. `ParsedWebhook` is a discriminated union by event `type`. Each member contains `id: string`, its event-name literal, `occurredAt: string`, and `data` with that validator's output type. | Rule | Contract | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | Body size | At most 512 KiB, checked before verification. Larger bodies produce `PAYLOAD_INVALID`. | | Envelope | A JSON object with `id`, `type`, `occurredAt`, and `data`. | | `id` | Must match the authenticated `webhook-id` header. | | `type` | Must be a key of `events`. Unknown event types produce `PAYLOAD_INVALID`. | | `occurredAt` | A canonical ISO timestamp. | | `data` | Must fit the sender's JSON limits: 256 KiB, maximum depth 64, finite numbers, and plain JSON values. Then the validator must accept it. | Receiver validators accept transmitted JSON and can decode it into local values such as `Date`. Expected error messages do not expose payload values. **When a validator throws** A validator that throws, or returns a result that is not a Standard Schema result, makes `parseWebhook` reject with an ordinary `Error` whose `cause` is the original failure. This is a receiver bug, not a bad request: respond `500`. A `WebhookError` thrown by your validator is wrapped the same way. ## verifyWebhook [#verifywebhook] ```ts verifyWebhook(input: { body: string | Uint8Array headers: Headers | Record secret: string | readonly string[] now?: Date toleranceSeconds?: number }): void ``` ```ts import { verifyWebhook } from 'good-webhooks/verify' export async function POST(request: Request) { const body = await request.text() verifyWebhook({ body, headers: request.headers, secret: process.env.WEBHOOK_SECRET! }) const payload: unknown = JSON.parse(body) // Parse only after verification. return new Response(null, { status: 204 }) } ``` Verifies [Standard Webhooks](https://www.standardwebhooks.com) HMAC-SHA256 signatures against the exact body bytes and checks timestamp tolerance. Successful verification returns `undefined`. It does not parse JSON or record the event ID. The input table below is generated from the public function signature. `VerifyOptions` is a documentation alias, not a named package export. | Field | Type | Default | Description | | --- | --- | --- | --- | | `body` | `string \| Uint8Array` | | | | `headers` | `Headers \| Record` | | | | `secret` | `string \| readonly string[]` | | | | `now?` | `Date` | | | | `toleranceSeconds?` | `number` | | | | Input | Contract | | ------------------ | ------------------------------------------------------------------------------------------------------------------- | | `body` | Required raw string or `Uint8Array`, before JSON parsing or reserialization. | | `headers` | Required `Headers` or record of string, string-array, or undefined values. Relevant values must each be one string. | | `secret` | One signing secret or an array of 1 through 16 secrets. | | `now` | Optional valid `Date`, defaulting to the current time. | | `toleranceSeconds` | Optional nonnegative safe integer, default `300`. Applies to timestamps in the past and future. | | Header | Rule | | ------------------- | ---------------------------------------------------------------- | | `webhook-id` | 1 through 255 visible ASCII characters without dots or commas. | | `webhook-timestamp` | Nonnegative integer Unix seconds. | | `webhook-signature` | At most 4,096 characters. Space-separated `v1,` entries. | Header names are case-insensitive. Duplicate relevant names or array-valued relevant headers are rejected. Signing secrets have a `whsec_` prefix and canonical base64 encoding of 24 through 64 bytes. Good Webhooks generates 32-byte secrets. Verification succeeds when a `v1` signature matches any supplied secret, so pass `[newSecret, oldSecret]` during [secret rotation](https://good-webhooks.dev/docs/operations/key-rotation.md). > **Warning: Limits you own.** `verifyWebhook` has no request-body size limit. Limit uploads before buffering. Receiver > deduplication of `webhook-id` is also your responsibility. See [process each event > once](https://good-webhooks.dev/docs/guides/receive.md#process-each-event-once). ## Failures [#failures] Both functions throw `WebhookError` for expected failures. The [receiving guide](https://good-webhooks.dev/docs/guides/receive.md) maps each code to an HTTP response. | Failure | Result | | ----------------------------------------------------------------------- | ---------------------------------------------------------------- | | Invalid body type, signing secret, clock, or tolerance | `INVALID_INPUT` | | Missing or malformed signature headers, or signature mismatch | `SIGNATURE_INVALID` | | Timestamp outside tolerance | `SIGNATURE_EXPIRED` | | Body over 512 KiB | `PAYLOAD_INVALID` from `parseWebhook` | | Invalid UTF-8, JSON, envelope, unknown event, or rejected event payload | `PAYLOAD_INVALID` from `parseWebhook` | | Missing event map or invalid selected validator | `INVALID_CONFIG` from `parseWebhook` | | Validator throws or returns an invalid result | Ordinary `Error` with a cause, not an expected request rejection |