The third part of our series was about If-Match and how you can use it to prevent lost updates for PUT and PATCH - two clients process the same resource, the server detects the collision based on the ETag and rejects the stale write access. One question remained unanswered: What about POST? When you create a new resource, there is no ETag to which you could attach a precondition - the resource doesn’t even exist yet. This is exactly the gap we’re talking about today.
You know that – a checkout form, the user clicks on “Order”, the cell phone network stops short, the answer doesn’t arrive in time. What does the user do? He clicks again. What does a well-intentioned retry mechanism do in the frontend or a load balancer that thinks a timeout is a server error? It automatically sends the request again. In both cases, the same ‘POST’ request arrives at the server twice - and if the server naively treats each request as a new order, your customer suddenly has two deliveries and two debits, even though he only wanted to order once.
This is not a special case, but a direct consequence of how HTTP defines POST: not idempotent. From a specification perspective, each call is an independent new operation. Unlike Lost Updates, there is no official HTTP standard for this - but there is a de facto convention that can be found in practically every serious payment and order API: Idempotency Keys. In this final part of the series, we’ll look at why POST has this problem, how idempotency keys solve it, and how to implement them cleanly in Spring Boot - including the race window problem that we already know from part 3.
The problem: non-idempotence of POST
The HTTP specification (RFC 9110) divides methods into idempotent and non-idempotent. Idempotent means: If you execute the same request multiple times, the result on the server is the same as if you executed it once. GET is idempotent - you simply read the same state multiple times. PUT is idempotent - if you set a resource to the same value twice, nothing changes the second time. Even DELETE is idempotent - the resource is gone after the first call, each subsequent call does not change this (although the status code may be different, 404 instead of 200).
POST is explicitly the exception. The spec deliberately says nothing about idempotence, because POST typically creates something - and “create the same thing again” is by definition a new thing, not a repetition of the old one. This is not in itself a flaw in the design of HTTP, but rather correct behavior as a rule. The problem only arises when networks are unreliable - and they are unreliable all the time.
A completely ordinary order endpoint, which every checkout flow probably needs:
// Anti-Pattern: Jeder POST erzeugt garantiert eine neue Bestellung – auch Retries
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest orderRequest) {
Order order = orderService.create(orderRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(order);
}
Functionally correct in the case that each request arrives exactly once. But exactly this assumption does not hold in practice:
- Client sends
POST /orderswith the shopping cart. - Server processes the order successfully, writes it to the database, initiates payment.
- The answer is lost on the way back - connection lost, timeout, proxy error. The client sees no success, just an error or nothing at all.
- The client does not know whether the request reached the server or not - from his point of view, both are plausible. So it retrys, automatically or because the user clicks again.
- The server receives a second
POST /orderswith identical content - and because it has no information that this is the same logical process, it creates a second order.
The tricky thing is that the server doesn’t do anything wrong, considering what it knows. He simply has no way of recognizing that Request 2 is a repeat of Request 1 and not a new, independent order. The client must explicitly provide this information – “this is the same process as before” –. This is exactly what the Idempotency Key does.
The solution: The Idempotency key header
The principle is essentially simple: The client generates a unique key, typically a UUID, for every logical process - not for every HTTP request, but for every technical attempt to “place this one order” - and sends it as a header:
POST /orders HTTP/1.1
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json
{ "items": [...], "total": 49.90 }
The server remembers this key along with the result of the operation. If the same key arrives a second time, the server doesn’t execute the operation again, but simply returns the saved result from back then - identical status code, identical response body, as if nothing had happened. From the client’s perspective, the retry is therefore safe: regardless of whether the original request was successful and only the response was lost, or whether it never reached the server - the client ultimately receives exactly one order, never two.
Important for classification: Unlike ‘If-Match’ from Part 3, the ‘Idempotency-Key’ header is not an established HTTP standard with a fixed RFC number that every server automatically understands. There is an IETF draft for this (‘draft-ietf-httpapi-idempotency-key-header’), but the pattern is mainly used as a de facto convention from the payment world - Stripe made the header significantly popular, and most APIs now use it where double writes would cost real money. So you implement the mechanism yourself instead of getting it “gifted” from the framework and spec like If-Match.
The process in detail when the key arrives for the second time:
- Server checks: Does this idempotency key already exist?
- If yes, and the original operation is completed: Server returns the saved result without calling
orderService.create()again. - If yes, but the original operation is still running (parallel retry while the first request is still being processed): Server responds with 409 Conflict - “Something is already running here with this key, wait or try again.”
- If no: Server executes the operation normally and saves key + result for future repetitions.
The crucial difference to If-Match: There the client has linked a condition to the state of the resource (“only if ETag X”). With the idempotency key, the client attaches a condition to the process itself (“this is the same attempt as before”). Both times, the client makes explicit implicit knowledge that the server otherwise wouldn’t have - just at different points in the request lifecycle.
Implementation in Spring Boot
Unlike If-Match, there is no built-in mechanism like WebRequest.checkNotModified() - you build the idempotency check yourself, with a table that remembers for each key what happened the first time.
The table: key, status, result
@Entity
@Table(name = "idempotency_keys")
public class IdempotencyRecord {
@Id
private String idempotencyKey;
private String requestHash;
@Enumerated(EnumType.STRING)
private IdempotencyStatus status;
private Integer responseStatus;
@Lob
private String responseBody;
private Instant createdAt = Instant.now();
protected IdempotencyRecord() {
// für JPA
}
public IdempotencyRecord(String idempotencyKey, String requestHash, IdempotencyStatus status) {
this.idempotencyKey = idempotencyKey;
this.requestHash = requestHash;
this.status = status;
}
// Getter, Setter
}
public enum IdempotencyStatus {
IN_PROGRESS,
COMPLETED
}
requestHash is important and often forgotten: it ensures that a key is actually reused for the same request. If a client sends the same idempotency key with a different payload - for whatever reason, bug in the client or copy-paste error - you don’t want to silently answer with the old result, but rather clearly reject it.
The service: Check, execute, remember
@Service
public class IdempotencyService {
private final IdempotencyRecordRepository repository;
private final ObjectMapper objectMapper;
public IdempotencyService(IdempotencyRecordRepository repository, ObjectMapper objectMapper) {
this.repository = repository;
this.objectMapper = objectMapper;
}
@Transactional
public <T> ResponseEntity<?> executeIdempotent(
String idempotencyKey,
Object requestBody,
Supplier<ResponseEntity<T>> operation) {
String requestHash = hash(requestBody);
Optional<IdempotencyRecord> existing = repository.findById(idempotencyKey);
if (existing.isPresent()) {
IdempotencyRecord record = existing.get();
if (!record.getRequestHash().equals(requestHash)) {
// Gleicher Key, anderer Payload -> kein sicherer Retry, klare Ablehnung
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
if (record.getStatus() == IdempotencyStatus.IN_PROGRESS) {
// Der erste Request läuft noch, dieser hier ist ein paralleler Retry
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
// Abgeschlossen -> gespeichertes Ergebnis zurückgeben, nichts erneut ausführen
return replayResponse(record);
}
// Neuer Key: Platzhalter anlegen, BEVOR die eigentliche Operation läuft
IdempotencyRecord record = new IdempotencyRecord(idempotencyKey, requestHash, IdempotencyStatus.IN_PROGRESS);
try {
repository.saveAndFlush(record);
} catch (DataIntegrityViolationException e) {
// Zwei Requests mit demselben neuen Key kamen praktisch gleichzeitig an
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
ResponseEntity<T> response = operation.get();
record.setStatus(IdempotencyStatus.COMPLETED);
record.setResponseStatus(response.getStatusCode().value());
record.setResponseBody(serialize(response.getBody()));
repository.save(record);
return response;
}
private String hash(Object requestBody) {
try {
byte[] json = objectMapper.writeValueAsBytes(requestBody);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(json));
} catch (JsonProcessingException | NoSuchAlgorithmException e) {
throw new IllegalStateException("Request konnte nicht gehasht werden", e);
}
}
private String serialize(Object body) {
try {
return objectMapper.writeValueAsString(body);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Response konnte nicht serialisiert werden", e);
}
}
private ResponseEntity<?> replayResponse(IdempotencyRecord record) {
try {
Object body = objectMapper.readValue(record.getResponseBody(), Object.class);
return ResponseEntity.status(record.getResponseStatus()).body(body);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Response konnte nicht wiederhergestellt werden", e);
}
}
}
public interface IdempotencyRecordRepository extends JpaRepository<IdempotencyRecord, String> {
}
The controller
@PostMapping("/orders")
public ResponseEntity<?> createOrder(
@RequestBody OrderRequest orderRequest,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
return idempotencyService.executeIdempotent(
idempotencyKey,
orderRequest,
() -> ResponseEntity.status(HttpStatus.CREATED).body(orderService.create(orderRequest))
);
}
The race window – and how the database closes it
There is also a small time window between “Key does not yet exist” and “Placeholder is saved”, just like in the “If-Match” check from Part 3 between checking and writing. If two requests with the same new key actually arrive at the same time, both repository.findById() could theoretically be answered with “not available” and both try to create a placeholder.
This is exactly why idempotencyKey in the entity above is @Id - the primary key enforces uniqueness at the database level. The second saveAndFlush() call inevitably fails with a DataIntegrityViolationException because the database does not allow the row with this key twice. The service intercepts this and responds with 409 Conflict. The same principle as @Version in part 3: The application check reduces the risk, the database constraint closes the last gap - defense in depth, not trust in a single layer.
Pro Tips/Warnings
Tip: Cleanly scale the
422case from the service code into a meaningful response instead of an empty body. A client that suddenly gets a422 Unprocessable Entityon a seemingly normalPOSTneeds a clue as to what’s going on:if (!record.getRequestHash().equals(requestHash)) { return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) .body(Map.of( "error", "idempotency_key_reused", "message", "This idempotency key has already been used with another request body." )); }This is usually a bug in the client - a retry mechanism that caches the key, but forgets that the payload has changed in the meantime (e.g. because the shopping cart was filled again after an error). A clear error message makes this immediately visible during debugging, instead of someone spending hours puzzling over why the “same” request is suddenly handled differently.
Warning: Do not save idempotency keys forever. Without cleaning up, the table grows indefinitely - and the longer a key remains valid, the greater the risk that a client will accidentally reuse it weeks later for a completely new request that happens to have the same hash. A TTL of 24 to 72 hours is usual, depending on how long your system can realistically expect retries:
@Scheduled(cron = "0 0 3 * * *") // daily at 3 a.m public void cleanupExpiredKeys() { Instant cutoff = Instant.now().minus(Duration.ofHours(72)); idempotencyRecordRepository.deleteByCreatedAtBefore(cutoff); }The exact duration depends on how long your client realistically retries - a mobile client with an offline queue takes longer than a server-to-server call with an immediate timeout.
Warning: Do not confuse an idempotency key with true idempotency of the method.
PUTis idempotent because the result remains the same when executed multiple times with the same data - this applies regardless of whether the client sends any header. An idempotency key doesn’t makePOSTidempotent in the true sense, it makes repeated identical requests secure - a subtle but important difference. Without the header, or with a new key per attempt, the same endpoint continues to behave as normal, non-idempotent and creates a new resource with each call. Security only applies as long as the client is disciplined about using the same key for the same logical process - if you rely on it to do this, you shift some of the correctness to the client. For critical flows (payments), it is therefore worth making the header mandatory with@RequestHeader(required = true)instead of leaving it optional.
Conclusion
Idempotency keys solve a problem that POST inherently has: the client cannot know for sure whether its request has been received, and the only safe reaction - “just send it again” - becomes a risk to data integrity without additional information. The header makes explicit what is otherwise implicitly lost: that two requests are the same attempt, not two independent operations. On the server side, a simple table with a unique constraint is sufficient - not an exotic mechanism, but one that hardly any CRUD endpoint comes with.
This closes the arc of this series. Part 1 was about QUERY as a clean alternative for complex but secure read operations. Part 2 showed how caching headers prevent unnecessary work without serving stale data. Part 3 stopped writing with If-Match Lost Updates. And today Idempotency Keys have closed the final gap: duplicate writes caused by network retries on POST.
What connects these four building blocks: None of them are complicated in and of themselves. ETag, If-Match, a hash in the header – these are all small, inconspicuous mechanisms. The real difference between an API that “works most of the time” and one that stays correct under load, network failures, and parallel accesses rarely lies in major architectural decisions. It’s in exactly those details that are easy to miss when you first throw up an endpoint - and which take their toll as soon as real users and real network chaos come across them.
![[EN] Idempotency Keys in Spring Boot: Safely prevent duplicate POST requests](/images/http-idempotency-keys-header.jpeg)