Good Webhooks

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:

  1. Better AuthEndpoints

    Users manage endpoints through the plugin, in your auth database.

  2. your appPublish

    delivery.publish() asks the source who subscribes and stores deliveries in PostgreSQL.

  3. 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 zod

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.

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

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) 
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:

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:

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

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

On this page