Skip to content

ADR-0010: Idempotency key retention and expiry

Status: Accepted · Date: 2026-10-19 · Resolves: OQ-4

Context

An expired key must never cause a second charge. payment.idempotency_key is UNIQUE forever, while idempotency_key rows expire. We do not know how long terminals store and forward (OQ-4).

Decision

  • Working assumption A12: a terminal never resends a payment more than 24 h after its first attempt.
  • idempotency_key.expires_at = created_at + 24 h (safeledger.idempotency.retention, default 24h).
  • Within retention: the standard semantics (FR-2). A replay returns the same status and an equal JSON body. jsonb does not preserve bytes, so "byte for byte" is not promised.
  • After expiry the key is never reused. A request whose key row is expired, or whose key exists in payment.idempotency_key / reversal without a live key row, gets 422, problem code = IDEMPOTENCY_KEY_EXPIRED. It is not executed. A late store-and-forward replay can therefore never debit twice.
  • Fingerprint: SHA-256 hex over the request body re-serialised from the generated request model, with properties sorted alphabetically and no whitespace (Jackson SORT_PROPERTIES_ALPHABETICALLY + ORDER_MAP_ENTRIES_BY_KEYS). Equivalent JSON with different key order or whitespace therefore has the same fingerprint.
  • Key scope is global per scope (PAYMENT, REVERSAL). It is not per terminal: UUID keys make collisions negligible, and a collision with a different body is a 422.
  • Purging expired rows is deferred (roadmap). Expired rows are harmless, because they still answer IDEMPOTENCY_KEY_EXPIRED.

Consequences

  • + No double-charge path through key expiry.
  • − A terminal that replays after 24 h gets a 422 and must start a new payment. Revisit trigger: the POS team reports a longer store-and-forward window. Then raise the retention.