# Delivery

> Publication, delivery history, worker execution, and PostgreSQL sender options.

Source: https://good-webhooks.dev/docs/reference/delivery



`good-webhooks/delivery` is the PostgreSQL sender: it publishes events, records deliveries, and runs workers against any endpoint source.

```ts
import { createDelivery } from 'good-webhooks/delivery'
import type {
  DeliveryEngine,
  DeliveryOptions,
  EndpointSource,
  EndpointResolution,
  ManagementScope,
} from 'good-webhooks/delivery'
```

Shared event, database, query, and result types come from [`good-webhooks`](https://good-webhooks.dev/docs/reference/core.md#other-root-exports). Every method on this page is also on the [`createWebhooks()`](https://good-webhooks.dev/docs/reference/core.md) client.

## createDelivery [#createdelivery]

```ts
createDelivery<const E extends EventDefinitions>(options: DeliveryOptions<E>): DeliveryEngine<E>
```

```ts title="delivery.ts"
import { createDelivery } from 'good-webhooks/delivery'
import { createPostgresManagement } from 'good-webhooks/management/postgres'
import { Pool } from 'pg'
import { z } from 'zod'

const database = new Pool({ connectionString: process.env.DATABASE_URL })
const events = {
  'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }),
}
const management = createPostgresManagement({
  database,
  eventTypes: Object.keys(events),
  encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!,
})

export const delivery = createDelivery({ database, events, source: management.source })
```

Any [`EndpointSource`](https://good-webhooks.dev/docs/reference/management.md#endpointsource) works as `source`, including [Better Auth management](https://good-webhooks.dev/docs/better-auth/delivery.md). The constructor starts no work.

| Option           | Default                                        | Contract                                                                                                                                |
| ---------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `database`       | Required                                       | PostgreSQL executor with `query()` and `connect()`. A checked-out client has `query()` and `release()`. `pg.Pool` satisfies this shape. |
| `events`         | Required                                       | Nonempty map of event names to Standard Schema validators.                                                                              |
| `source`         | Required                                       | Trusted `EndpointSource` with `matchRecipients` and `resolveEndpoint`.                                                                  |
| `schema`         | `'public'`                                     | PostgreSQL schema containing delivery tables.                                                                                           |
| `retry.delaysMs` | `[1000, 5000, 30000, 120000, 600000, 1800000]` | At most 20 integer delays, each from 1 through 86,400,000 ms. An empty array disables retries.                                          |
| `retry.maxAgeMs` | `86400000`                                     | Delivery age limit from 100 through 2,592,000,000 ms.                                                                                   |
| `retentionMs`    | `604800000`                                    | Event retention from 1,000 through 31,536,000,000 ms. Must be at least `retry.maxAgeMs`.                                                |
| `allowLocalhost` | `false`                                        | Permits HTTP loopback destinations for development. Other private addresses stay forbidden.                                             |
| `delivery`       | See below                                      | Per-attempt and per-batch limits.                                                                                                       |

Event names match `^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,119}$`. The constructor validates each event's Standard Schema interface. Any invalid option throws `INVALID_CONFIG`. [Delivery guarantees](https://good-webhooks.dev/docs/concepts/delivery-guarantees.md) explains how `retry` plays out.

### The delivery object [#the-delivery-object]

The `delivery` object has the following fields. This table is generated from the public `DeliveryOptions` type.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `timeoutMs?` | `number` |  |  |
| `concurrency?` | `number` |  |  |
| `leaseMs?` | `number` |  |  |
| `maxResponseBytes?` | `number` |  |  |

| Field              | Default | Limits                                                             |
| ------------------ | ------- | ------------------------------------------------------------------ |
| `timeoutMs`        | `10000` | Integer from 10 through 120,000 ms.                                |
| `concurrency`      | `10`    | Integer from 1 through 100 claims per batch.                       |
| `leaseMs`          | `60000` | Integer from 100 through 3,600,000 ms, at least twice `timeoutMs`. |
| `maxResponseBytes` | `4096`  | Integer from 0 through 65,536 captured response bytes.             |

## publish [#publish]

```ts
publish(input: PublishInput<E>, options?: PublishOptions): Promise<PublishResult>
```

```ts
import { delivery } from './delivery.js'

const { eventId, deliveryCount, duplicate } = await delivery.publish({
  type: 'invoice.paid',
  data: { invoiceId: 'inv_123', amount: 4200 },
  idempotencyKey: 'invoice.paid:inv_123',
})
```

| Input or result        | Contract                                                                                                         |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `input.type`           | A configured event name.                                                                                         |
| `input.data`           | The event validator's input type. The validator runs before anything is stored.                                  |
| `idempotencyKey`       | Nonblank string of at most 200 characters without null bytes. Unique within a scope while the event is retained. |
| `options.transaction`  | Optional PostgreSQL client inside an active `BEGIN` transaction. It must access the configured delivery tables.  |
| `result.eventId`       | Accepted event UUID.                                                                                             |
| `result.deliveryCount` | Number of original deliveries created for the accepted recipient set, including paused recipients. Can be zero.  |
| `result.duplicate`     | `true` when the same key already identifies the same event type and validated payload.                           |

The stored body is `{ id, type, occurredAt, data }`. `id` is the event UUID and `occurredAt` is an ISO timestamp assigned during publication.

The event and its original deliveries commit atomically within delivery storage. A caller-owned transaction stays uncommitted when `publish` returns. The [transaction and recipient guarantees](https://good-webhooks.dev/docs/concepts/delivery-guarantees.md) also apply to duplicate publication. The [publish guide](https://good-webhooks.dev/docs/guides/publish.md) shows idempotency keys and transactions in context.

| Error                  | When                                                                                                       |
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| `INVALID_INPUT`        | Unknown event type, payload rejected by the validator, payload outside the JSON limits, or an invalid key. |
| `IDEMPOTENCY_CONFLICT` | The key already identifies a different event type or payload in this scope.                                |
| `TRANSACTION_REQUIRED` | `options.transaction` has no active transaction.                                                           |
| Other errors           | Validator-thrown errors, source failures, and database failures propagate unchanged.                       |

**Payload normalization and limits**

Payload normalization sorts object keys. It rejects nonfinite numbers, cycles, non-JSON values, nonplain objects, nesting beyond 64 levels, and JSON larger than 256 KiB. A publication validator can transform input, but its output must meet those JSON limits.

**Publishing inside a caller-owned transaction**

`publish` runs in a savepoint on the supplied client, so a failed publication rolls back only its own writes. Workers cannot see the event until you commit. Recipient matching is a separate read through `source`, so it is not part of your transaction. See [publish inside your transaction](https://good-webhooks.dev/docs/guides/publish.md#publish-inside-your-transaction).

## deliveries [#deliveries]

```ts
deliveries.list(query?: DeliveryQuery): Promise<Page<Delivery>>
deliveries.get(id: string): Promise<DeliveryDetail>
deliveries.replay(id: string): Promise<Delivery>
```

```ts
import { delivery } from './delivery.js'

const page = await delivery.deliveries.list({ status: 'failed', limit: 25 })
const detail = await delivery.deliveries.get(page.items[0]!.id)
if (detail.replayOf === null) await delivery.deliveries.replay(detail.id)
```

### list [#list]

| `DeliveryQuery` field | Contract                                                      |
| --------------------- | ------------------------------------------------------------- |
| `endpointId`          | Only deliveries to this endpoint.                             |
| `eventId`             | Only deliveries of this event UUID.                           |
| `status`              | One delivery status.                                          |
| `limit`               | Integer from 1 through 100. Default 25.                       |
| `before`              | Exclusive delivery-ID cursor. Pass the previous `nextCursor`. |

`list` returns newest delivery IDs first. The result is `{ items, nextCursor }`; `nextCursor: null` marks the final page. Invalid query values throw `INVALID_INPUT`.

### get [#get]

`get` returns the delivery with its `attempts` array in attempt-number order. Delivery IDs are positive PostgreSQL bigint strings. A missing record in the selected scope produces `NOT_FOUND`.

### replay [#replay]

`replay` sends the original event to the same endpoint again as a new delivery. It keeps the event ID and body and gets a new delivery ID and retry budget. See [retries and replay](https://good-webhooks.dev/docs/operations/retries-and-replay.md).

| Error                | When                                                                                                                    |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `NOT_FOUND`          | No delivery with this ID in the selected scope.                                                                         |
| `INVALID_STATE`      | The endpoint is not active, the delivery is a replay or not `succeeded` or `failed`, or the event is outside retention. |
| `REPLAY_IN_PROGRESS` | The original delivery already has a pending or in-flight replay.                                                        |

### Delivery and attempt records [#delivery-and-attempt-records]

| Delivery field                | Type or meaning                                                          |
| ----------------------------- | ------------------------------------------------------------------------ |
| `id`, `endpointId`, `eventId` | String identifiers.                                                      |
| `status`                      | `'pending'`, `'in_flight'`, `'succeeded'`, `'failed'`, or `'cancelled'`. |
| `attemptCount`                | Number of recorded attempts.                                             |
| `nextAttemptAt`, `createdAt`  | `Date` values.                                                           |
| `replayOf`                    | Original delivery ID, or `null`.                                         |
| `lastError`                   | Last error text, or `null`.                                              |
| `lastStatus`                  | Last HTTP status, or `null`.                                             |
| `attempts`                    | `Attempt[]`. Only on `DeliveryDetail`, the result of `get`.              |

| Attempt field    | Type or meaning                                                      |
| ---------------- | -------------------------------------------------------------------- |
| `number`         | Attempt number, starting at 1.                                       |
| `startedAt`      | `Date`.                                                              |
| `finishedAt`     | `Date`, or `null` while the attempt runs.                            |
| `outcome`        | `'started'`, `'succeeded'`, `'retry'`, `'failed'`, or `'abandoned'`. |
| `responseStatus` | HTTP status, or `null`.                                              |
| `responseBody`   | Captured response text bounded by `maxResponseBytes`, or `null`.     |
| `error`          | Error text, or `null`.                                               |

## deliverySettings [#deliverysettings]

```ts
deliverySettings.get(endpointId: string): Promise<{ maxInFlight: number }>
deliverySettings.set(endpointId: string, settings: { maxInFlight: number }): Promise<{ maxInFlight: number }>
```

```ts
import { delivery } from './delivery.js'

const endpointId = 'ENDPOINT_ID' // From endpoints.create()
await delivery.deliverySettings.set(endpointId, { maxInFlight: 4 })
```

| Setting       | Default | Limits                     |
| ------------- | ------- | -------------------------- |
| `maxInFlight` | `2`     | Integer from 1 through 50. |

Stores sender-owned concurrency settings separately from endpoint records. Active and paused endpoints accept settings. Missing or deleted endpoints produce `NOT_FOUND`. An out-of-range value produces `INVALID_INPUT`.

These settings apply to all workers sharing the same delivery database and schema. They are distinct from the `delivery.concurrency` limit on each batch.

## worker [#worker]

```ts
worker.runOnce(options?: {
	signal?: AbortSignal
	shutdownGraceMs?: number
}): Promise<WorkerResult>
worker.run(options: {
	signal: AbortSignal
	onError?: (error: unknown) => void
	pollIntervalMs?: number
	shutdownGraceMs?: number
}): Promise<void>
worker.prune(): Promise<number>
```

```ts title="worker.ts"
import { delivery } from './delivery.js'

const stop = new AbortController()
process.once('SIGTERM', () => stop.abort())

await delivery.worker.run({
  signal: stop.signal,
  onError: (error) => console.error('Webhook worker batch failed', error),
})
```

| Option            | Applies to                  | Default | Contract                                                                                   |
| ----------------- | --------------------------- | ------- | ------------------------------------------------------------------------------------------ |
| `signal`          | `run` (required), `runOnce` | None    | Abort to stop. An already-aborted signal returns without database work.                    |
| `onError`         | `run`                       | None    | Called with an internal batch failure. Without it, the failure stops `run`.                |
| `pollIntervalMs`  | `run`                       | `1000`  | Integer from 10 through 60,000 ms. The wait after an empty batch.                          |
| `shutdownGraceMs` | `run`, `runOnce`            | `30000` | Integer from 0 through 2,147,483,647 ms. Time for prepared attempts to finish after abort. |

All worker methods cover every scope in the configured delivery database and schema. Workers own neither the host's process signal handlers nor its database pool. The [workers guide](https://good-webhooks.dev/docs/operations/workers.md) shows a supervised process.

### runOnce [#runonce]

`runOnce` recovers expired leases and processes one bounded batch. It does not drain the queue. Its `WorkerResult` contains these counts:

| Count       | Meaning                                                    |
| ----------- | ---------------------------------------------------------- |
| `claimed`   | Deliveries claimed in this batch.                          |
| `succeeded` | Attempts that got a `2xx`.                                 |
| `retried`   | Attempts that failed and were scheduled for a retry.       |
| `failed`    | Attempts that failed permanently or used up their retries. |
| `stale`     | Attempts whose claim no longer owns completion.            |

### run [#run]

`run` processes batches until its signal aborts. After a batch completes at least one prepared attempt as a success, retry, or terminal failure, the next batch starts immediately. Empty batches and stale-only results wait for `pollIntervalMs`. Receiver retries keep their configured schedule. Recovery and reservation cleanup alone do not trigger an immediate batch.

Without `onError`, an internal batch failure stops `run`. With `onError`, the loop awaits the callback, then waits for `pollIntervalMs` before continuing. A callback that throws or rejects stops the loop.

### Attempt outcomes [#attempt-outcomes]

| Response                                                   | Attempt outcome                                     |
| ---------------------------------------------------------- | --------------------------------------------------- |
| `2xx`                                                      | `succeeded`                                         |
| Network, DNS, or timeout error, `408`, `425`, `429`, `5xx` | `retry` while retries remain, otherwise `failed`    |
| Any other status, including `3xx`                          | `failed`. Redirects are never followed              |
| URL resolves to an unsafe address at send time             | `failed`, no retry                                  |
| Worker stopped or lease expired mid-request                | `abandoned`. The delivery retries if budget remains |

Each retry waits its configured delay plus up to 20% random jitter. A retry that would land after `retry.maxAgeMs` is not scheduled.

### prune [#prune]

`prune` deletes at most 100 expired events and their dependent deliveries and attempts per call. It returns the number of removed events. Live leases or contended locks can defer deletion. It does not run automatically. See [retention](https://good-webhooks.dev/docs/operations/retention.md).

### Worker errors [#worker-errors]

| Error            | When                                                                    |
| ---------------- | ----------------------------------------------------------------------- |
| `INVALID_INPUT`  | `pollIntervalMs` or `shutdownGraceMs` is out of range.                  |
| `INVALID_STATE`  | This instance already has an active `run` loop or batch.                |
| `AggregateError` | Internal failures in a batch. Thrown after all sibling attempts settle. |

**Shutdown and the grace period**

When the signal aborts, the worker stops taking new work and stops waiting for endpoint-provider results. Already prepared attempts can finish during the grace period. When the period expires, the worker requests cancellation of remaining sends. A value of `0` requests immediate cancellation.

The grace period starts when the signal aborts. It does not extend delivery leases or bound database cleanup. Both methods await owned work and completion writes before returning. Underlying provider calls can continue after the worker stops waiting for them. The host controls database timeouts, pool closure, and the final process deadline. Forced cancellation can leave the receiver's outcome unknown and require another attempt.

**Running several workers**

One worker instance cannot run overlapping `runOnce` calls or mix `runOnce` with an active `run` loop, including during shutdown. Separate instances coordinate through PostgreSQL leases and share each endpoint's `maxInFlight` limit.

## check and forScope [#check-and-forscope]

```ts
check(): Promise<void>
forScope(scope: Scope): Pick<DeliveryEngine<E>, 'publish' | 'deliveries' | 'deliverySettings'>
```

```ts
import { delivery } from './delivery.js'

await delivery.check()
const customer = delivery.forScope({ type: 'organization', id: 'org_123' })
await customer.deliveries.list()
```

`check()` verifies delivery schema version `4` and throws `SCHEMA_MISMATCH` otherwise, naming the SQL to apply. It never applies migrations and does not check management storage.

`forScope(scope)` returns `publish`, `deliveries`, and `deliverySettings` bound to one named scope. An invalid scope throws `INVALID_INPUT`. The root methods use application scope. Scope selection does not authorize access. See [scopes](https://good-webhooks.dev/docs/concepts/scopes.md).
