Good Webhooks

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>
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 works as source, including Better Auth management. The constructor starts no work.

OptionDefaultContract
databaseRequiredPostgreSQL executor with query() and connect(). A checked-out client has query() and release(). pg.Pool satisfies this shape.
eventsRequiredNonempty map of event names to Standard Schema validators.
sourceRequiredTrusted 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.maxAgeMs86400000Delivery age limit from 100 through 2,592,000,000 ms.
retentionMs604800000Event retention from 1,000 through 31,536,000,000 ms. Must be at least retry.maxAgeMs.
allowLocalhostfalsePermits HTTP loopback destinations for development. Other private addresses stay forbidden.
deliverySee belowPer-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

FieldDefaultLimits
timeoutMs10000Integer from 10 through 120,000 ms.
concurrency10Integer from 1 through 100 claims per batch.
leaseMs60000Integer from 100 through 3,600,000 ms, at least twice timeoutMs.
maxResponseBytes4096Integer 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 resultContract
input.typeA configured event name.
input.dataThe event validator's input type. The validator runs before anything is stored.
idempotencyKeyNonblank string of at most 200 characters without null bytes. Unique within a scope while the event is retained.
options.transactionOptional PostgreSQL client inside an active BEGIN transaction. It must access the configured delivery tables.
result.eventIdAccepted event UUID.
result.deliveryCountNumber of original deliveries created for the accepted recipient set, including paused recipients. Can be zero.
result.duplicatetrue 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.

ErrorWhen
INVALID_INPUTUnknown event type, payload rejected by the validator, payload outside the JSON limits, or an invalid key.
IDEMPOTENCY_CONFLICTThe key already identifies a different event type or payload in this scope.
TRANSACTION_REQUIREDoptions.transaction has no active transaction.
Other errorsValidator-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 fieldContract
endpointIdOnly deliveries to this endpoint.
eventIdOnly deliveries of this event UUID.
statusOne delivery status.
limitInteger from 1 through 100. Default 25.
beforeExclusive 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.

ErrorWhen
NOT_FOUNDNo delivery with this ID in the selected scope.
INVALID_STATEThe endpoint is not active, the delivery is a replay or not succeeded or failed, or the event is outside retention.
REPLAY_IN_PROGRESSThe original delivery already has a pending or in-flight replay.

Delivery and attempt records

Delivery fieldType or meaning
id, endpointId, eventIdString identifiers.
status'pending', 'in_flight', 'succeeded', 'failed', or 'cancelled'.
attemptCountNumber of recorded attempts.
nextAttemptAt, createdAtDate values.
replayOfOriginal delivery ID, or null.
lastErrorLast error text, or null.
lastStatusLast HTTP status, or null.
attemptsAttempt[]. Only on DeliveryDetail, the result of get.
Attempt fieldType or meaning
numberAttempt number, starting at 1.
startedAtDate.
finishedAtDate, or null while the attempt runs.
outcome'started', 'succeeded', 'retry', 'failed', or 'abandoned'.
responseStatusHTTP status, or null.
responseBodyCaptured response text bounded by maxResponseBytes, or null.
errorError 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 })
SettingDefaultLimits
maxInFlight2Integer 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>
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),
})
OptionApplies toDefaultContract
signalrun (required), runOnceNoneAbort to stop. An already-aborted signal returns without database work.
onErrorrunNoneCalled with an internal batch failure. Without it, the failure stops run.
pollIntervalMsrun1000Integer from 10 through 60,000 ms. The wait after an empty batch.
shutdownGraceMsrun, runOnce30000Integer 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:

CountMeaning
claimedDeliveries claimed in this batch.
succeededAttempts that got a 2xx.
retriedAttempts that failed and were scheduled for a retry.
failedAttempts that failed permanently or used up their retries.
staleAttempts 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

ResponseAttempt outcome
2xxsucceeded
Network, DNS, or timeout error, 408, 425, 429, 5xxretry while retries remain, otherwise failed
Any other status, including 3xxfailed. Redirects are never followed
URL resolves to an unsafe address at send timefailed, no retry
Worker stopped or lease expired mid-requestabandoned. 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

ErrorWhen
INVALID_INPUTpollIntervalMs or shutdownGraceMs is out of range.
INVALID_STATEThis instance already has an active run loop or batch.
AggregateErrorInternal 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.

On this page