Retention and cleanup
Prune expired events, deliveries, and attempts on a schedule you control.
Events are retained for seven days by default (retentionMs). After that they are eligible for deletion. Nothing deletes them automatically: neither worker.run() nor worker.runOnce() prunes. Schedule a cleanup job.
const removed = await webhooks.worker.prune() // at most 100 events per callprune() deletes up to 100 expired events with their deliveries and attempts. Call it in a loop until it returns zero or your budget runs out.
A bounded cleanup job
The package ships a cleanup entrypoint that runs up to 20 batches or 30 seconds, then exits:
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'
let summary: { batches: number; deleted: number; reason: string } | undefined
const requestStop = () => stop.abort()
const fail = (error: unknown, at: string) => {
failed = true
process.exitCode = 1
console.error(
JSON.stringify({ event: 'webhooks.cleanup.failed', phase: at, ...errorDetails(error) }),
)
stop.abort()
}
process.on('SIGTERM', requestStop)
process.on('SIGINT', requestStop)
try {
const config = readOperationsConfig('cleanup')
pool = new Pool(config.database)
pool.on('error', (error) => fail(error, 'pool'))
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()
phase = 'prune'
const deadline = performance.now() + config.cleanupMaxDurationMs
let batches = 0
let deleted = 0
let reason = 'batch_budget'
while (batches < config.cleanupMaxBatches) {
if (stop.signal.aborted) {
reason = 'signal'
break
}
if (performance.now() >= deadline) {
reason = 'time_budget'
break
}
const count = await webhooks.worker.prune()
batches += 1
deleted += count
// A short batch can reflect contention. Continue until zero progress or a budget ends.
if (count === 0) {
reason = 'no_progress'
break
}
}
summary = { batches, deleted, reason }
if (reason === 'batch_budget' || reason === 'time_budget') {
console.warn(JSON.stringify({ event: 'webhooks.cleanup.budget_exhausted', ...summary }))
}
} 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 && summary)
console.log(JSON.stringify({ event: 'webhooks.cleanup.finished', ...summary }))
}
It uses the same configuration as the worker plus WEBHOOK_CLEANUP_MAX_BATCHES and WEBHOOK_CLEANUP_MAX_DURATION_MS. Run it from a checkout with node packages/good-webhooks/examples/operations/cleanup.ts.
It closes its pool, then logs webhooks.cleanup.finished with the batch count, deletion count, and stop reason. Budget exhaustion also logs webhooks.cleanup.budget_exhausted and still exits zero.
Size the schedule
Twenty batches per minute remove at most 2,000 events per minute. Locks and active leases lower that. Pick a cadence whose observed throughput exceeds the rate at which history expires, avoid overlapping runs, and alert on repeated budget exhaustion and nonzero exits.
no_progress means the last batch deleted nothing. That can be because everything eligible is gone, or because locks and leases deferred some rows.
What pruning removes
- The event, so it can no longer be replayed.
- Its publication idempotency key, so the key can be accepted again with new content.
If your business needs longer deduplication, keep your own record. Cleanup runs across every scope in its delivery database and schema. It never touches Better Auth management records or receiver receipts.