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.
Keep Better Auth for endpoint management and add Good Webhooks delivery on PostgreSQL. The two connect through management.source:
- Better AuthEndpoints
Users manage endpoints through the plugin, in your auth database.
- your appPublish
delivery.publish()asks the source who subscribes and stores deliveries in PostgreSQL. - worker processDeliver
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 first.
Install delivery dependencies
npm install pg zodCreate 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.
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.sqlBetter Auth keeps owning the management model's migrations. The delivery SQL does not touch it.
Construct the delivery engine
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)
export const delivery = createDelivery({
database: deliveryDatabase,
source: management.source,
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
A personal endpoint belongs to { type: 'user', id }. Publishing on the root instance uses application scope and will not reach it.
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.
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:
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:
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()
},
})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.