Idempotent API Design: Safe Retries with Idempotency Keys
Timeouts, mobile retries and gateway retries send the same request twice; the result is duplicate orders and duplicate payments. How the Idempotency-Key pattern is implemented server-side, natural idempotency with business keys, conditional updates with ETag and the equivalent for message consumers, with Spring Boot code.
A mobile app sends the request when "Confirm Order" is tapped, and retries when no response arrives within 30 seconds. The API gateway retries automatically on a 502. The user gets impatient and taps again. These three scenarios share one outcome: the same request reaches the server twice, and two orders, two payments, two invoices are created. Retries are unavoidable in distributed systems; the problem isn't the retry, it's an API that isn't prepared for it.
An idempotent operation produces a single effect no matter how many times it is repeated. This article covers what is naturally idempotent in HTTP, how the Idempotency-Key pattern is implemented on the server, natural idempotency through business keys, conditional updates with ETag, and the equivalent for message consumers, with Spring Boot code.
Natural idempotency in HTTP
Method | Idempotent? | Note |
|---|---|---|
GET, HEAD | Yes | Must be side-effect free; a "delete via GET" design breaks the guarantee |
PUT | Yes | Sets the whole resource to the given state; a second PUT writes the same state again |
DELETE | Yes | A second delete may return 404, but the effect is single |
POST | No | Every call creates a new resource; idempotency must be designed explicitly |
PATCH | Usually no | Relative updates like "stock += 5" accumulate on repeat |
The rule: creation operations (POST) and relative updates need an idempotency guarantee. The others are safe unless the design breaks them.
The Idempotency-Key pattern
The client puts a unique key (UUID) on every creation request; the server stores the key with a fingerprint of the request and the response it produced. If the same key arrives again, the business logic doesn't run; the stored response is returned as is.
POST /api/orders HTTP/1.1
Idempotency-Key: 7f3c9e2a-5b1d-4c8e-9a0f-2d6b8e4c1a77
Content-Type: application/json
{ "customerId": "C-1042", "lines": [ { "product": "10001", "quantity": 3 } ] }Case | Server behavior | HTTP response |
|---|---|---|
Key seen for the first time | Run the operation, store the response under the key | 201 Created |
Same key, same request body | Return the stored response; business logic doesn't run | Same as the original (201) |
Same key, different body | Reject; the client is misusing the key | 422 Unprocessable Content |
Same key, first request still in progress | Hold or reject | 409 Conflict (with Retry-After) |
No key | By policy: reject or process normally | 400 (if mandatory) |
Server-side implementation
The critical point is claiming the key atomically; two concurrent requests must not both see the same key as "first time". The most reliable way is a unique constraint in the database:
CREATE TABLE idempotency_keys (
key VARCHAR(64) PRIMARY KEY,
scope VARCHAR(64) NOT NULL, -- user / tenant; the key is unique within the scope
request_hash CHAR(64) NOT NULL, -- SHA-256 of the body
status VARCHAR(16) NOT NULL, -- IN_PROGRESS | DONE
response_code INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);@Component
public class IdempotentExecutor {
private final IdempotencyRepository repo;
private final TransactionTemplate tx;
public <T> Result<T> execute(String key, String scope, String bodyHash, Supplier<Result<T>> work) {
// 1) Claim the key: INSERT ... ON CONFLICT DO NOTHING → 0 rows = seen before
boolean claimed = tx.execute(s -> repo.claim(key, scope, bodyHash));
if (!claimed) {
IdempotencyRecord r = repo.find(key, scope);
if (!r.requestHash().equals(bodyHash)) throw new KeyMismatch(); // 422
if (r.status() == Status.IN_PROGRESS) throw new StillProcessing(); // 409
return Result.of(r.responseCode(), r.responseBody()); // stored response
}
// 2) Run the work and complete the key with the response
try {
Result<T> result = work.get();
tx.executeWithoutResult(s -> repo.complete(key, scope, result.code(), result.bodyJson()));
return result;
} catch (RuntimeException e) {
tx.executeWithoutResult(s -> repo.delete(key, scope)); // a failed attempt releases the key
throw e;
}
}
}@PostMapping("/api/orders")
ResponseEntity<OrderResponse> place(@RequestHeader("Idempotency-Key") String key,
@AuthenticationPrincipal User user,
@Valid @RequestBody OrderRequest request) {
Result<OrderResponse> result = executor.execute(key, user.tenantId(), Hash.sha256(request),
() -> Result.created(orderService.place(request.toCommand())));
return ResponseEntity.status(result.code()).body(result.body());
}Two details matter. The key is unique within a scope (user or tenant); otherwise a UUID generated by one client could return another's response. And completing the key must happen in the same transaction as the business logic; otherwise, in the "order created, key not marked DONE" case, a repeated request creates a second order. In the example above you achieve that by including repo.complete in the service transaction inside work.get(), or by using an outer transaction boundary.
Natural idempotency: taking the key from business data
An Idempotency-Key isn't needed everywhere. In most enterprise scenarios the business data itself carries a unique key:
Client-generated resource id: The client generates the order number and sends
PUT /api/orders/{number}; a second PUT rewrites the same resource, and the unique constraint on the table prevents duplicates.External reference: The customer reference on an order transferred to the ERP, the document number on an e-invoice; the other system says "this reference already exists".
Upsert: With
INSERT ... ON CONFLICT (customer_id, date) DO UPDATE, daily summary records stay a single row no matter how many times they are written.
For updates: ETag and conditional requests
The problem with updates isn't repetition but the lost update: two users read the same record, both write, one overwrites the other. The solution is the HTTP counterpart of optimistic locking:
GET /api/customers/C-1042 → 200, ETag: "v7"
PUT /api/customers/C-1042
If-Match: "v7" → 200 if the version is still 7; 412 Precondition Failed if it changedIn Spring, the @Version field is exposed as the ETag; when If-Match doesn't match, ObjectOptimisticLockingFailureException is mapped to 412. The client re-reads the record and shows the conflict to the user.
Idempotency in message consumers
What retries are to HTTP, redelivery is to Kafka and queues; an at-least-once guarantee means the same message may be processed twice. The counterpart is the same: the consumer writes the ids of processed messages to a table in its own transaction and skips an id that arrives again. We covered this pattern in detail in the outbox pattern article.
The payment scenario: a request with an unknown outcome
The hardest case is a request to the payment provider that times out: was the money taken or not? Retrying risks a double charge; not retrying is a lost sale. The right order: (1) pass your own idempotency key to the provider's key mechanism (most payment APIs support one), (2) on timeout, first query the transaction status from the provider, (3) if the outcome is still unknown, retry with the same key. Retrying without a key is unacceptable in payment systems.
Implementation details
Retention: Keys are kept for 24–72 hours, then deleted; clients retry within that window.
Body fingerprint: SHA-256 to catch a different body under the same key; canonical JSON so field order doesn't matter.
Response storage: Only the status code and body; for large responses, storing the resource id and regenerating is cheaper.
Observability: A "duplicate requests caught" metric surfaces both client bugs and network problems early.
Documentation: Declare the header as a required parameter in the OpenAPI definition; client teams won't use a pattern they don't know exists.
Pitfalls
Keeping keys in an in-memory cache. The guarantee disappears on restart or when a second instance appears; a shared database or Redis is needed.
Checking the key, then writing it. "SELECT, return if present, otherwise INSERT" lets two concurrent requests both through; an atomic INSERT is essential.
Leaving a failed attempt's key locked. If the business logic failed, the key must be released; otherwise the client can't fix and retry.
Keys without a scope. Key collisions across tenants are a data leak.
Frequently asked questions
Should the client or the server generate the Idempotency-Key?
The client; it is the one retrying and must send the same key. A server-generated key only works with a two-step "get a key, then send" flow and adds latency.
Redis or the database?
When response time is critical and key lifetime is short, Redis (atomic claim with SET NX EX) fits. For operations where loss is unacceptable, such as payments, the same database as the business data is safer because it gives a single-transaction guarantee.
Should we apply it to every POST endpoint?
To those whose effect is irreversible or costly: orders, payments, invoices, e-mail/SMS sending, writes to external systems. Side-effect-free POSTs such as search and reports don't need it.
Conclusion
Retries are the nature of distributed systems; the API's job is to make them safe. An Idempotency-Key or a business key for creation, ETag/If-Match for updates, a processed-messages table for consumers — this trio eliminates the duplicate-order and duplicate-payment class of failures at the design level.
To make your existing APIs safe against repeated requests, see our integration and automation and web and e-commerce development services.