Idempotency keys: making payment APIs safe to retry
Why retries cause double charges, and how an Idempotency-Key header backed by a unique constraint prevents them in Spring Boot.
3 min read
On this page
The double-payment problem
A mobile app sends POST /payments. The server charges the customer, but the response is lost on a
weak connection. The app sees a timeout and does the reasonable thing: it retries. Without
protection, the customer is charged twice.
Retries are not a bug; on mobile networks they are guaranteed. The fix is to make the endpoint idempotent: sending the same request twice has the same effect as sending it once.
The contract
The client generates a unique key per user action (a UUID is fine) and sends it with every attempt of that action:
POST /api/payments HTTP/1.1
Idempotency-Key: 6f1c2a8e-3b7d-4c55-9a0e-2d8f4b1e7c90
Content-Type: application/json
{ "policyNumber": "P-001", "amount": 250000.00 }The server promises three things:
- The first request with a key is processed normally.
- A repeat with the same key and same body returns the stored response, without charging again.
- A repeat with the same key but a different body is rejected with
422, because it is almost certainly a client bug.
Store keys with a unique constraint
The database does the hard part. A unique constraint guarantees that only one request can claim a key, even when two retries arrive at the same moment on different instances:
CREATE TABLE idempotency_keys (
idempotency_key VARCHAR(64) NOT NULL PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL, -- IN_PROGRESS | COMPLETED
response_status INT NULL,
response_body NVARCHAR(MAX) NULL,
created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);The service flow
@Service
class IdempotentPaymentService {
private final IdempotencyKeyRepository keys;
private final PaymentService payments;
private final ObjectMapper json;
IdempotentPaymentService(IdempotencyKeyRepository keys, PaymentService payments, ObjectMapper json) {
this.keys = keys;
this.payments = payments;
this.json = json;
}
ResponseEntity<String> create(String key, CreatePaymentRequest request) throws Exception {
var hash = sha256(json.writeValueAsString(request));
// 1. Claim the key. Only one concurrent request can win this insert.
if (!keys.tryInsert(key, hash)) {
var existing = keys.findById(key).orElseThrow();
if (!existing.getRequestHash().equals(hash)) {
throw new BusinessRuleException("IDEMPOTENCY_KEY_REUSED",
"This Idempotency-Key was already used with a different request.");
}
if (existing.isInProgress()) {
// The first attempt is still running; tell the client to try again shortly.
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
// 2. Same request, already done: replay the stored response.
return ResponseEntity.status(existing.getResponseStatus()).body(existing.getResponseBody());
}
// 3. First attempt: do the work and remember the result.
var result = payments.create(request);
var body = json.writeValueAsString(result);
keys.complete(key, HttpStatus.CREATED.value(), body);
return ResponseEntity.status(HttpStatus.CREATED).body(body);
}
}tryInsert is a plain INSERT that returns false when the primary key already exists:
@Repository
class IdempotencyKeyRepository {
private final JdbcTemplate jdbc;
IdempotencyKeyRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
boolean tryInsert(String key, String hash) {
try {
jdbc.update("""
INSERT INTO idempotency_keys (idempotency_key, request_hash, status)
VALUES (?, ?, 'IN_PROGRESS')
""", key, hash);
return true;
} catch (DuplicateKeyException e) {
return false;
}
}
// findById(...) and complete(...) are ordinary SELECT and UPDATE statements.
}The controller reads the header and delegates. The @Size check on a header only runs when the
controller class is annotated with @Validated:
@PostMapping
ResponseEntity<String> create(
@RequestHeader("Idempotency-Key") @Size(min = 8, max = 64) String key,
@Valid @RequestBody CreatePaymentRequest request) throws Exception {
return idempotentPayments.create(key, request);
}Details that matter
- Failures. If the payment fails with a validation or business error, store that response too.
Replaying a
422is correct. For unexpected crashes, delete theIN_PROGRESSrow so the client can retry safely. - Calls to a payment provider. Pass the same key (or one derived from it) to the provider if it supports idempotency. Then even a crash between "charged" and "recorded" does not double-charge.
- Expiry. Keys do not need to live forever. A scheduled job that deletes rows older than a day or a week keeps the table small.
- Scope. Make the key unique per client or per user, for example by storing
user_idin the primary key, so one user can never replay another user's response.
On the client
Generate the key when the user taps Pay, not per HTTP attempt, and keep it until you get a final answer. If the app is closed mid-request, persisting the key with the pending payment lets the next launch retry with the same key instead of creating a new charge.
Summary
- Retries are normal; design write endpoints for them.
- An
Idempotency-Keyheader plus a unique constraint stops duplicates, even across instances. - Same key and same body: replay. Same key, different body: reject.
- Generate the key once per user action, not once per request.