Recovery playbook
Idempotency keys for transfer reversals
The webhook timed out, your queue retried, and the reversal ran twice. Stripe executed both calls correctly — from its point of view nothing went wrong. The seller now sees two debits. Because a reversal cannot itself be reversed, undoing the mistake means creating a fresh transfer back and an awkward explanation. The idempotency key is the only mechanism standing between a routine retry and that outcome, and using it well requires one decision most teams get subtly wrong.
Scope the key to the intent, not the request
The intent is "recover finding X" — so the key derives from the finding id: clawback-{id}. Keys derived from request UUIDs, timestamps or random values are unique per attempt and therefore useless: every retry carries a new key and Stripe happily executes each one. Ask what would make two executions "the same operation" and encode exactly that in the key.
// Right: retries collapse onto one execution
const key = `clawback-${finding.id}`;
// Wrong: every retry is a brand-new operation
const key = crypto.randomUUID();The 24-hour expiry nobody plans for
Stripe idempotency keys expire after 24 hours. A retry arriving later genuinely re-attempts the operation. The key therefore cannot be the whole defence — it handles the retry storm; the worker handles the long tail. Before acting, read the live transfer: if amount_reversed already reflects this finding, stop. One API call removes the entire class of late-retry duplicates.
The double-guard pattern
Check live state → derive key from intent → execute → persist Stripe’s response against the finding. Four steps, every money-moving job, no exceptions. Persisting the original response matters as much as the guard: when support asks "did this happen?", the answer is a stored receipt rather than a reconstruction.
What FeeGuard does about it
Every automated clawback derives its key from the discrepancy id, workers re-read live transfer state before acting regardless of queue age, and the append-only audit trail stores Stripe’s original response against the finding. The pattern is not clever — it is simply enforced everywhere, every time, which is the part teams find hard to sustain by hand.
Common questions
Should the idempotency key include the amount?
No. Include the intent; parameters belong outside the key. Otherwise a corrected amount after a failed validation creates a second key and defeats the purpose.
Do keys work across API versions?
Keys are honoured per key value for 24 hours independent of version. Do not rely on version pinning as a safety net.
Same pattern for application-fee refunds?
Yes — key on the fee-refund intent (feerefund-{findingId}), same live-state guard, same persistence.