Replay deliveries

Re-deliver events through a forwarder behind a mandatory dry-run, with idempotency, rate control, and exit-code-driven waiting.

replay is the primary mutating command in the agentic surface. It re-delivers real events to a real target, a deliberate re-send, distinct from the forwarder's automatic retries. Every replayed delivery has the same downstream effect as the original webhook.

A replay is not a simulation. Replayed deliveries hit the live target and can charge cards, send mail, or mutate state again. Always run --dry-run first, and never replay a --query or --dlq selection without reading the would_replay count.

Before you replay

Replay follows the universal safe-mutation pattern: confirm write scope, resolve stable IDs (arrive here from Diagnose webhooks), preview, make it idempotent, gate bulk behind --yes, then wait. This page covers the replay-specific parts: choosing a selector, reading the dry-run, and branching on the wait exit code. --forwarder is always required; a replay targets exactly one forwarder.

Always dry-run first

--dry-run resolves the selector against the index and returns a count without creating a job. It is the only safe way to learn a replay's blast radius.

repost replay \
  --forwarder prod-api \
  --bucket stripe-prod \
  --query 'response_status:500 AND path:/stripe/*' \
  --since 30m \
  --dry-run \
  --json
{
  "schema": "repost.replay.create/v1",
  "data": {
    "status": "DRY_RUN",
    "dry_run": true,
    "would_replay": 42
  }
}

In a dry-run, job_id is absent and would_replay is authoritative. Gate on it: if the count is 0, there is nothing to replay; if it is higher than the diagnosis predicted, the selector is too broad. Refine it rather than continuing.

Choose a selector

A replay sources its events one of four ways. The selector you pass determines the source_type on the resulting job.

To replay…Command shapesource_type--yes for execution
Specific eventsreplay evt_1 evt_2 …ID_LISTOnly when more than one ID
A search result setreplay --query '…' --since 30mSEARCH_QUERYRequired
A DLQ ULID rangereplay --dlq --dlq-id-from … --dlq-id-to …DLQ_RANGERequired
Specific DLQ rowsreplay --dlq --dlq-id … --dlq-id …DLQ_ID_LISTRequired

A single explicit event ID is the only replay that executes without --yes. Everything else is bulk: multiple IDs, any --query, or any --dlq selection. The CLI refuses it locally (--yes is required for non-dry-run bulk replay) and replays nothing unless you pass --yes or --dry-run. This makes "replay one known event" a one-liner while forcing a deliberate acknowledgement for fan-out.

Execute the replay

Once the dry-run count is right, drop --dry-run and confirm. Keep --idempotency-key and set --rate if the target is sensitive to burst.

repost replay \
  --forwarder prod-api \
  --bucket stripe-prod \
  --query 'response_status:500 AND path:/stripe/*' \
  --since 30m \
  --rate 25 \
  --idempotency-key "incident-4821-stripe-500s" \
  --yes \
  --json
{
  "schema": "repost.replay.create/v1",
  "data": {
    "job_id": "rpl_01JZ9F2K7M",
    "status": "PENDING",
    "dry_run": false
  }
}

The job is accepted asynchronously and starts in PENDING. Capture job_id. Every follow-up (status, wait, pause, cancel) takes it.

--rate is deliveries per second (default 50, accepted range 5500); values outside it are rejected before any job is created. Lower it when the target rate-limits or when you are replaying into production during business hours. This is the per-job replay speed, distinct from a forwarder's persistent --rate-limit-per-second.

Idempotent retries

If a replay call times out or its connection drops, re-run the identical command. With the same --idempotency-key, the API re-attaches to the original job instead of creating a second one and sets deduplicated:

{
  "schema": "repost.replay.create/v1",
  "data": { "job_id": "rpl_01JZ9F2K7M", "status": "IN_PROGRESS", "deduplicated": true }
}

Treat deduplicated: true as success: the work is already running under job_id. Without a key, a blind retry double-delivers, so always set one for bulk replays.

Wait for the result

replay wait polls until the job is finalized, then exits with a code that encodes the outcome. Branch on the exit code and the boolean finalized field, never on the status string.

repost replay wait rpl_01JZ9F2K7M --max-wait 5m --json
{
  "schema": "repost.replay.status/v1",
  "data": {
    "job_id": "rpl_01JZ9F2K7M",
    "status": "COMPLETED",
    "finalized": true,
    "total": 42,
    "pending": 0,
    "succeeded": 41,
    "failed": 1,
    "created_at": "2026-06-19T09:02:11Z",
    "completed_at": "2026-06-19T09:03:44Z"
  }
}

replay wait exit codes:

  • 0: finalized with zero failed deliveries. Done.
  • 8 (partial_failure): finalized but at least one delivery failed (or the job itself is FAILED). Inspect failed, then forwards chain the events that did not land.
  • 7 (timeout): --max-wait (default 5m) elapsed before the job finalized. The job keeps running; poll replay status or wait again.

Job lifecycle

A replay job moves through these states. wait returns the moment finalized becomes true: at COMPLETED, FAILED, or CANCELLED.

stateDiagram-v2
    [*] --> PENDING: replay accepted
    PENDING --> STARTING
    STARTING --> IN_PROGRESS
    IN_PROGRESS --> PAUSED: pause
    PAUSED --> IN_PROGRESS: resume
    IN_PROGRESS --> COMPLETED: all delivered
    IN_PROGRESS --> FAILED: errors exceed threshold
    IN_PROGRESS --> CANCELLED: cancel
    PAUSED --> CANCELLED: cancel
    COMPLETED --> [*]
    FAILED --> [*]
    CANCELLED --> [*]

Manage a running job

While a job runs, inspect or steer it by job_id.

repost replay status rpl_01JZ9F2K7M --json

status, list, and the actions return repost.replay.status/v1, repost.replay.list/v1, and repost.replay.action/v1 respectively. list is created_at-descending and paginates with has_more. Raise --limit to see older jobs. Use pause/resume to throttle a replay mid-incident; Guard a deployment shows the full pause-deploy-resume loop.

For agents

Replay decision procedure

  1. Require write scope (auth statusscopes contains write), else stop with forbidden_scope.
  2. Build a selector from concrete IDs / bounded query / DLQ rows. Never an open query.
  3. replay … --dry-run --json → read data.would_replay. If 0, stop. If unexpectedly large, refine.
  4. Re-run without --dry-run; add --yes unless the selector is exactly one event ID. Always pass --idempotency-key.
  5. On transport error, re-run the identical command; data.deduplicated == true means it is already running.
  6. replay wait <job_id> --json → branch on exit: 0 done · 8 inspect data.failed then forwards chain · 7 not finalized, wait again.

Continue