There's a version of this story that plays out at almost every fintech startup I've worked with: the team ships a payment API, it works in testing, it goes live. Then a user's connection drops mid-request. The mobile app retries. The backend processes it twice. Two charges hit the customer's account. And suddenly you're in an incident with your compliance team asking uncomfortable questions.
Idempotency is the only thing standing between you and that story. After 11 years building payment systems — including integrations with instant payment rails, card processors, and core banking platforms — I have a strong opinion: if your payment API doesn't implement idempotency from day one, you've already shipped a bug. You just haven't seen it detonate yet.
What Idempotency Actually Means
An idempotent operation produces the same result no matter how many times you apply it. In the context of a REST API: if the client sends the same request multiple times, the server executes it exactly once and returns the same response every time.
HTTP GET and DELETE are idempotent by definition. POST is not — unless you design it to be. Payment submission is POST. So you have to build idempotency yourself.
The standard mechanism: the client generates a unique idempotency key (a UUID) and includes it in the request header. The server stores the key alongside the resulting response. On subsequent requests with the same key, the server returns the stored response without reprocessing.
Why Networks Make This Non-Negotiable
Mobile clients, third-party integrators, internal services — they all operate over networks that can fail. A timeout doesn't tell the client whether the request was received. The connection might have dropped before the request arrived. Or after processing. Or mid-response.
The only safe behavior for a client facing a timeout on a payment request is to retry. Which means your server must be able to receive the same payment request twice and process it exactly once. Without idempotency, you're putting the burden of deduplication on the client — which is both incorrect and unenforceable.
At scale, this gets worse. Under load, timeouts become more frequent. Retries increase. Without idempotency, your duplication rate scales with your traffic.
Getting the Key Scope Right
Here's where teams commonly go wrong: scoping the idempotency key to the request body hash.
My recommendation: require the client to provide the idempotency key explicitly — a UUID they generate before the request. This makes deduplication intent explicit and removes ambiguity. Stripe does this. Braintree does this. There's a reason.
Pair the key with the user ID and operation type in your storage key. A key generated by user A should never accidentally deduplicate a request from user B with the same UUID.
Idempotency Is Not Caching
This distinction matters more than it sounds. Caching is about performance — a cache miss is acceptable. Idempotency is about correctness — a "miss" on your idempotency store could result in a duplicate charge.
Your idempotency store needs:
- Durability: if your service restarts, you cannot lose stored idempotency records
- Atomicity: storing the key and executing the payment must be atomic, or a concurrent duplicate can slip through
- TTL policy: 24 hours is a common baseline
A Redis cache with default eviction policy fails at least two of these.
The Implementation Skeleton (Spring Boot)
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> createPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody PaymentRequest request) {
Optional<PaymentResponse> cached = idempotencyStore.get(idempotencyKey);
if (cached.isPresent()) {
return ResponseEntity.ok(cached.get());
}
// Distributed lock on the key closes the race window
try (Lock lock = distributedLock.acquire(idempotencyKey)) {
// Re-check after acquiring lock
cached = idempotencyStore.get(idempotencyKey);
if (cached.isPresent()) return ResponseEntity.ok(cached.get());
PaymentResponse response = paymentService.process(request);
idempotencyStore.save(idempotencyKey, response, Duration.ofHours(24));
return ResponseEntity.ok(response);
}
}
The lock on the idempotency key is critical — it closes the race window between the initial check and the store. Without it, two concurrent requests with the same key can both pass the check and both execute.
The Takeaway
Idempotency is a correctness requirement for any API that mutates financial state. The clients calling your API will retry. Networks will drop packets. Your job is to make that safe.
Design it in from the start. It's an order of magnitude easier than retrofitting it after your first duplicate-charge incident.
Top comments (0)