Idempotency Keys

June 30, 2026

Table of Contents

Overview
What Idempotency Means
Why Idempotency Keys Matter
The Basic Idempotency Key Flow
Where Idempotency Keys Are Most Useful
What Should Be Stored for an Idempotency Key?
Request Fingerprinting
Concurrency and Race Conditions
Response Replay: Return the Same Result
TTL and Cleanup Strategy
Idempotency Keys vs. Natural Idempotency
Database Design Example
Example Implementation
Common Pitfalls (and Fixes)
Conclusion
Key Takeaways

Overview
^

Idempotency keys are a pattern for making unsafe operations safer to retry. They are most commonly used with APIs where a client might send the same request more than once because of a timeout, network failure, browser retry, mobile reconnection, or worker crash.

The classic example is a payment API:

Client sends "charge customer $50"
Network times out
Client does not know whether the charge succeeded
Client retries

Without idempotency, the user might be charged twice. With an idempotency key, the server can recognize that the retry is the same logical operation and return the original result instead of performing the operation again.

What Idempotency Means
^

An operation is idempotent if doing it multiple times has the same effect as doing it once.

For example:

PUT /users/123/email

with body:

{
  "email": "sam@example.com"
}

This is naturally idempotent. Setting the email to the same value repeatedly leaves the system in the same state.

But this is not naturally idempotent:

POST /payments

with body:

{
  "amount": 5000,
  "currency": "USD"
}

Calling it twice might create two payments.

An idempotency key gives the server a way to say:

“I have already processed this logical operation. I should return the same answer, not perform it again.”

Why Idempotency Keys Matter
^

Distributed systems fail in awkward ways.

A client may not know whether a request succeeded because:

  • the server completed the operation but the response was lost
  • the server timed out after partially doing the work
  • the client lost network connectivity
  • a retry job crashed and restarted
  • a load balancer closed the connection
  • a mobile app retried after reconnecting

Idempotency keys let you make retries safe.

They are especially useful for operations that create or mutate state:

  • payments
  • order creation
  • subscription changes
  • account provisioning
  • email sending
  • inventory reservations
  • job enqueueing
  • webhook processing

The Basic Idempotency Key Flow
^

A typical flow looks like this:

1. Client generates a unique idempotency key
2. Client sends the key with the request
3. Server atomically checks/reserves the key
4. If this request owns the reservation, server processes the operation
5. Server stores the final response
6. If the same key is retried, server returns the stored response

Example request:

POST /orders
Idempotency-Key: 7e3f3d5c-1d47-4f73-a417-8e25e96a1b7a
Content-Type: application/json

{
  "sku": "book_123",
  "quantity": 1
}

The key should generally be generated by the client, because the client is the one trying to say:

“These retry attempts all belong to the same logical operation.”

Where Idempotency Keys Are Most Useful
^

Use CaseWhy Idempotency HelpsNuance
PaymentsPrevents duplicate charges during retriesMust store enough response state to replay the original result
Order creationPrevents duplicate orders from browser/mobile retriesKey should usually be scoped to user or account
Webhook processingHandles providers that deliver the same event multiple timesThe provider event ID often acts as the idempotency key
Job enqueueingPrevents duplicate background jobsMay need a unique job key in the queue or database
Email sendingPrevents duplicate emails after worker crashesSome systems prefer dedupe windows rather than permanent dedupe

What Should Be Stored for an Idempotency Key?
^

A robust idempotency record usually stores:

  • idempotency key

  • user/account/tenant scope

  • request method and route

  • request fingerprint or hash

  • status:

    • processing
    • completed
    • failed
  • response status code

  • response body or resource reference

  • creation time

  • expiry time

A minimal record might look like this:

{
  "key": "7e3f3d5c-1d47-4f73-a417-8e25e96a1b7a",
  "scope": "user_123",
  "request_hash": "sha256:...",
  "status": "completed",
  "response_code": 201,
  "response_body": {
    "order_id": "ord_456"
  },
  "expires_at": "2025-12-31T12:00:00Z"
}

The key nuance is that the server should not merely remember “this key happened.” It should remember enough to answer:

“Was this retry identical, and what response should I return?”

Request Fingerprinting
^

A common bug is letting the same idempotency key be reused with different request bodies.

For example:

First request:
Idempotency-Key: abc
amount: $50

Second request:
Idempotency-Key: abc
amount: $500

That should not silently return the first response. It should usually return a conflict-style error because the key is being reused for a different operation.

A request fingerprint helps detect this.

import hashlib
import json

def stable_json_hash(payload: dict) -> str:
    normalized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()

You can include:

  • HTTP method
  • route or operation name
  • normalized request body
  • authenticated user or tenant scope

Do not include unstable values like:

  • timestamps generated by the server
  • tracing headers
  • random request IDs
  • header order

Concurrency and Race Conditions
^

The hardest part of idempotency is not the happy path. It is the race condition where two identical requests arrive at the same time.

Bad implementation:

1. Request A checks key: missing
2. Request B checks key: missing
3. Request A creates payment
4. Request B creates payment
5. Duplicate side effect

The fix is to atomically reserve the key before performing the side effect.

In SQL, that usually means:

INSERT INTO idempotency_keys (scope, key, request_hash, status)
VALUES ($1, $2, $3, 'processing')
ON CONFLICT (scope, key) DO NOTHING;

Then:

  • if insert succeeds, this request owns processing
  • if insert fails, another request already owns or completed it
  • the retry should wait, return conflict, or replay the completed response
Handling Duplicate In-Flight RequestProsCons
Return 409 ConflictSimple; avoids blocking server resourcesClient must retry later
Wait for first request to finishBetter client experienceConsumes server resources; needs timeout handling
Return 202 AcceptedGood for async jobsClient needs polling or callback flow

Response Replay: Return the Same Result
^

A strong idempotency implementation does not just prevent duplicate writes. It returns the same response for the same logical operation.

Example:

First request:
POST /orders
Idempotency-Key: abc

Response:
201 Created
{ "order_id": "ord_123" }

Retry:
POST /orders
Idempotency-Key: abc

Response:
201 Created
{ "order_id": "ord_123" }

This matters because clients may have lost the original response. The retry is not just asking “please do it again.” It is asking:

“What happened when I asked you to do this?”

TTL and Cleanup Strategy
^

Idempotency keys should not usually live forever.

A TTL controls how long the server remembers the operation.

Common TTL choices:

  • 15 minutes for low-risk form submissions
  • 24 hours for payments or order creation
  • several days for webhook/event dedupe
  • longer if business requirements demand it
TTL StrategyProsCons
Short TTLLower storage cost; less retained metadataLate retries may duplicate work after expiry
Long TTLBetter protection for delayed retries and webhooksMore storage; more cleanup work; more retained request metadata
Permanent dedupeStrongest duplicate preventionCan become expensive and may conflict with privacy/data-retention goals

A good cleanup process should remove expired keys safely without deleting records still needed for active retries.

Idempotency Keys vs. Natural Idempotency
^

Some operations are naturally idempotent.

PUT /users/123/profile

If the request says “set profile to this exact value,” repeating it does not create a duplicate resource.

Other operations are not naturally idempotent:

POST /orders
POST /payments
POST /emails/send

Those often benefit from explicit idempotency keys.

ApproachBest ForTrade-off
Natural idempotencySet/update operations with stable resource IDsRequires API design that makes repeated calls safe by construction
Idempotency keysCreate/side-effect operations where retries may duplicate workRequires storage, TTLs, request hashing, and concurrency handling

Database Design Example
^

A simple PostgreSQL table might look like this:

CREATE TABLE idempotency_keys (
    scope TEXT NOT NULL,
    key TEXT NOT NULL,
    request_hash TEXT NOT NULL,
    status TEXT NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
    response_code INTEGER,
    response_body JSONB,
    resource_type TEXT,
    resource_id TEXT,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (scope, key)
);

Why (scope, key)?

Because idempotency keys should usually be scoped. Two different users might accidentally generate the same UUID-like key. Scoping prevents one user’s key from affecting another user’s request.

Common scopes:

  • user ID
  • tenant ID
  • API key ID
  • merchant/account ID

Example Implementation
^

Here is a simplified Python-style flow:

def create_order(request, db):
    scope = request.user.id
    key = request.headers.get("Idempotency-Key")

    if not key:
        raise BadRequest("Missing Idempotency-Key")

    request_hash = stable_json_hash({
        "method": "POST",
        "route": "/orders",
        "body": request.json,
    })

    record = db.get_idempotency_record(scope, key)

    if record:
        if record.request_hash != request_hash:
            raise Conflict("Idempotency-Key reused with different request")

        if record.status == "completed":
            return Response(record.response_body, status=record.response_code)

        if record.status == "processing":
            raise Conflict("Request already in progress")

        if record.status == "failed":
            raise Conflict("Previous attempt failed; retry requires recovery or a new idempotency key")

    inserted = db.try_insert_idempotency_record(
        scope=scope,
        key=key,
        request_hash=request_hash,
        status="processing",
    )

    if not inserted:
        raise Conflict("Request already in progress")

    try:
        order = db.create_order(user_id=scope, body=request.json)

        response_body = {"order_id": order.id}
        db.mark_idempotency_completed(
            scope=scope,
            key=key,
            response_code=201,
            response_body=response_body,
        )

        return Response(response_body, status=201)

    except Exception:
        db.mark_idempotency_failed(scope=scope, key=key)
        raise

A failed idempotency record needs explicit semantics. Some APIs replay the original failure response. Others require manual recovery, allow retry only if the side effect is known not to have happened, or store a resource reference so the operation can be completed safely. Do not treat failed as the same thing as processing.

In a real implementation, the insert/reservation and the actual business write often need careful transaction boundaries.

The key idea:

  • reserve first
  • perform side effect once
  • persist response
  • replay response on retry

Common Pitfalls (and Fixes)
^

PitfallWhy It HappensFix
Checking before insertingTwo concurrent requests both see no recordUse atomic insert / unique constraint to reserve the key
Not hashing the requestSame key can be reused for different operationsStore a stable request fingerprint and reject mismatches
Only storing “seen” keysClient retries need the original resultStore response status/body or a resource reference
Global key scopeOne user’s key can collide with another user’s keyScope keys by user, tenant, account, or API key
Deleting keys too soonLate retries arrive after TTL expiryChoose TTL based on retry behavior and business risk
Retrying failed partial operations incorrectlyThe side effect may have partially completedUse transactions, resource references, and explicit recovery states

Conclusion
^

Idempotency keys are one of the most practical reliability tools in API design. They let clients retry safely when the network lies, timeouts happen, workers crash, or responses disappear. The pattern is simple at the surface—send a unique key with a request—but the production details matter: atomic reservation, request fingerprinting, scoped keys, response replay, TTLs, and careful handling of in-flight duplicates.

Used well, idempotency keys turn dangerous retries into predictable, recoverable operations.

Key Takeaways

  • Idempotency keys make unsafe operations safer to retry.
  • They are most useful for POST-style create/side-effect operations.
  • Always scope keys by user, tenant, account, or API key.
  • Use atomic reservation to avoid race conditions.
  • Store a request fingerprint to detect key misuse.
  • Store enough response information to replay the original result.
  • Choose TTLs based on real retry windows and business risk.