A runbook is judged by one number: how long it takes an unfamiliar responder to stop the bleeding. Everything else — root-cause guidance, background, links — is secondary and belongs below the fold. This page gives the structure and the content, and sits under incident response for duplicate processing.
Prerequisites. The containment levers described below must actually exist and be tested; a runbook documenting a feature flag nobody has built is worse than none, because it consumes the responder’s first five minutes.
Step 1 — Containment first, above the fold
# RUNBOOK: Duplicate charges on /v1/charges
## STOP THE BLEEDING — run one of these now
# 1. Fail the endpoint closed. Rejects new charges; stops duplicates dead.
flagctl set charges.idempotency_required strict --reason "$INCIDENT_ID"
# 2. If a deploy in the last 2 hours is suspected, roll it back instead.
kubectl rollout undo deployment/payments-api -n payments
# 3. If duplicates come from ONE client minting fresh keys, cap its retries.
gwctl route charges set-retry-policy --credential "$CRED" --max-attempts 1
**Record the containment time now:** `date -u +%FT%TZ` → paste into the incident channel.
Three commands, no prose between them, the credential and incident id as the only variables. A responder should be able to act from this block without reading a sentence.
Step 2 — Commit the detection queries
Queries live in the repository next to the remediation script, not in the wiki page. A query in version control can be reviewed, tested against a snapshot, and fixed when a column is renamed.
-- runbooks/duplicate_charges/detect.sql
-- Usage: psql -v from="'2026-08-01 09:12:00Z'" -v to="'2026-08-01 14:41:00Z'" \
-- -v incident="'incident_2026_08_01'" -f detect.sql
\set table_name :incident '_duplicates'
CREATE TABLE :table_name AS
WITH windowed AS (
SELECT id, customer_id, amount_minor, currency, idempotency_key,
processor_charge_id, created_at,
date_trunc('second', created_at)
- (extract(second FROM created_at)::int % 10) * interval '1 second' AS bucket
FROM charges
WHERE created_at >= :from::timestamptz
AND created_at < :to::timestamptz
AND status IN ('succeeded', 'captured')
), grouped AS (
SELECT customer_id, amount_minor, currency, bucket, count(*) AS n,
(array_agg(id ORDER BY created_at))[1] AS keep_id,
array_agg(id ORDER BY created_at) AS all_ids
FROM windowed
GROUP BY customer_id, amount_minor, currency, bucket
HAVING count(*) > 1
)
SELECT customer_id, amount_minor, currency, n, keep_id,
unnest(all_ids[2:]) AS reverse_id, 'pending'::text AS reversal_state
FROM grouped;
-- The three numbers the incident channel needs within five minutes.
SELECT count(*) AS charges_to_reverse,
count(DISTINCT customer_id) AS customers_affected,
sum(amount_minor)/100.0 AS value_to_return
FROM :table_name;
The 10-second bucket is the tunable that decides false positives. Document why it is 10 seconds — “two identical charges more than 10 s apart are plausibly a genuine repeat purchase for this product” — so a future responder can adjust it knowingly rather than guessing.
Step 3 — The authorisation checklist
No bulk write to financial data happens without this, and it belongs in the runbook as a literal checklist the responder ticks in the incident channel.
## Before any reversal is authorised
- [ ] Containment confirmed: `charges_created_total / idempotency_keys_registered_total`
has returned to 1.000 for at least 10 minutes
- [ ] Detection query run and frozen into `<incident>_duplicates` (never remediate
from a live query)
- [ ] Processor cross-check: exported window row count matches the incident table
within 1%, or the difference is explained in the channel
- [ ] Twenty rows sampled and reviewed by a human; no genuine repeat purchases present
- [ ] Dry run executed: `reverse.py --run-id <incident> --dry-run` output attached
- [ ] Incident commander AND one finance approver have said "proceed" in the channel
- [ ] Support tooling frozen for the affected customers so agents do not refund in parallel
The dry-run output is the artefact the approvers actually read. Make the script print a one-line-per-charge projection — customer, amount, intended action, current processor state — so approval is a review rather than an act of faith.
# runbooks/duplicate_charges/reverse.py — committed, reviewed, dry-runnable.
python3 reverse.py --run-id incident-2026-08-01 --dry-run | tee dryrun.txt
wc -l dryrun.txt # must equal charges_to_reverse
grep -c 'would_refund\|would_void' dryrun.txt
grep -c 'manual_review' dryrun.txt # any value needs an owner before proceeding
Step 4 — Pre-write the customer communication
Drafting a message under time pressure produces either silence or a claim you cannot support. Template it, with placeholders that map to columns in the incident table.
Subject: We charged you {{ n }} time(s) in error on {{ date }} — refund issued
Hello {{ first_name }},
On {{ date }} a fault in our payment system charged your card {{ n }} times for the
same {{ description }}. You should have been charged once.
We have refunded {{ extra_amount }} to the card ending {{ last4 }}. Card refunds
usually appear within {{ refund_eta }}; your bank controls the exact timing.
Reference: {{ incident_public_ref }}. If the refund has not appeared by
{{ refund_deadline }}, reply to this message and we will chase it directly.
We are sorry. The specific fault has been fixed and we have added a monitor that
would have caught it before you did.
Send it after the reversal_audit rows exist, never before. Every factual claim in the message — the count, the amount, the refund reference — should be a column in a row you can produce on request. A message promising a refund that later fails is a second incident with the same customer.
Step 5 — Drill it quarterly
# runbooks/duplicate_charges/drill.sh — inject, then get out of the way.
set -euo pipefail
KEY_BASE="drill-$(date +%s)"
for i in $(seq 1 200); do
for attempt in 1 2 3; do # deliberate triple-send
curl -s -o /dev/null -XPOST "$STAGING/v1/charges" \
-H "Idempotency-Key: ${KEY_BASE}-${i}" \
-H 'Content-Type: application/json' \
-d '{"amount":4500,"currency":"gbp","source":"card_drill"}' &
done
done
wait
echo "drill duplicates injected at $(date -u +%FT%TZ) — hand the pager over"
Record two timings every drill: minutes to containment and minutes to a frozen incident table. Track them across quarters. A number that grows means the runbook has drifted from reality — usually a renamed flag, a rotated credential, or a query against a column that no longer exists.
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
| Runbook opens with three paragraphs of context, and the responder spends four minutes reading while duplicates accumulate | Move containment to the top and everything explanatory below the fold. Measure time-to-containment in the drill; above two minutes, restructure. | Drill metric minutes_to_containment; a rising trend means the page has grown a preamble |
| Containment lever documented but never built, so the first command fails | Test every lever in the quarterly drill and mark any that fails as a blocking defect. A documented, non-existent lever is worse than none. | Drill checklist with pass/fail per lever; any failure blocks sign-off |
| Detection query pasted from the wiki references a column dropped three months ago | Keep queries in the repository, run them against a staging snapshot in CI, and fail the build when a referenced column disappears. | A CI job executing every runbook query with EXPLAIN against the current schema |
| Support agents refund manually while the batch job runs, double-refunding some customers | Add “freeze support tooling for affected customers” to the authorisation checklist, and have the reversal job re-read processor state immediately before each action. | reversal_state_changed_since_snapshot_total; any non-zero value pauses the job |
| Customer email sent before the audit rows exist, promising refunds that later fail | Gate the communication step on reversal_audit row count matching the reversal count. Never send from the projection. |
A pre-send assertion comparing audit rows to the incident table; block on mismatch |
SRE / observability checklist
minutes_to_containment— Recorded per drill and per real incident. The target is under 2 minutes; the trend matters more than any single value.minutes_to_frozen_scope— Time from detection to a materialised incident table. Target under 15 minutes.runbook_lever_test_pass_rate— Share of containment levers that worked in the last drill. Anything below 100% is a blocking defect.runbook_query_ci_failures— Count of committed runbook queries failing their schema check. Catches drift between drills.charges_created_total / idempotency_keys_registered_total— The detection signal the runbook’s first checkpoint depends on. Page above1.001sustained for 5 minutes.incident_idon every command run during the incident — Via the--reasonflag on flag flips and therun_idon scripts, so the audit trail reconstructs itself.
Related
- Incident Response for Duplicate Processing — the parent page covering the full containment, scoping and reversal arc.
- Reconciling Duplicate Payments After an Incident — the closing step this runbook hands off to.
- Defining SLOs and Alerts for Deduplication Failures — the alerts that should page before a customer does.
- Chaos Testing Duplicate Request Handling — the engineering-side drill that complements this operational one.