A concrete Spring Boot implementation of idempotency keys for payment endpoints — the database-level unique constraint that makes it actually safe, the race condition a naive check-then-insert has, and how to handle a retry that arrives while the original is still in flight.
Published September 23, 2026
API Contract Design introduced idempotency keys generally; this lesson implements the mechanism specifically for a payment endpoint, where getting it wrong means a real duplicate charge.
// BROKEN — race condition between check and insert
public Payment charge(String idempotencyKey, PaymentRequest request) {
Payment existing = repository.findByIdempotencyKey(idempotencyKey);
if (existing != null) return existing; // step A: check
Payment payment = processCharge(request); // step B: process
payment.setIdempotencyKey(idempotencyKey);
return repository.save(payment); // step C: insert
}
If two requests with the same idempotency key arrive concurrently (a genuine, common scenario — a client times out and retries while the original request is still processing), both can pass step A's check simultaneously (neither has been saved yet), and both proceed to charge the card — the exact duplicate-charge bug idempotency exists to prevent. The bug isn't in the idea of checking first; it's that check-then-insert isn't atomic.
@Document(collection = "payments")
@CompoundIndex(name = "idempotency_key_unique", def = "{'idempotencyKey': 1}", unique = true)
class Payment { /* ... */ }
public Payment charge(String idempotencyKey, PaymentRequest request) {
try {
Payment placeholder = new Payment();
placeholder.setIdempotencyKey(idempotencyKey);
placeholder.setStatus(PaymentStatus.PROCESSING);
repository.insert(placeholder); // atomic — the unique index rejects a duplicate key HERE
} catch (DuplicateKeyException e) {
// another request already owns this idempotency key — poll/return its current state
return waitForResultOrReturnCurrentState(idempotencyKey);
}
// this request WON the race — it's the only one that proceeds to actually charge
Payment result = processCharge(request);
result.setIdempotencyKey(idempotencyKey);
repository.save(result);
return result;
}
The fix is moving the uniqueness guarantee to the database itself (a unique index on idempotencyKey), not application-level logic — the database's own atomicity guarantee is what actually closes the race window that check-then-insert leaves open. Whichever concurrent request's insert succeeds "wins" and proceeds to charge; every other concurrent request with the same key gets a DuplicateKeyException and must NOT charge again.
The losing request (the one that got DuplicateKeyException) has a genuine problem: the winning request might still be mid-flight (waiting on the payment processor's response), so there's no final result to return yet. Two reasonable approaches:
PROCESSING to a final state, then return that result.Either is defensible; what's NOT acceptable is silently proceeding to charge again just because the first attempt's result wasn't immediately available — that reintroduces the exact bug idempotency was implemented to prevent.
The idempotency key must be client-generated (not server-generated) — if the server generated it, a client retry after a lost response would get a NEW key from the server and the duplicate-prevention would never trigger at all. The client generates one key per logical charge attempt (typically a UUID) and reuses that exact same key on any retry of that same attempt. As covered in API Contract Design, keys are retained for a bounded window (matching realistic retry timeframes) rather than forever.
Q: Why insert a PROCESSING placeholder before calling the payment processor, rather than after? A: Inserting first is what claims the idempotency key atomically before any external call happens — if the charge were attempted first and only saved afterward, two concurrent requests could both pass an early check, both call the processor, and both succeed at the processor level before either save happens, defeating the entire purpose.
Q: What if the process crashes after successfully charging the processor but before updating the Payment record from PROCESSING to CAPTURED? A: This is precisely the scenario Payment — Failure Handling & Reconciliation exists to catch — the local record is stuck in an ambiguous PROCESSING state while the processor's own records show a completed charge; reconciliation (comparing local records against the processor's transaction log) is what detects and resolves this specific gap.
Q: Does every payment-related endpoint need an idempotency key, or just the charge endpoint? A: Any endpoint with a real external side effect that a client might retry — charge, refund, and capture all qualify; a pure read endpoint (checking payment status) doesn't need one, since reads are naturally idempotent and retrying them causes no harm.
Q: Could the database-level unique constraint approach have performance implications at very high payment volume? A: A unique index lookup/insert is a fast, well-optimized database operation and isn't a meaningful bottleneck at realistic payment volumes — the far bigger latency cost in this flow is the synchronous call to the external payment processor itself, not the local idempotency-key check.