Skip to content
stateless.co · Engineering notes from the request/response layer
statelessThe engineering desk

A publication about the machinery under everyday software: the contracts between services, the queries behind a page, and the failures that only show up in production.

01APIs & Protocols

Idempotency-Key Implementation in REST APIs: Stopping Double Charges Without Missing the Second Request

Implementing idempotency keys in a REST API requires more than just deduplicating on key - it demands storing enough detail to reject mismatched retries and safely handle concurrent…

Idempotency-Key Implementation in REST APIs: Stopping Double Charges Without Missing the Second Request
Photo: tales of a wandering youkai · CC BY 2.0 · Wikimedia Commons
What is in this piece
  1. Request Fingerprint and Record Shape
  2. What Happens on a Repeat
  3. The Running-Request Case
  4. Expiration and Retention
  5. What to Store, and What Not to Invent

When a request replay shares the same key, endpoint, and payload as the original, the stored response should be returned. If the fingerprint differs, the replay should fail. Concurrent retries must leave only one operation running, with a processing state and temporary response often handled by middleware. Stored records must also carry an expiration time.

Request Fingerprint and Record Shape

To replay a request correctly or reject an unrelated retry, the server needs to store the idempotency key in combination with a request fingerprint. The Go middleware package velmie/idempo fingerprints requests with method, URL, body hash, and optionally certain headers, blocking accidental key reuse with a different request 3,4.

A Rails implementation calculates a request signature from the URL, method, and body hash to identify unique requests tied to one idempotency key. This ensures that even retries with different payloads are not replayed 7.

A Spring Boot guide recommends records that include caller scope, endpoint, key, fingerprint, state, response status, and an expiration timestamp. Only upon a state change, typically from PENDING to either COMPLETED or FAILED, is the actual response stored to avoid replaying non-terminal states or states that might be workflow-specific 11,14.

Replacement fields: 2/2

What Happens on a Repeat

When a server receives a repeat request with a matching idempotency key, it consults the state. If the first request is no longer PENDING or PROCESSING, a stored final response is returned, not a fresh computation. velmie/idempo populates Result.Response on this check to enable direct response replay from the middleware 5,6.

If the first request is still running, only the owner may perform further operations. A non-owner sees Result.IsOwner as false, while the owner can proceed and commit. Ownership must be guarded carefully to avoid conflicting concurrent request handling. On failure, both velmie/idempo and the Rails implementation unlock the key to allow retries by the owner 5,6,7,8.

The Running-Request Case

Processing and committing an operation as its owner yields full control over the final response, state, and retention, but handling concurrent non-owners requires more nuance. The Spring Boot implementation shows a 409 Conflict with Retry-After and no service action, while the Rails implementation indicates a replay state without committing a response. More detail may be required on proofs, timeouts, and expiries for either approach to avoid non-owners from falling into a bottleneck 8,12.

Replacement fields: 0/0

Expiration and Retention

Expiration for idempotency records balances usability, storage efficiency, and security. Newer records cannot overhang earlier usage if constraints change. Stored responses, though often replayed from temporary caches, must ultimately expire once they can no longer be reset or recomputed fresh 10,13.

While practical heuristics offer one fallback for deployments lacking a strong specification, the Rails implementation still advocates defining an appropriate window based on the expected retry interval. The Spring Boot implementation offered a 24-hour TTL, though no primary-spec source specifies the correct TTL or expiration behavior across implementations and schemas 10,11,13,14.

What to Store, and What Not to Invent

When implementing idempotency keys in your API, several key pieces of data are required to reliably prevent double-charges, enforce single-semantics, and replay responses for matching retries:

  • The idempotency key itself, as a key/index for retrieval 1,2,9
  • The unique request fingerprint detailing endpoint and payload 1,2,3,4,5,11,12
  • The processing state to differentiate in-flight requests from rejected, completed, or failed ones 5,6,7,8,11,12,14
  • The final response to serve on replay from innumerable retry attempts 1,4,5,6,7,11,12,14
  • The owner identity to allow retries in progress and prevent accidental duplicate processing 5,7,8
  • The expiration time for old keys and immutable responses to prevent permanent state 10,13,14
  • Failed requests as a separate state or response, or avoid or retry as appropriate, to satisfy client retry logic 11,12

Replacement fields: 0/0

Constructing a schema or memory-store view with this data avoids ambiguous replay, concurrent execution, unintended statefulness, and squandered computation. The idempotency state, once committed, can reify the request enough for any service to replay the outcome correctly on idempotent re-submissions.

Establishing an idempotent contract for your endpoints, while nontrivial, merits a synthesis of these components directly. Accounting for incremental states and replacing work with stored results, when applied consistently, sets the table for reaching single-request performance and consistency even under duplicated or absent requests.