Idempotency in REST APIs with Spring Boot: how to avoid duplicate charges on network retries
A mobile client sends a POST to /payments. The network drops right after the server processes the charge but before the response makes it back. The client sees a timeout, not an explicit error, and does what any well-behaved client does with a timeout: it retries. The server receives a second, identical request, has no way of knowing it already handled the first one, and charges the customer again.
This is the scenario that pushed us to standardize idempotency across the write endpoints of our Spring Boot services that move money or perform any operation that cannot be safely repeated. It is not an exotic edge case: it happens every time there is a network between client and server, and a network can always drop at the worst possible moment, which is right after the server finishes the work but before the client finds out.
Why a database constraint alone does not solve it
The instinctive first move is usually: "I'll put a UNIQUE constraint on the order number and call it done, the database will reject the duplicate." That does stop the double charge, which is half the problem, but it introduces a new one: the client's second request, the retry sent because it lost the response to the first one, now gets a constraint-violation error instead of the confirmation it was waiting for.
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> createPayment(@RequestBody PaymentRequest request) {
// UNIQUE on orderNumber prevents the duplicate charge...
Payment payment = paymentRepository.save(new Payment(request.getOrderNumber(), request.getAmount()));
return ResponseEntity.ok(new PaymentResponse(payment.getId(), payment.getStatus()));
}
On the first attempt, this saves the payment and returns a 200 with the transaction id. On the second attempt -the client's retry, which never saw that first response- the UNIQUE constraint throws, Spring translates it into a DataIntegrityViolationException, and the endpoint ends up returning a 409 or a 500 depending on how the error handler is wired. The client, which only wanted to confirm whether its payment went through, gets a generic error and has no way to tell "your payment failed" apart from "your payment already went through, this is a duplicate." That ambiguity is exactly what idempotency is supposed to remove, and a uniqueness constraint on its own does not remove it: it stops the duplicate side effect but never hands the client the result it actually needed.
The idempotency key and where it lives
The standard pattern is for the client to generate a unique identifier per operation -usually a UUID- and send it in a header, typically Idempotency-Key. That identifier represents the client's intent to execute the operation exactly once, no matter how many times the underlying HTTP request gets repeated because of network retries.
@Entity
@Table(name = "idempotency_keys")
public class IdempotencyKeyRecord {
@Id
private String key;
private String requestHash;
private Integer statusCode;
@Lob
private String responseBody;
private Instant createdAt;
private Instant expiresAt;
}
The table stores three things that matter: the key the client sent, a hash of the request body (to catch a client reusing the same key with a different payload, which is a client bug and should be rejected), and the full response the server generated the first time -status code included-. That last part is what solves the problem the UNIQUE constraint alone could not: when a retry arrives with a key the server has already seen, it does not re-run the business logic, it simply returns exactly the same response it generated the first time.
Implementing it with a Spring Boot filter
We centralize this in a OncePerRequestFilter instead of repeating the logic in every controller, because the idempotency mechanics are identical no matter which endpoint uses them.
@Component
public class IdempotencyFilter extends OncePerRequestFilter {
private final IdempotencyKeyRepository repository;
public IdempotencyFilter(IdempotencyKeyRepository repository) {
this.repository = repository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if (!requiresIdempotency(request)) {
chain.doFilter(request, response);
return;
}
String key = request.getHeader("Idempotency-Key");
if (key == null || key.isBlank()) {
response.sendError(HttpStatus.BAD_REQUEST.value(), "Missing Idempotency-Key header");
return;
}
Optional<IdempotencyKeyRecord> existing = repository.findById(key);
if (existing.isPresent()) {
IdempotencyKeyRecord record = existing.get();
if (record.getResponseBody() == null) {
response.sendError(HttpStatus.CONFLICT.value(), "This operation is already in progress");
return;
}
response.setStatus(record.getStatusCode());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(record.getResponseBody());
return;
}
try {
repository.insertPlaceholder(key, hashOf(request));
} catch (DataIntegrityViolationException e) {
// another request with the same key won the race and is already in flight
response.sendError(HttpStatus.CONFLICT.value(), "This operation is already in progress");
return;
}
ContentCachingResponseWrapper wrapped = new ContentCachingResponseWrapper(response);
chain.doFilter(request, wrapped);
byte[] body = wrapped.getContentAsByteArray();
repository.saveResponse(key, wrapped.getStatus(), new String(body, StandardCharsets.UTF_8));
wrapped.copyBodyToResponse();
}
}
Two details in this filter matter more than the rest of the code. The first is insertPlaceholder: we insert an empty row (no responseBody) before running the business logic, relying on the primary key being UNIQUE by definition. If two requests with the same key arrive at nearly the same time -which happens when a mobile client retries aggressively while the first request is still in flight- only one wins the insert; the other gets the database exception and returns a 409 without ever touching the business logic. Without this placeholder, both concurrent requests would run the charge before either finished persisting its result, which is exactly the race condition idempotency is meant to prevent.
The second detail is ContentCachingResponseWrapper: without it, there is no way to read the response body after the controller has already written to the HttpServletResponse, because the output stream cannot be replayed. The wrapper intercepts it, lets us copy the content into the idempotency table once the controller finishes, and then flushes it to the real response with copyBodyToResponse().
How long to keep the key
The expiresAt field exists because the table cannot grow forever, but the value should not come from a generic convention copied from another project. The right criterion is to cover the realistic worst case of client retries: if a mobile client does exponential backoff with a retry limit and a configured network timeout, the expiration window has to be longer than the sum of those, with room for a user closing the app during a connection drop and reopening it later, retrying the same operation with the same key it generated before the app closed. A scheduled job that purges expired keys is enough; there is no need to delete them inline with the request.
The mistake we actually kept running into
The failure mode we actually found when reviewing half-built idempotency implementations was not a missing idempotency key -it was applying idempotency only at the database layer, the same pattern shown in this article's first example. That pattern covers the happy path -nobody retries- and fails exactly in the case idempotency exists to handle: a retry after a lost response. If the only protection is a UNIQUE constraint, the system prevents the duplicate side effect but turns every legitimate retry into a client-facing error, and on the other side of that error is a person or a system that has no idea whether its payment went through.
Idempotency done well is not "stop the operation from being repeated in the database." It is "guarantee that no matter how many times the same request arrives, the client gets exactly the response it would have gotten if the network had never failed." That second part is the one a uniqueness constraint, on its own, can never give you.