Why this matters
- Payment APIs without idempotency double-charge when mobile clients retry POST on gateway timeout.
- At-least-once messaging and HTTP retries both require deduplication at the server — the key is the contract.
- ShardPay requires
Idempotency-Keyon all POST /transfers; keys expire after 24h with stored response hash. - Interviewers walk through timeout-after-commit scenarios and ask how you detect conflicting payloads on the same key.
Server-side deduplication flow
First request with key idem-7f3a executes the transfer, persists ledger rows, and stores (key → response body hash, transferId, status) in Postgres. Retry with the same key and identical body returns the stored response without re-debiting. Same key with different amount or accounts returns 409 Conflict — the client reused a key incorrectly.
ShardPay processes idempotency check inside the same DB transaction as the ledger write when possible, so crash between transfer and store cannot create a committed transfer with no dedup record.
Walkthrough: mobile retry after 504
User submits $500 transfer; gateway times out after ledger commit but before response reaches phone. App retries with same Idempotency-Key. ShardPay's store hits on key, returns 201 with original transferId and body — user sees success, account debited once. Support logs show two HTTP requests, one ledger mutation.
Key points
- Idempotency-Key header — client-generated unique token (UUID recommended) sent on mutating requests. ShardPay rejects POST /transfers without the header using
428 Precondition Requiredso no production client ships without key generation. - Store-and-return — persist successful response metadata keyed by idempotency token before returning to client. ShardPay stores JSON response,
transferId, and SHA-256 of canonical request body for conflict detection. - In-flight deduplication — concurrent duplicate requests with the same key must not double-execute. ShardPay uses
INSERT ... ON CONFLICT DO NOTHINGon the idempotency row as a claim lock; second request waits or polls until first completes. - Key TTL — expire old keys to bound table size (ShardPay: 24 hours). After TTL a retry with the same key is treated as a new request — document TTL in API docs so clients generate fresh keys for new business operations.
- Conflict detection — same key plus different payload hash returns
409instead of silently returning the first response. Prevents a bug where client reuses key for two different payees and believes the second succeeded.
Idempotency beyond HTTP
Kafka consumers at ShardPay dedupe by eventId header in a processed-events table — same semantics as HTTP keys. Database upserts use natural keys: UPDATE balance SET ... WHERE transfer_id = :id applied twice is safe when transfer_id is unique.
Idempotency is not automatic — INSERT without unique constraint duplicates rows. Every mutating code path needs an explicit key: HTTP header, event ID, or primary key constraint.
Walkthrough: concurrent duplicate requests
Two tabs submit the same transfer with identical idempotency key within 50ms. Both hit ShardPay; first transaction inserts idempotency claim and executes ledger. Second blocks on claim row lock, then reads completed response and returns without second debit. User sees one transfer in history.
// ShardPay idempotency store — transactional dedup (Java 17)
@Transactional
public TransferResult transfer(TransferRequest req, String idempotencyKey) {
byte[] payloadHash = hash(canonicalJson(req));
Optional<IdempotencyRecord> existing = store.find(idempotencyKey);
if (existing.isPresent()) {
IdempotencyRecord record = existing.get();
if (!Arrays.equals(record.payloadHash(), payloadHash)) {
throw new IdempotencyConflictException(idempotencyKey);
}
return record.response();
}
store.insertClaim(idempotencyKey, payloadHash); // unique constraint — second caller waits
TransferResult result = ledger.execute(req);
store.complete(idempotencyKey, result, payloadHash, Duration.ofHours(24));
return result;
}
// Kafka consumer — same pattern with eventId
public void onSettlementEvent(ConsumerRecord<String, SettlementEvent> record) {
String eventId = header(record, "eventId");
if (processedEvents.markIfAbsent(eventId, Duration.ofDays(7))) {
settlementService.apply(record.value());
}
}
Designing keys with retries and sagas
Saga compensating steps need their own idempotency keys per step — not the same key as the forward transfer. ShardPay uses idem-{clientRequestId}-capture and idem-{clientRequestId}-refund so partial saga retries do not collide.
Keys should be generated once per user intent at the client, not per HTTP attempt. Retries reuse; new user taps "pay again" get a new key.
Quick recall
Everything you need if you only revisit this box.
- Idempotency keys make HTTP and message retries safe — store response before acknowledging client.
- Detect conflicts: same key, different payload → 409, not silent wrong success.
- Use claim locks for concurrent duplicates; TTL bounds storage; sagas need per-step keys.
Test yourself
Answer these before moving on — recall is what makes it stick.