ADR-007: Idempotency-Key required on every money-moving POST
- Status: Accepted
- Date: 2026-05-23
- Deciders: CTO / Founding Engineering
Context
Networks lose packets. Clients retry. Partners retry. Background workers retry. Without idempotency, every retry is a double-charge.
Fintech's most embarrassing class of bug is "we charged the same employee three times because retry policy met no idempotency policy."
Decision
- Every money-moving HTTP POST handler in
apps/apirequires anIdempotency-Keyheader on every request. - Every gRPC method that mutates money state in
services/ledgerorservices/integration-gatewayrequires anidempotency_keyfield. - Same scope + same key + same payload fingerprint → return the cached result.
- Same scope + same key + different fingerprint → return HTTP 409 / gRPC
FAILED_PRECONDITION("idempotency violation"). This is a bug in the caller, not a retry. - Idempotency records are retained for at least 24 hours.
- The contract is in
packages/shared/idempotency. Domains do not roll their own.
What counts as "money-moving"
Anything that, if executed twice, produces two ledger transactions:
- Wallet deposit / withdrawal / transfer
- EWA request approval / disbursement
- Payroll batch payment
- Loan disbursement / repayment
- BNPL purchase / scheduled deduction
- Bill payment
- Settlement payout
- External webhook ingestion that maps to a ledger entry
Alternatives considered
- Idempotency by
(actorId, requestHash)without an explicit header. Rejected — clients sometimes legitimately resubmit with a fresh intent and the same payload (e.g. transfer the same amount again). Explicit keys are the only honest signal. - Idempotency at the load balancer. Rejected — too coarse; can't see the request body to know what counts as "same."
- Soft idempotency (best-effort, swallow conflicts). Rejected — silent data loss is worse than the explicit error.
Consequences
Positive
- Retry-safe by construction. Operators can replay queues, partners can retransmit webhooks, mobile apps can resend on flaky connections — all without the system double-acting.
- 409 / FAILED_PRECONDITION points to a real caller bug, every time.
Negative
- Callers must generate and persist keys correctly. Mitigated by the SDK setting the key by default and documenting the pattern in onboarding.
- Idempotency store consumes storage. ~24h retention with the volumes we project: trivial.
Follow-ups
- NestJS interceptor in
apps/apienforces the header on routes decorated@MoneyMoving(). tooling/eslint/adds a custom rule: a controller method with@Post(...)in a domain taggedscope:wallet|payroll|ewa|lending|savingsmust carry the@MoneyMoving()decorator (or explicitly opt out with@NotMoneyMoving()for read-shaped POSTs).- Postgres impl of
IdempotencyStorelands when first money-moving endpoint goes live.