Skip to main content

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/api requires an Idempotency-Key header on every request.
  • Every gRPC method that mutates money state in services/ledger or services/integration-gateway requires an idempotency_key field.
  • 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/api enforces the header on routes decorated @MoneyMoving().
  • tooling/eslint/ adds a custom rule: a controller method with @Post(...) in a domain tagged scope:wallet|payroll|ewa|lending|savings must carry the @MoneyMoving() decorator (or explicitly opt out with @NotMoneyMoving() for read-shaped POSTs).
  • Postgres impl of IdempotencyStore lands when first money-moving endpoint goes live.