Good Webhooks

Run workers

Start delivery explicitly, supervise it, and shut it down without losing in-flight work.

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

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 first. run() also takes pollIntervalMs, shutdownGraceMs, and onError; see the worker reference.

  1. Claim

    Lease due deliveries, respecting each endpoint's in-flight limit.

  2. ResolveRead each endpoint's current URL, status, and secrets.
  3. SendSign and POST. No database transaction is held open meanwhile.
  4. RecordStore the attempt; mark succeeded, failed, or due for retry.
What run() does in a loop

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

runOnce() processes one bounded batch and returns counts. Use it from a scheduler or in tests:

const result = await webhooks.worker.runOnce()
// { claimed, succeeded, retried, failed, stale }

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.

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:

VariableRequiredPurpose
DATABASE_URLyesPostgreSQL connection string
WEBHOOK_ENCRYPTION_KEYyesThe key that encrypted the registered endpoints
WEBHOOK_SCHEMAMust match the migrations and the producer. Default public
WEBHOOK_POLL_INTERVAL_MSWait after an empty batch. Default 1000
WEBHOOK_DB_CONNECT_TIMEOUT_MSDefault 5000
WEBHOOK_DB_STATEMENT_TIMEOUT_MSDefault 10000. The client query timeout is one second longer
WEBHOOK_ALLOW_LOCALHOSTtrue 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.

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.

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 lists what each kind of failure does to in-flight work.

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. CancelRemaining requests are cancelled and their outcome recorded.
  4. Returnrun() 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.

Bound database waits

The example configuration sets connection and statement timeouts, an idle-in-transaction timeout, and ten pool connections per process:

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 separately.

On this page