Designing Idempotent Payment APIs in Spring Boot
Retries are inevitable. A client times out, a load balancer hiccups, a user double-clicks. If your payment endpoint isn’t idempotent, every one of those becomes a potential double charge.
The idempotency key pattern
The client generates a unique key per logical operation and sends it with the request:
POST /payments
Idempotency-Key: 8f14e45f-ea0c-4b9e-9f3d-6c2a1b0d7e21
On the server, you persist the key alongside the result of the first successful request. Subsequent requests with the same key return the stored result instead of executing again.
@PostMapping("/payments")
public ResponseEntity<PaymentResult> create(
@RequestHeader("Idempotency-Key") String key,
@RequestBody PaymentRequest request
) {
return idempotencyStore.find(key)
.map(ResponseEntity::ok)
.orElseGet(() -> {
var result = paymentService.charge(request);
idempotencyStore.save(key, result);
return ResponseEntity.ok(result);
});
}
The edge cases that matter
- Concurrent duplicates. Two identical requests can arrive before either finishes. Use a unique constraint on the key and treat the conflict as “already in progress.”
- Key reuse with a different body. Reject it — a key must map to exactly one request payload.
- Expiry. Keys can’t live forever. Pick a window that comfortably exceeds your longest client retry policy.
Get these three right and retries stop being scary.