Delivery
Publication, delivery history, worker execution, and PostgreSQL sender options.
good-webhooks/delivery is the PostgreSQL sender: it publishes events, records deliveries, and runs workers against any endpoint source.
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. Every method on this page is also on the createWebhooks() client.
createDelivery
createDelivery<const E extends EventDefinitions>(options: DeliveryOptions<E>): DeliveryEngine<E>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 works as source, including Better Auth management. 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 explains how retry plays out.
The delivery object
The delivery object has the following fields. This table is generated from the public DeliveryOptions type.
Prop
Type
| 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(input: PublishInput<E>, options?: PublishOptions): Promise<PublishResult>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 also apply to duplicate publication. The publish guide 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. |
deliveries
deliveries.list(query?: DeliveryQuery): Promise<Page<Delivery>>
deliveries.get(id: string): Promise<DeliveryDetail>
deliveries.replay(id: string): Promise<Delivery>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
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 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 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.
| 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 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.get(endpointId: string): Promise<{ maxInFlight: number }>
deliverySettings.set(endpointId: string, settings: { maxInFlight: number }): Promise<{ maxInFlight: number }>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.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>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 shows a supervised process.
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 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
| 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 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.
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. |
check and forScope
check(): Promise<void>
forScope(scope: Scope): Pick<DeliveryEngine<E>, 'publish' | 'deliveries' | 'deliverySettings'>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.