Good Webhooks

Publish events

Define a typed event catalog, publish with idempotency keys, reach the right customer, and commit events with your own database writes.

publish() records an event and one delivery per subscribed endpoint. It sends nothing itself; a worker does. The examples use the webhooks client from installation.

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

The event map is your public webhook catalog: names and payload schemas. Any Standard Schema validator works. Keep it in its own module so receivers can import it too.

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:

await webhooks.publish({ type: 'invoice.paid', data: { invoiceId: 'inv_1', amount: '42' } })
//                                                                             ^ type error

Payloads are also validated at runtime before anything is stored.

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:

const result = await webhooks.publish({
  type: 'invoice.paid',
  data: { invoiceId: 'inv_123', amount: 4200 },
  idempotencyKey: 'invoice.paid:inv_123', 
})

result.duplicate // true when this key was already accepted
You publish again withResult
Same key, same type and payloadThe original event, duplicate: true. Nothing new sent
Same key, different type or payloadThrows IDEMPOTENCY_CONFLICT
A new keyA new event

Keys are unique per scope and are released when the event is pruned (seven days by default).

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:

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.

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. your appBEGIN

    Open a transaction on one client.

  2. Your writeUPDATE invoices SET paid = true …
  3. publish()Same client, passed as {transaction}.
  4. COMMIT or ROLLBACK

    Commit: event and data exist. Rollback: neither does.

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 }, 
  )
  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.

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:

await customer.deliverySettings.set(endpointId, { maxInFlight: 4 })

See deliverySettings.

Next steps

On this page