Idempotency Key Implementation

Idempotency is a non-negotiable contract for distributed APIs handling financial transactions, resource provisioning, or any state-mutating workflow where network failures are inevitable. Without an enforced idempotency contract, client retries produce duplicate charges, orphaned resources, and impossible-to-debug state corruption. This page covers the complete implementation surface: OpenAPI contract definition, atomic server-side storage, client-side auto-injection, CI enforcement, and production observability. Part of the API Design Fundamentals & Architecture reference.


Idempotency Key Lifecycle Sequence showing a client generating a UUID idempotency key, a first POST request that executes and stores the result, and a duplicate POST that returns the cached 200 response instead of re-executing. Client API Server Key Store POST + Idempotency-Key: <uuid> EXISTS uuid? → MISS SET uuid = response (TTL 24h) 200 OK (first execution) — retry / duplicate request — POST + Idempotency-Key: <same uuid> EXISTS uuid? → HIT 200 OK (cached, no re-execution)

Problem Framing

While GET, PUT, and DELETE are inherently idempotent under the HTTP specification, POST and PATCH are not — the server may create or mutate state on every call. In distributed systems, a payment that times out at the load balancer will be retried by the client without knowing whether the server committed it. The Idempotency-Key header is the contract that lets the server detect and suppress the duplicate execution while still returning a correct response to the client.

Without an enforced idempotency contract, the failure modes are silent: double charges process, inventory decrements twice, and audit logs contain phantom records. The HTTP Method Mapping Guidelines cover which HTTP verbs are safe and idempotent by default; this page covers how to make POST and PATCH safe through explicit key tracking.

Spec Definition

Define the Idempotency-Key header as a reusable OpenAPI 3.1 component. This anchors the contract and drives downstream validation, mock server generation, and SDK auto-injection.

# openapi.yaml (OpenAPI 3.1.0)
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >
        Client-generated UUID (v4 or v7) that identifies this logical operation.
        The server returns the original response for any duplicate request carrying
        the same key within the key's TTL window.
      schema:
        type: string
        format: uuid
        pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
        examples:
          - 550e8400-e29b-41d4-a716-446655440000

  responses:
    IdempotencyConflict:
      description: >
        A request with this key is already in-flight. The client must wait
        and retry rather than submit a new key.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'

paths:
  /v1/payments:
    post:
      operationId: createPayment
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Payment created (or replayed from cache)
          headers:
            X-Idempotency-Replayed:
              schema:
                type: boolean
              description: true when returning a cached result
        '409':
          $ref: '#/components/responses/IdempotencyConflict'

RFC / Standard Alignment

Standard Relevance
RFC 7231 §4.2.2 — Idempotent Methods Defines which HTTP methods are idempotent by spec; POST is explicitly non-idempotent
RFC 7807 — Problem Details Required format for 409 Conflict error body; see RFC 7807 Problem+JSON Implementation
IETF draft-ietf-httpapi-idempotency-key-header Working draft formalising Idempotency-Key header semantics for HTTP APIs
IETF RFC 9562 (UUID) Defines UUIDv4 (random) and UUIDv7 (time-ordered); v7 improves B-tree index locality
HTTP 409 Conflict Correct status for in-flight collision; do not use 400 or 422 for this case
HTTP 200 + X-Idempotency-Replayed: true Signals a cached replay without changing semantics for the client

Implementation Walkthrough — Server Side

Atomic check-and-write is the critical invariant. A non-atomic GET-then-SET creates a race window where two simultaneous retries both observe a cache miss and both execute, defeating the entire contract.

Redis — Lua Script (atomic)

// idempotency-middleware.ts
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const IDEMPOTENCY_TTL_SECONDS = 86400; // 24 hours

const checkAndSetScript = `
  local existing = redis.call('GET', KEYS[1])
  if existing then
    return existing
  end
  redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
  return false
`;

export async function idempotencyMiddleware(req, res, next) {
  const key = req.headers['idempotency-key'];
  if (!key) {
    return res.status(400).json({ type: '/errors/missing-idempotency-key' });
  }

  // Namespace by method + path to prevent cross-endpoint collisions
  const storageKey = `idemp:${req.method}:${req.path}:${key}`;

  // Reserve slot: marks key as IN_FLIGHT while the handler runs
  const placeholder = JSON.stringify({ status: 'IN_FLIGHT' });
  const existing = await redis.eval(
    checkAndSetScript,
    { keys: [storageKey], arguments: [placeholder, String(IDEMPOTENCY_TTL_SECONDS)] }
  );

  if (existing) {
    const cached = JSON.parse(existing as string);
    if (cached.status === 'IN_FLIGHT') {
      return res.status(409).json({
        type: '/errors/idempotency-key-in-flight',
        title: 'Request already in progress',
        detail: 'A request with this key is currently being processed. Retry after a short delay.'
      });
    }
    // Replay the stored response
    res.set('X-Idempotency-Replayed', 'true');
    return res.status(cached.statusCode).json(cached.body);
  }

  // Capture the response so it can be stored for future replays
  const originalJson = res.json.bind(res);
  res.json = async (body) => {
    await redis.setEx(
      storageKey,
      IDEMPOTENCY_TTL_SECONDS,
      JSON.stringify({ statusCode: res.statusCode, body })
    );
    return originalJson(body);
  };

  next();
}

For a complete walkthrough of middleware ordering, header normalisation, and error edge cases in Express, see Generating Idempotency Keys in Node.js Express APIs.

DynamoDB — Conditional Write (AWS)

# idempotency_store.py
import boto3
import json
import time
from botocore.exceptions import ClientError

dynamodb = boto3.client('dynamodb')
TABLE = 'IdempotencyKeys'
TTL_SECONDS = 86400

def check_and_reserve(storage_key: str) -> dict | None:
    """
    Returns None if key was successfully reserved (first execution).
    Returns cached item dict if key was already processed.
    Raises ConditionalCheckFailedException if key is in-flight.
    """
    ttl_epoch = int(time.time()) + TTL_SECONDS
    try:
        dynamodb.put_item(
            TableName=TABLE,
            Item={
                'PK':     {'S': storage_key},
                'Status': {'S': 'IN_FLIGHT'},
                'TTL':    {'N': str(ttl_epoch)},
            },
            ConditionExpression='attribute_not_exists(PK)',
        )
        return None  # Reserved — proceed with execution
    except ClientError as e:
        if e.response['Error']['Code'] != 'ConditionalCheckFailedException':
            raise
        # Key exists — fetch and return cached result
        response = dynamodb.get_item(TableName=TABLE, Key={'PK': {'S': storage_key}})
        return response.get('Item')

def store_result(storage_key: str, status_code: int, body: dict):
    ttl_epoch = int(time.time()) + TTL_SECONDS
    dynamodb.update_item(
        TableName=TABLE,
        Key={'PK': {'S': storage_key}},
        UpdateExpression='SET #s = :s, ResponseBody = :b, #t = :t',
        ExpressionAttributeNames={'#s': 'Status', '#t': 'TTL'},
        ExpressionAttributeValues={
            ':s': {'S': 'COMPLETE'},
            ':b': {'S': json.dumps({'statusCode': status_code, 'body': body})},
            ':t': {'N': str(ttl_epoch)},
        },
    )

Align the PK naming convention with Resource Modeling Best Practices to namespace keys consistently — POST:/v1/payments:<uuid> prevents collisions if the same UUID is reused across different endpoints.

Implementation Walkthrough — Client Side

Manual header injection on every call site is error-prone and breaks retries when a fresh UUID is generated per attempt. Auto-inject at the HTTP client layer so retry logic sees the same key on every attempt.

TypeScript — Axios Interceptor

// idempotent-client.ts
import axios, { AxiosInstance } from 'axios';

const NON_IDEMPOTENT_METHODS = new Set(['post', 'patch']);

export function addIdempotencyInterceptor(client: AxiosInstance): AxiosInstance {
  client.interceptors.request.use((config) => {
    const method = (config.method ?? '').toLowerCase();
    if (NON_IDEMPOTENT_METHODS.has(method)) {
      // Preserve existing key if caller already set one (e.g. for manual retry flows)
      if (!config.headers['Idempotency-Key']) {
        config.headers['Idempotency-Key'] = crypto.randomUUID();
      }
    }
    return config;
  });
  return client;
}

// Retry wrapper that preserves the key across attempts
export async function idempotentPost<T>(
  client: AxiosInstance,
  url: string,
  data: unknown,
  maxRetries = 3
): Promise<T> {
  const key = crypto.randomUUID();
  let delay = 500;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const { data: result } = await client.post<T>(url, data, {
        headers: { 'Idempotency-Key': key },
      });
      return result;
    } catch (err: any) {
      // 409 IN_FLIGHT: brief wait then retry with same key
      if (err?.response?.status === 409 && attempt < maxRetries) {
        await new Promise((r) => setTimeout(r, delay));
        delay *= 2;
        continue;
      }
      // 5xx: retry with same key (server may not have committed)
      if (err?.response?.status >= 500 && attempt < maxRetries) {
        await new Promise((r) => setTimeout(r, delay));
        delay *= 2;
        continue;
      }
      throw err;
    }
  }
  throw new Error('Max retries exceeded');
}

Python — Requests Session

# idempotent_session.py
import uuid
import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

NON_IDEMPOTENT = {'POST', 'PATCH'}

class IdempotentSession(requests.Session):
    """Automatically injects Idempotency-Key on POST/PATCH and preserves it on retries."""

    def request(self, method: str, url: str, **kwargs):
        if method.upper() in NON_IDEMPOTENT:
            headers = kwargs.setdefault('headers', {})
            # Only generate a new key if the caller hasn't provided one
            if 'Idempotency-Key' not in headers:
                headers['Idempotency-Key'] = str(uuid.uuid4())
        return super().request(method, url, **kwargs)


def build_idempotent_session(retries: int = 3, backoff_factor: float = 0.5) -> IdempotentSession:
    session = IdempotentSession()
    retry = Retry(
        total=retries,
        backoff_factor=backoff_factor,
        status_forcelist=[500, 502, 503, 504],
        allowed_methods=['POST', 'PATCH', 'GET'],  # urllib3 must allow POST retries
        raise_on_status=False,
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount('https://', adapter)
    session.mount('http://', adapter)
    return session

For configuring exponential backoff with correct retry budgets on 5xx responses, see Configuring Exponential Backoff for 5xx Errors.

Edge-Case Handling

Bulk operations. When a POST /v1/payments/batch creates multiple resources atomically, the idempotency key covers the entire batch — not individual items. Store the complete batch response under a single key. If partial failures can occur, document this in your OpenAPI spec explicitly so clients understand that replaying the key returns the original partial result.

In-flight collision (409 storms). Under high concurrency, many clients retrying the same operation will all see IN_FLIGHT and get a 409. Implement jitter in the client backoff so retries stagger. On the server, set a maximum in-flight TTL (e.g. 30 s) so that if the handler crashes mid-execution, the key does not stay locked forever — use a background cleanup job or TTL on the IN_FLIGHT placeholder.

Header stripping by proxies. CORS preflight and some reverse proxies drop non-standard headers. Explicitly whitelist Idempotency-Key in Access-Control-Allow-Headers and in proxy configuration. Test with curl -v through the full proxy chain to confirm the header reaches the application.

Key reuse across API versions. A key generated against /v1/payments must not be treated as equivalent for /v2/payments. Include the path (not just the UUID) in the storage namespace. When issuing API versions, ensure the idempotency store is partitioned by version prefix.

Conditional requests and ETags. If an endpoint uses If-Match / ETag for optimistic locking alongside Idempotency-Key, the idempotency semantics take precedence on replay — return the original response even if the ETag has since changed, because the operation already completed.

Validation and Testing Patterns

Spectral Rule — Enforce Header on POST/PATCH

# spectral.yaml
rules:
  idempotency-header-required:
    message: 'POST and PATCH operations must require an Idempotency-Key header'
    severity: error
    given: '$.paths[*][post,patch]'
    then:
      function: schema
      field: parameters
      functionOptions:
        schema:
          type: array
          contains:
            type: object
            properties:
              name:
                const: Idempotency-Key
              required:
                const: true
            required: [name, required]

  idempotency-replay-header-documented:
    message: 'POST 200 response should document X-Idempotency-Replayed header'
    severity: warn
    given: '$.paths[*].post.responses.200.headers'
    then:
      function: truthy
      field: X-Idempotency-Replayed

GitHub Actions Contract Gate

# .github/workflows/api-contract.yml
name: API Contract Validation
on: [pull_request]

jobs:
  validate-idempotency:
    runs-on: ubuntu-latest
    services:
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']

    steps:
      - uses: actions/checkout@v4

      - name: Lint OpenAPI spec
        run: npx @stoplight/spectral-cli@6 lint openapi.yaml --ruleset spectral.yaml --fail-severity error

      - name: Start mock server
        run: |
          npx @stoplight/prism-cli@5 mock openapi.yaml --port 4010 &
          sleep 2

      - name: First request — expect 200 fresh execution
        run: |
          KEY=$(python3 -c "import uuid; print(uuid.uuid4())")
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:4010/v1/payments \
            -H "Idempotency-Key: $KEY" \
            -H "Content-Type: application/json" \
            -d '{"amount": 100, "currency": "USD"}')
          [ "$STATUS" = "200" ] || (echo "Expected 200, got $STATUS" && exit 1)
          echo "IDEMPOTENCY_KEY=$KEY" >> $GITHUB_ENV

      - name: Duplicate request — expect 200 with replay header
        run: |
          REPLAYED=$(curl -s -D - -X POST http://localhost:4010/v1/payments \
            -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
            -H "Content-Type: application/json" \
            -d '{"amount": 100, "currency": "USD"}' | grep -i "x-idempotency-replayed")
          echo "Replay header: $REPLAYED"

      - name: Missing key — expect 400
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:4010/v1/payments \
            -H "Content-Type: application/json" \
            -d '{"amount": 100, "currency": "USD"}')
          [ "$STATUS" = "400" ] || (echo "Expected 400 for missing key, got $STATUS" && exit 1)

SDK Generation Impact

When Idempotency-Key is defined as required: true in the OpenAPI spec, generated clients behave differently across generator targets:

Generator Behaviour
typescript-axios Emits the header as a required parameter on the operation method signature — callers must pass it explicitly
python (openapi-generator) Adds idempotency_key: str as a required positional argument; missing it is a TypeError at call time
go Includes the header in the operation’s *Params struct with json:"Idempotency-Key,omitempty" — note this makes it optional in Go’s type system even though the spec marks it required
java (OkHttp) Adds @Header("Idempotency-Key") annotation on the Retrofit interface method

The Go generator quirk means you must add an additional contract test to confirm missing-key requests return 400 — the type system alone will not catch it. For all generators, pair the required header with an interceptor (as shown above) that auto-injects a UUID when the caller omits it, so SDK consumers are not burdened with UUID generation on every call.

Anti-Patterns

Anti-pattern Correct approach
Non-atomic GET then SET Use Redis Lua script, DynamoDB conditional write, or INSERT ... ON CONFLICT IGNORE — a single atomic operation
Global UUID uniqueness without path scope Namespace the storage key: POST:/v1/payments:<uuid> to prevent cross-endpoint false hits
No TTL on stored keys Always set EXPIRE / DynamoDB TTL (24–48 h); unbounded growth exhausts storage
Generating a fresh UUID per retry attempt Generate once, preserve across all retry attempts for the same logical operation
Returning 200 OK for replay without X-Idempotency-Replayed Add the response header so clients and observability tooling can distinguish replays from fresh executions
Using 422 Unprocessable Entity for in-flight collisions Use 409 Conflict — it is the semantically correct status for a key that is already being processed

FAQ

Should idempotency keys be scoped per endpoint or globally unique?

Scope keys to the endpoint and HTTP method combination. Store them under a composite key — METHOD:PATH:UUID — to prevent cross-resource cache collisions. A UUID that was used for a payment must not suppress a later order-creation request just because the UUID happens to match.

What is the recommended TTL for idempotency key storage?

24–48 hours covers the retry window of virtually all production clients and is the de-facto standard in payment APIs (Stripe, Adyen, PayPal). Align the TTL with your client’s maximum backoff ceiling. For compliance-sensitive APIs, check whether the TTL needs to match audit retention requirements.

Can generated client SDKs manage idempotency keys automatically across retries?

Yes. Add a request interceptor (Axios, requests.Session, http.RoundTripper in Go) that generates a UUID on the first attempt and stores it in the request context. Every retry re-uses the stored key. Avoid re-generating the key inside the retry loop — that is the most common implementation error.

How do I test idempotency behavior without hitting production databases?

Spin up an in-memory Redis (Docker or Testcontainers) with a 60-second TTL override for the test environment. Run your full middleware stack against the mock store, fire two identical requests with the same UUID, and assert the second returns X-Idempotency-Replayed: true. This is fast enough to run in every pull request CI job.