# Run workers

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

Source: https://good-webhooks.dev/docs/operations/workers



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 [#the-smallest-worker]

```ts title="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](https://good-webhooks.dev/docs/operations/migrations.md) first. `run()` also takes `pollIntervalMs`, `shutdownGraceMs`, and `onError`; see the [worker reference](https://good-webhooks.dev/docs/reference/delivery.md#worker).

What run() does in a loop:

1. **Claim**: Lease due deliveries, respecting each endpoint's in-flight limit.
2. **Resolve**: Read each endpoint's current URL, status, and secrets.
3. **Send**: Sign and POST. No database transaction is held open meanwhile.
4. **Record**: Store the attempt; mark succeeded, failed, or due for retry.

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 [#one-batch-instead-of-a-loop]

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

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

## A supervised worker process [#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.

```ts title="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:

| Variable                          | Required | Purpose                                                      |
| --------------------------------- | -------- | ------------------------------------------------------------ |
| `DATABASE_URL`                    | yes      | PostgreSQL connection string                                 |
| `WEBHOOK_ENCRYPTION_KEY`          | yes      | The key that encrypted the registered endpoints              |
| `WEBHOOK_SCHEMA`                  |          | Must match the migrations and the producer. Default `public` |
| `WEBHOOK_POLL_INTERVAL_MS`        |          | Wait after an empty batch. Default 1000                      |
| `WEBHOOK_DB_CONNECT_TIMEOUT_MS`   |          | Default 5000                                                 |
| `WEBHOOK_DB_STATEMENT_TIMEOUT_MS` |          | Default 10000. The client query timeout is one second longer |
| `WEBHOOK_ALLOW_LOCALHOST`         |          | `true` 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.

> **Warning.** 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](https://good-webhooks.dev/docs/better-auth/delivery.md#run-a-separate-worker).

## Handle failures [#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](https://good-webhooks.dev/docs/operations/failure-behavior.md) lists what each kind of failure does to in-flight work.

## Shut down cleanly [#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. **Cancel**: Remaining requests are cancelled and their outcome recorded.
4. **Return**: `run()` 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](https://good-webhooks.dev/docs/guides/receive.md#process-each-event-once).

## Bound database waits [#bound-database-waits]

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

```ts title="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](https://good-webhooks.dev/docs/operations/retention.md) separately.
