| |

REST API 19 ๐ŸŒ Idempotency in Write Operations (Idempotency Keys)

โฑ๏ธ Estimated read: 17 min | |

A client sends POST /payments, the connection times out before the response arrives, and the client retries. Without an idempotency mechanism, the server processes both requests and charges the customer twice. The timeout does not mean the request failedโ€”it means the outcome is unknown. The request may have never reached the server, it may have failed mid-processing, or it may have succeeded with the response lost in transit. The third case is the dangerous one: the operation completed, and the retry duplicates it . Idempotency keys exist to make retries safe by ensuring that a request with the same key produces the same outcome regardless of how many times it arrives.

The Idempotency-Key header has moved from a Stripe-specific convention to an IETF draft standard. The draft specifies that the header carries a client-generated unique value, that the server uses it to recognize retries of the same request, and that a key must not be reused with a different request payload . Stripe, Adyen, Dwolla, Interledger, WorldPay, Yandex, and others already implement the header, and the IETF draft records their implementations as evidence of the pattern’s maturity . The core guarantee is outcome-based, not receipt-based: the server must return the original response for a repeated key, not merely acknowledge that the key was seen before.

This chapter covers the key generation rules, the atomic claim that prevents concurrent duplicates, the request fingerprint that detects key reuse with different payloads, the storage and expiry decisions that trade safety against table growth, and the replay semantics that make retries transparent to the client. It assumes familiarity with HTTP write operations and the distinction between at-least-once delivery and exactly-once effects.

Key point: An idempotency key is a promise about outcomes, not about request receipt. A check-then-process sequence has a gap where two concurrent requests both see “key not found” and both proceed. The fix is an atomic claim on the keyโ€”INSERT ... ON CONFLICT DO NOTHINGโ€”that exactly one request can win, with the loser either waiting for the winner’s result or returning a conflict response.


Why idempotency keys exist as a protocol concern

Timeouts are ambiguous. A client that sends a request and receives no response within its timeout window cannot know whether the server processed the request. The network may have dropped the request before it arrived, the server may have crashed mid-processing, or the server may have completed the operation and the response may have been lost. Retrying without an idempotency key turns the third case into a duplicate operation. The IETF draft describes this as the core motivation: “Is the resource actually created? Did the timeout occur during sending of the request, or when receiving of the response? Can the client safely retry the request, or does it need to figure out what happened in the first place?” .

Non-idempotent methods need external guarantees. POST is not idempotent by HTTP definition. Sending it twice may create two resources. PUT and DELETE are idempotent by definition, which is why Stripe recommends not sending idempotency keys with GET and DELETEโ€”these methods are already safe to retry . The idempotency key makes POST and PATCH fault-tolerant by giving the server a way to recognize that two requests are the same logical operation.

The key is bound to a business intent, not a retry attempt. The most common implementation error is generating a new key inside the retry loop. Each retry then presents a new identity, and the server has no way to know it is the same intent. The key must be generated once per business operationโ€”one purchase, one payment, one orderโ€”and reused across all retries of that operation . Stripe’s documentation recommends deriving the key from business intent: create-pi-${orderId}-${attemptId}, not a random UUID minted per HTTP call .

Concurrency is the harder problem. Sequential retries are the easy case: request one times out, request two arrives seconds later, and the server can check whether the key exists. Concurrent duplicatesโ€”both arriving within millisecondsโ€”are what most implementations get wrong. Both requests check the key store, both find nothing, and both proceed to execute the operation . The fix is an atomic claim: a database INSERT ... ON CONFLICT DO NOTHING or a Redis SET NX that exactly one request can win. The loser must not proceed; it must wait for the winner’s result or return a 409 Conflict indicating that a request with that key is already being processed .

The trade-off. Idempotency adds a storage dependency to every write endpoint. The key store must be durable enough to survive the retry window, fast enough not to bottleneck writes, and cleaned up so it does not grow without bound. The Idempotency-Key header is not a substitute for database constraintsโ€”it is a protocol layer that lets clients participate in deduplication. The server still needs its own atomicity guarantees.


a. Key generation and the fingerprint

The client generates the key. The IETF draft recommends UUIDs or a similar random identifier with enough entropy to avoid collisions . Stripe suggests V4 UUIDs or another random string up to 255 characters and explicitly warns against using sensitive data like email addresses or personal identifiers as keys . A deterministic key derived from business intentโ€”payment:${orderId}:${attemptId}โ€”is also valid and has the advantage of being stable across client restarts, but it must be unique per logical operation and must not collide with a different operation.

The server must bind the key to the request payload. Without a fingerprint, a client bug that reuses the same key for two genuinely different requestsโ€”a different amount, a different recipientโ€”would receive the first request’s result silently. The IETF draft defines an optional idempotency fingerprint: a checksum of the entire payload, a checksum of selected fields, or a request digest . Stripe compares incoming parameters to the original request and errors if they differ . The fingerprint should be stored alongside the key and compared on every reuse.

POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json

{"amount": 5000, "currency": "usd", "recipient": "acct_123"}

The server records the key, the fingerprint of {"amount":5000,"currency":"usd","recipient":"acct_123"}, and the response. A retry with the same key and the same body returns the recorded response. A request with the same key and a different bodyโ€”{"amount":9999,...}โ€”is rejected with 422 Unprocessable Entity or a 400 error, depending on the server’s documentation .


b. The atomic claim and the state machine

A naive implementation checks whether the key exists, and if not, processes the request and stores the result. The gap between the check and the store is where concurrent duplicates slip through. Both requests check, both find nothing, both process, and one of them overwrites the other’s resultโ€”or both charge the card .

The correct pattern is a two-phase state machine with an atomic claim. The first phase inserts a row with status processing using an atomic operation that fails if the key already exists. The request that wins the insert owns the operation and proceeds to execute the business logic. The request that loses the insert reads the existing row and acts according to its status: if completed, it returns the stored response; if processing, it returns 409 Conflict or waits for completion; if failed, it may retry with the same key .

-- Atomic claim: exactly one request can insert this row.
INSERT INTO idempotency_keys (key, request_hash, status, created_at)
VALUES ($1, $2, 'processing', now())
ON CONFLICT (key) DO NOTHING
RETURNING key;

If the insert returns a row, the request owns the operation. It executes the business logic, then updates the row to completed with the response. If the insert returns no row, another request has already claimed the key. The losing request queries the existing row and handles it according to its status .

The IETF draft specifies 409 Conflict for the case where the original request is still being processed: “If the request is retried, while the original request is still being processed, the resource SHOULD reply with an HTTP 409 status code” . The error body should explain that a request with that key is outstanding. Some implementations instead wait for the original request to complete and return its result, which is more transparent to the client but requires the loser to hold a connection open or poll.

The state machine must handle crashes. If the server crashes after claiming the key but before completing the operation, the row remains in processing forever unless a staleness check or TTL releases it. Stripe’s implementation saves results only after endpoint execution begins, and concurrent conflicts do not save a result because no endpoint initiated the execution . A robust implementation includes a lock_ttl or processing timeout: a row in processing older than the TTL is treated as abandoned and eligible for a new claim, with the understanding that the original operation’s outcome may be unknown.


c. Storage, expiry, and replay semantics

The storage backend determines durability and latency. Redis is fast and suitable for retry windows measured in hours to days, but it is volatileโ€”if Redis crashes without persistence, keys are lost and retries become new operations . A database unique constraint is durable and provides an audit trail, at the cost of a write per request. Stripe keeps keys for at least 24 hours and automatically prunes older entries; a key reused after pruning is treated as a new request . The expiry window must be at least as long as the client’s retry window. If the client retries after 25 hours and the key was pruned at 24, the retry duplicates the operation.

-- Schema for database-backed idempotency.
CREATE TABLE idempotency_keys (
    key VARCHAR(255) PRIMARY KEY,
    request_hash VARCHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'processing',
    response JSONB,
    created_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP DEFAULT NOW() + INTERVAL '24 hours'
);

The replay must return the original response, byte for byte where practical, including the original status code. A client retrying a request expects the same acknowledgement it would have received the first time. Returning a different status or a different body shape on replay produces client-side bugs that only appear during retries, which makes them rare and hard to reproduce . Stripe’s documentation is explicit: “Subsequent requests with the same key return the same result, including 500 errors” . The exception is the 409 Conflict for an in-flight request, which is not a replay of a completed operation.

The server must also document which error scenarios are retryable. The IETF draft distinguishes between errors that mean the request was processed and failed (which should be replayed) and errors that mean the request was rejected before processing (which the client must correct before retrying) . A 400 Bad Request for malformed input is not idempotent in the sense that retrying with the same malformed input will produce the same rejection; the client must fix the request. A 500 Internal Server Error that occurred after the operation started is replayed, because the operation may have partially completed.


Complete Example Session

# ============================================
# PART 1: INITIAL REQUEST
# ============================================
POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json

{"amount": 5000, "currency": "usd", "recipient": "acct_123"}

# Server:
# 1. Compute fingerprint of payload.
# 2. INSERT (key, fingerprint, 'processing') ON CONFLICT DO NOTHING.
# 3. Insert succeeds โ†’ own the operation.
# 4. Execute payment.
# 5. UPDATE status='completed', response=... WHERE key=...

HTTP/1.1 201 Created
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json

{"id": "pay_abc", "status": "succeeded", "amount": 5000}

# ============================================
# PART 2: RETRY WITH SAME KEY AND BODY
# ============================================
POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json

{"amount": 5000, "currency": "usd", "recipient": "acct_123"}

# Server:
# 1. Fingerprint matches.
# 2. INSERT fails (key exists).
# 3. SELECT status='completed', response.
# 4. Replay response.

HTTP/1.1 201 Created
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json

{"id": "pay_abc", "status": "succeeded", "amount": 5000}

# ============================================
# PART 3: CONCURRENT DUPLICATE
# ============================================
# Request A and Request B arrive within 10ms.
# A: INSERT succeeds โ†’ status='processing'.
# B: INSERT fails (key exists).
# B: SELECT status='processing'.
# B returns 409 Conflict, not 200.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.example.com/idempotency",
  "title": "A request is outstanding for this Idempotency-Key",
  "detail": "A request with the same Idempotency-Key is being processed or is outstanding."
}

# B should retry after a delay, or poll the original response.

# ============================================
# PART 4: KEY REUSE WITH DIFFERENT BODY
# ============================================
POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json

{"amount": 9999, "currency": "usd", "recipient": "acct_123"}

# Server:
# 1. Fingerprint of new body โ‰  stored fingerprint.
# 2. Reject. Do not replay the 5000 response.

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/idempotency",
  "title": "Idempotency-Key reused with different payload",
  "detail": "This key was already used for a request with a different body."
}

# ============================================
# PART 5: THE TWO-PHASE STATE MACHINE
# ============================================
# processing โ†’ completed (success)
# processing โ†’ failed (error; client may retry)
# processing โ†’ abandoned (TTL expired; new claim possible)
#
# 'failed' should replay the error on retry.
# 'abandoned' is treated as a new request.

# ============================================
# PART 6: STORAGE CHOICE
# ============================================
# Redis:
#   Fast (~1ms), TTL support, volatile.
#   Best for short retry windows (hours to days).
#
# Database:
#   Durable, auditable, slower (~5-20ms).
#   Best for payments requiring audit trail.
#
# Hybrid:
#   Redis as first check, DB as authoritative store.

# ============================================
# PART 7: ERROR SCENARIOS (IETF DRAFT)
# ============================================
# 400/422: key reused with different payload.
#   Client must correct before retrying.
#
# 409 Conflict: original request in flight.
#   Client retries later; no correction needed.
#
# 500: original request failed after starting.
#   Replay the 500 on retry.

# ============================================
# PART 8: KEY GENERATION RULES
# ============================================
# โœ… V4 UUID, 255 chars max.
# โœ… Deterministic from business intent:
#    "payment:{orderId}:{attemptId}"
# โŒ Random UUID generated inside retry loop.
# โŒ Timestamp alone (collisions).
# โŒ Email/phone as key (PII in logs).

# ============================================
# PART 9: EXPIRY
# ============================================
# Stripe: 24 hours, then pruned.
# Key reused after pruning โ†’ new request.
# Window must exceed client retry window.
# Document the window in API docs.

# ============================================
# PART 10: THE COMPLETE FLOW
# ============================================
# 1. Client generates key once per intent.
# 2. Sends Idempotency-Key header on POST/PATCH.
# 3. Server atomically claims key.
# 4. Winner processes; loser returns 409 or replays.
# 5. Winner stores response + status.
# 6. Retry with same key + body โ†’ replay.
# 7. Retry with same key + different body โ†’ 422.
# 8. Key expires after documented TTL.

Quick Reference

Key Lifecycle

StepActionFailure Mode
GenerateClient creates unique keyRandom per retry โ†’ duplicates
SendIdempotency-Key headerMissing โ†’ no dedup
ClaimINSERT ... ON CONFLICT DO NOTHINGCheck-then-insert โ†’ race
ProcessExecute business logicCrash โ†’ stuck processing
StoreSave response + statusLost response โ†’ retry duplicates
ReplayReturn stored responseDifferent body โ†’ 422

Error Scenarios

StatusMeaningClient Action
422Key reused with different bodyCorrect the request
409Original request in flightRetry later
500 (replayed)Original failed after startingSame error on retry
201 (replayed)Original succeededSame success response

Storage Comparison

BackendLatencyDurabilityBest For
Redis~1msVolatileShort retry windows
Database~5โ€“20msDurableAudit trail, payments
HybridMixedBothHigh-volume + compliance

Key Generation Rules

RuleCorrectIncorrect
Unique per intentpayment:order:123:attempt:1uuid4() per retry
No PIIUUID or business IDuser@example.com
Lengthโ‰ค255 charsArbitrary length
EntropyV4 UUID or ULIDTimestamp alone

Best Practices

โœ… Do This:

# Send Idempotency-Key on POST and PATCH.                    // โœ…
POST /payments
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

# Generate once per business intent, reuse on retry.         // โœ…
key = f"payment:{order_id}:{attempt_id}"

# Atomically claim the key before processing.                // โœ…
INSERT ... ON CONFLICT (key) DO NOTHING RETURNING key;

# Store the request fingerprint alongside the key.           // โœ…
request_hash = sha256(json.dumps(payload))

# Replay the original response on retry.                     // โœ…
return stored_status, stored_body

โŒ Don’t Do This:

# Generate a new key inside the retry loop.                  // โŒ
for attempt in range(3):
    key = uuid4()  # New identity each time; no dedup.

# Check-then-process without atomic claim.                   // โŒ
if not key_exists(key):  # Race window.
    process()            # Both concurrent requests pass.

# Replay a response for a different request body.            // โŒ
# 5000 response returned for a 9999 request โ†’ correctness bug.

# Use PII as the key.                                        // โŒ
Idempotency-Key: "user@example.com"  # Leaks in logs.

Common Pitfalls

PitfallWhy It HappensFix
Double charge on retryNew key generated per retryGenerate once per intent
Concurrent duplicatesCheck-then-insert raceAtomic INSERT ... ON CONFLICT
Wrong response on replayNo fingerprint comparisonStore and compare request hash
Key store grows unboundedNo TTLExpire after documented window
Stuck processing foreverCrash without cleanuplock_ttl or staleness check
PII in logsEmail/phone as keyUse UUID or business ID

Real-World Examples

1. Stripe-Style Header

Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

2. Atomic Claim (PostgreSQL)

INSERT INTO idempotency_keys (key, status) VALUES ($1, 'processing')
ON CONFLICT (key) DO NOTHING RETURNING key;

3. Request Fingerprint

fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()

4. 409 for In-Flight

HTTP/1.1 409 Conflict
{"title": "A request is outstanding for this Idempotency-Key"}

5. 422 for Key Reuse

HTTP/1.1 422 Unprocessable Entity
{"title": "Idempotency-Key reused with different payload"}

6. Deterministic Key

key = f"payment:{order_id}:{attempt_id}"

7. Two-Phase State

UPDATE idempotency_keys SET status='completed', response=$2 WHERE key=$1;

8. Redis TTL

SET idempotency:key "response" EX 86400

9. Database Schema

CREATE TABLE idempotency_keys (
  key VARCHAR(255) PRIMARY KEY,
  request_hash VARCHAR(64) NOT NULL,
  status VARCHAR(20) DEFAULT 'processing',
  response JSONB
);

10. Retry After 409

if response.status == 409:
    time.sleep(1)
    retry_same_key()

Visual

The Ambiguous Timeout

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  CLIENT TIMEOUT                              โ”‚
โ”‚                                              โ”‚
โ”‚  Client sends POST /payments                 โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ”œโ”€โ”€ Request never arrived โ†’ safe retry โ”‚
โ”‚       โ”œโ”€โ”€ Server crashed โ†’ safe retry        โ”‚
โ”‚       โ””โ”€โ”€ Server succeeded, response lost    โ”‚
โ”‚              โ”‚                               โ”‚
โ”‚              โ–ผ                               โ”‚
โ”‚         Retry without key โ†’ DOUBLE CHARGE    โ”‚
โ”‚                                              โ”‚
โ”‚  The timeout does not tell the client        โ”‚
โ”‚  which case occurred.                        โ”‚
โ”‚                                              โ”‚
โ”‚  Idempotency key makes all three cases safe. โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Atomic Claim vs Check-Then-Process

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  RACE CONDITION                              โ”‚
โ”‚                                              โ”‚
โ”‚  Check-then-process:                         โ”‚
โ”‚    A: key exists? no โ†’ process               โ”‚
โ”‚    B: key exists? no โ†’ process               โ”‚
โ”‚    Both process. Duplicate.                  โ”‚
โ”‚                                              โ”‚
โ”‚  Atomic claim:                               โ”‚
โ”‚    A: INSERT key โ†’ succeeds โ†’ process        โ”‚
โ”‚    B: INSERT key โ†’ fails โ†’ read status       โ”‚
โ”‚    Exactly one processes.                    โ”‚
โ”‚                                              โ”‚
โ”‚  The INSERT is the lock.                     โ”‚
โ”‚  ON CONFLICT DO NOTHING is the release.      โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

State Machine

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  IDEMPOTENCY STATES                          โ”‚
โ”‚                                              โ”‚
โ”‚  (new key)                                   โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ–ผ                                      โ”‚
โ”‚  processing โ”€โ”€โ”€โ”€ success โ”€โ”€โ”€โ”€โ–บ completed     โ”‚
โ”‚       โ”‚                         โ”‚            โ”‚
โ”‚       โ”‚                         โ””โ”€โ”€ replay   โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ”œโ”€โ”€โ”€โ”€ error โ”€โ”€โ”€โ”€โ–บ failed               โ”‚
โ”‚       โ”‚                  โ”‚                   โ”‚
โ”‚       โ”‚                  โ””โ”€โ”€ replay error    โ”‚
โ”‚       โ”‚                                      โ”‚
โ”‚       โ””โ”€โ”€โ”€โ”€ TTL โ”€โ”€โ”€โ”€โ–บ abandoned              โ”‚
โ”‚                         โ”‚                    โ”‚
โ”‚                         โ””โ”€โ”€ new claim allowedโ”‚
โ”‚                                              โ”‚
โ”‚  Concurrent duplicate on 'processing' โ†’ 409  โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Fingerprint Mismatch

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  KEY REUSE WITH DIFFERENT BODY               โ”‚
โ”‚                                              โ”‚
โ”‚  Stored:                                     โ”‚
โ”‚    key: abc123                               โ”‚
โ”‚    hash: sha256({"amount":5000})             โ”‚
โ”‚    response: 201 pay_abc                     โ”‚
โ”‚                                              โ”‚
โ”‚  Retry:                                      โ”‚
โ”‚    key: abc123                               โ”‚
โ”‚    hash: sha256({"amount":9999})  โ† differs  โ”‚
โ”‚                                              โ”‚
โ”‚  Action: 422, not replay.                    โ”‚
โ”‚                                              โ”‚
โ”‚  Without fingerprint: client receives        โ”‚
โ”‚  pay_abc for a 9999 request โ†’ silent         โ”‚
โ”‚  correctness failure.                        โ”‚
โ”‚                                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
HeaderIdempotency-Key (IETF draft)
Key generationClient, V4 UUID or deterministic
Key scopeOne per business intent, reused on retry
Atomic claimINSERT ... ON CONFLICT DO NOTHING
FingerprintHash of request body, compared on reuse
In-flight duplicate409 Conflict
Key reuse, different body422 (or 400)
ReplayOriginal status + body, byte for byte
TTLโ‰ฅ client retry window (Stripe: 24h)
StorageRedis (fast) or database (durable)

Key takeaways:

  • Timeouts are ambiguous; idempotency keys make retries safe. The timeout does not reveal whether the request was never received, failed mid-processing, or succeeded with a lost response. The key lets the server recognize a retry and return the original outcome .
  • The key must be generated once per business intent, not per retry. A key created inside the retry loop presents a new identity on every attempt, defeating deduplication entirely .
  • The atomic claim is the concurrency fix. Check-then-process has a race window where both concurrent requests see no key and both proceed. INSERT ... ON CONFLICT DO NOTHING ensures exactly one request owns the operation .
  • The fingerprint prevents silent correctness failures. Without it, a reused key with a different payload returns the first request’s response for a different operation. Store a hash and reject mismatches .
  • Replay the original response, including errors. Stripe returns the same status and body for a repeated key, including 500 errors. A different response on replay produces client bugs that only appear during retries .
  • The TTL must exceed the client retry window. A key pruned before the client’s last retry becomes a new request. Stripe’s 24-hour window is a reasonable default for most flows, but the server must document it .
  • Use the key only on non-idempotent methods. POST and PATCH need it. GET and DELETE are idempotent by definition, and sending the header has no effect .

Remember: An idempotency key is a protocol for making retries safe, not a database uniqueness check. The client generates it once per business intent and reuses it across every retry. The server atomically claims it, executes the operation, stores the response, and replays that response for any subsequent request with the same key. The request fingerprint ensures the key is bound to the same payload, so a reused key with different data is rejected rather than silently replaying the wrong result. The state machine handles concurrency: one request wins the claim and processes, while duplicates receive a conflict or a replay. The storage choiceโ€”Redis for speed, database for durabilityโ€”determines the failure modes, and the TTL determines how long retries remain safe. Get the atomic claim right, and the double charge disappears.


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!