A client that never gets your response will retry — and without protection, a retried "create payment" can charge twice. Idempotency keys let a client safely repeat a request, guaranteeing the operation happens at most once.
Accept an idempotency key
The client generates a unique key per logical operation and sends it as a header. The same key means "this is the same request."
@PostMapping("/api/payments")
public ResponseEntity<Receipt> pay(
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody PaymentRequest request) {
Receipt receipt = payments.process(key, request);
return ResponseEntity.ok(receipt);
}Store the result against the key
On first sight of a key, do the work and persist the result. On a repeat, return the stored result instead of doing the work again.
@Transactional
public Receipt process(String key, PaymentRequest request) {
return idempotencyRepo.findByKey(key)
.map(record -> deserialize(record.getResult()))
.orElseGet(() -> {
Receipt receipt = gateway.charge(request);
idempotencyRepo.save(new IdempotencyRecord(key, serialize(receipt)));
return receipt;
});
}Guard the race
Two retries can arrive at once. A unique constraint on the key column turns the race into a catchable database error instead of a double charge.
CREATE TABLE idempotency_record (
key VARCHAR(64) PRIMARY KEY,
result JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);Takeaways
- Require an
Idempotency-Keyheader on unsafe, non-idempotent operations. - Persist the result so repeats return the original response.
- A unique constraint closes the concurrent-retry race safely.
Expire old keys on a schedule — they only need to live as long as a client might reasonably retry.