An idempotency key lets an API return the recorded result for a repeated create request instead of creating another receipt.
Spring POST idempotency keys: replay the same receipt result
Key and request must agree
The checked controller requires a nonblank Idempotency-Key. The first key and body create receipt-1. Sending the same key and body again returns receipt-1. Reusing the key with a different body gets 409; a new key can create receipt-2. The service compares exact request text in this fixture, so even harmless formatting differences count as different requests.
A real service should scope the key to the authenticated owner and operation, store a canonical request fingerprint and the response in durable storage, and perform the record/create decision atomically. A synchronized HashMap covers one controller instance only. It loses history on restart and cannot coordinate other replicas. Tenant write authorization is a separate check.
Do not confuse replay with preconditions
A retry of a create request asks whether the same operation already happened. If-Match asks whether a known resource revision is still current before an update. Both can appear in one API, but they protect different failure paths. A network timeout after a commit is exactly where replaying the stored result matters.
Checked source
@PostMapping(path = "/contract/idempotent-receipts", consumes = "text/plain")
synchronized ResponseEntity<String> create(
@RequestHeader(value = "Idempotency-Key", required = false) String key,
@RequestBody String request) {
if (key == null || key.isBlank()) return ResponseEntity.badRequest().build();
Stored prior = accepted.get(key);
if (prior != null) {
if (!prior.request().equals(request)) return ResponseEntity.status(409).build();
return ResponseEntity.ok(prior.receipt());
}
String receipt = "receipt-" + (accepted.size() + 1);
accepted.put(key, new Stored(request, receipt));
return ResponseEntity.ok(receipt);
}Verification boundary
IdempotentReceiptPostTest.replayReturnsTheOriginalReceiptAndConflictingPayloadIsRejected runs in the downloadable Spring source kit. The excerpt leaves out surrounding setup and imports; the kit contains the complete test.
Costs and limits
Map lookup is expected O(1) average time within one JVM, but the synchronized method serializes calls and memory grows with retained keys. The test covers local HTTP mapping and response decisions, not durable atomic creation, expiry, authentication, distributed retries or a payment-grade API.
Common Mistakes
- Do not mint another receipt for a retry with the same operation key.
- Do not accept the same key with a different request silently.
- Do not use a global key namespace when two tenants can choose the same key.
Read next
Spring If-Match writes: reject a stale receipt revision, Spring method authorization: reject a cross-tenant read, Spring transactional outbox: commit a receipt and event row together.
Continue with checked tenant commands
Continue with Spring POST idempotency keys: bind replay to tenant and command.
Release boundary continuation
Continue with Spring HTTP retries: only replay a command with a defined identity.
