# Retention and cleanup

> Prune expired events, deliveries, and attempts on a schedule you control.

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



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.

```ts
const removed = await webhooks.worker.prune() // at most 100 events per call
```

`prune()` 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 [#a-bounded-cleanup-job]

The package ships a cleanup entrypoint that runs up to 20 batches or 30 seconds, then exits:

```ts title="examples/operations/cleanup.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'
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](https://good-webhooks.dev/docs/operations/workers.md#a-supervised-worker-process) 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 [#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 [#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.
