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.
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 errorPayloads 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 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 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:
- your appBEGIN
Open a transaction on one client.
- Your write
UPDATE invoices SET paid = true … - publish()Same client, passed as
{transaction}. - 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.