Skip to content

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.