REST API Idempotency

Idempotent HTTP methods, idempotency keys, and safe retry patterns for building reliable distributed APIs.

Last Updated:

What Is Idempotency?

An operation is idempotent if performing it multiple times has the same effect as performing it once. In the context of REST APIs, idempotency determines whether a request can be safely retried without causing unintended side effects.

This matters most in distributed systems where network failures, timeouts, and retries are a reality. If a client sends a payment request and never receives a response (due to a network error), should it retry? Only if the operation is idempotent — or if the API provides an idempotency mechanism.

HTTP Method Idempotent? Safe? Notes
GET Yes Yes Read-only; no side effects
HEAD Yes Yes Same as GET but no body
OPTIONS Yes Yes No side effects
PUT Yes No Sets resource to a fixed state; repeating gives same result
DELETE Yes No Resource gone after first call; subsequent calls return 404
POST No No Creates new resource each time; requires idempotency keys for safe retries
PATCH Depends No Idempotent if setting absolute values; not idempotent if incrementing

Safe vs idempotent: A "safe" method has no side effects (GET, HEAD, OPTIONS). An idempotent method may have side effects, but repeating it doesn't compound them — DELETE is idempotent but not safe, because it modifies server state (the first time).

Why Idempotency Matters: The Retry Problem

In production, requests fail. Networks time out. Servers crash. Load balancers drop connections. Clients must decide: is it safe to retry?

The Double-Charge Problem

Consider a payment flow:

Client → POST /charges { amount: 9900, currency: "usd" }
  ... network timeout after 5 seconds ...
  Client never receives a response.

Was the charge created? Unknown.
Should the client retry? 
  → If yes: potentially charged twice.
  → If no: payment may be lost.

Without idempotency keys, the client cannot safely retry. With them:

Client → POST /charges
Idempotency-Key: a4e3f2b1-9c8d-4e6f-b2a1-8d7c9e3f4b2a
{ amount: 9900, currency: "usd" }

  ... network timeout ...

Client retries with the SAME key:
POST /charges
Idempotency-Key: a4e3f2b1-9c8d-4e6f-b2a1-8d7c9e3f4b2a
{ amount: 9900, currency: "usd" }

Server: "I've seen this key. Here's the original response."
→ Returns the same result, no duplicate charge.

Implementing Idempotency Keys

An idempotency key is a unique identifier the client generates and includes in each mutating request. The server uses this key to deduplicate requests.

Client: Generating and Sending Keys

// Node.js client example
const { randomUUID } = require('crypto');

async function createCharge(amount, currency) {
  const idempotencyKey = randomUUID(); // e.g. "a4e3f2b1-9c8d-4e6f-b2a1-8d7c9e3f4b2a"

  const response = await fetch('https://api.example.com/charges', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + token,
      'Idempotency-Key': idempotencyKey
    },
    body: JSON.stringify({ amount, currency })
  });

  if (!response.ok && response.status !== 409) {
    // Safe to retry with the same idempotencyKey
    throw new Error('Request failed: ' + response.status);
  }

  return response.json();
}

Server: Storing and Replaying Results

The server must:

  1. Extract the Idempotency-Key header on every mutating request
  2. Check a key store (Redis, database) for an existing result
  3. If found, return the stored response immediately
  4. If not found, process the request, store the result, then respond
  5. Expire stored keys after a sensible window (e.g., 24 hours)
// Express.js middleware example
const redis = require('redis');
const client = redis.createClient();

async function idempotencyMiddleware(req, res, next) {
  const key = req.headers['idempotency-key'];
  if (!key || req.method === 'GET') return next();

  // Check for stored result
  const cached = await client.get('idempotency:' + key);
  if (cached) {
    const { status, body } = JSON.parse(cached);
    res.set('X-Idempotent-Replayed', 'true');
    return res.status(status).json(body);
  }

  // Intercept the response to store it
  const originalJson = res.json.bind(res);
  res.json = (body) => {
    // Store result for 24 hours
    client.setEx(
      'idempotency:' + key,
      86400,
      JSON.stringify({ status: res.statusCode, body })
    );
    originalJson(body);
  };

  next();
}

app.use(idempotencyMiddleware);

Edge Cases and Design Decisions

1. Concurrent Requests with the Same Key

If two requests with the same key arrive simultaneously (before the first completes), the server must handle this safely. Use optimistic locking or atomic operations to ensure only one request executes:

# Redis atomic lock pattern
SET idempotency:KEY "processing" NX EX 30
# NX = only set if not exists (atomic)
# EX 30 = expire after 30 seconds (release if server crashes)

→ If SET returns OK: process the request, then update the key
→ If SET returns nil: another request is processing — return 409 Conflict

2. Key Reuse with Different Payloads

What if a client sends the same idempotency key but with a different request body? Best practice: return 422 Unprocessable Entity or 400 Bad Request with a clear error. Stripe returns 400 for this case.

3. Key Expiry Window

Idempotency keys should expire after a reasonable window — long enough to cover all realistic retry attempts, but not so long that you store stale data forever. 24 hours is the standard (used by Stripe). For high-volume APIs, consider shorter windows (e.g., 1 hour) to reduce storage requirements.

4. What to Store

Store the HTTP status code and response body. Do not store response headers (they may contain request-specific values like Date). The X-Idempotent-Replayed: true header on replayed responses is a useful signal for clients and debugging.

5. Idempotency Keys for PATCH

PATCH with relative/incremental operations is not idempotent. For example, {"views": "+1"} increments on every call. If you need safe retries for PATCH, use idempotency keys the same way as POST. Alternatively, design PATCH to use absolute values: {"views": 42} is idempotent; {"views": "+1"} is not.

Real-World Patterns

Stripe's Idempotency Implementation

Stripe uses the Idempotency-Key header across all mutating endpoints. Their implementation stores results for 24 hours. Replayed responses include the Idempotent-Replayed: true header.

curl -X POST https://api.stripe.com/v1/charges \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -d amount=2000 \
  -d currency=usd \
  -d source=tok_visa

Python: Retry with Idempotency Key

import uuid
import time
import requests

def create_order_with_retry(payload, max_retries=3):
    idempotency_key = str(uuid.uuid4())
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {TOKEN}',
        'Idempotency-Key': idempotency_key
    }

    for attempt in range(max_retries):
        try:
            resp = requests.post(
                'https://api.example.com/orders',
                json=payload,
                headers=headers,
                timeout=10
            )
            if resp.status_code in (200, 201):
                return resp.json()
            if resp.status_code == 409:
                # Concurrent duplicate — wait and retry
                time.sleep(0.5 * (attempt + 1))
                continue
            resp.raise_for_status()
        except requests.Timeout:
            # Safe to retry with same key
            time.sleep(1 * (attempt + 1))

    raise Exception('Max retries exceeded')

Exponential Backoff for Retries

Always use exponential backoff with jitter when retrying idempotent operations. Retrying immediately at full rate compounds server load during outages:

// Exponential backoff with jitter
function delay(attempt) {
  const base = 1000; // 1 second
  const max  = 30000; // 30 seconds cap
  const exp  = Math.min(base * Math.pow(2, attempt), max);
  const jitter = Math.random() * exp * 0.25;
  return exp + jitter;
}

// attempt 0: ~1 second
// attempt 1: ~2 seconds
// attempt 2: ~4 seconds
// attempt 3: ~8 seconds

Frequently Asked Questions

Is DELETE idempotent if it returns 404 on the second call?

Yes. Idempotency refers to the state of the system, not the HTTP status code returned. After a successful DELETE, the resource is gone. A second DELETE finds nothing — the system state is the same (no resource). The 404 is correct and expected; it does not make DELETE non-idempotent.

Do I need idempotency keys for GET requests?

No. GET is inherently idempotent and safe. Idempotency keys are only needed for mutating operations (POST, and non-idempotent PATCH) where a duplicate request could cause harm (double charges, duplicate records).

Who should generate the idempotency key — client or server?

The client generates idempotency keys, because the client is the one that decides when to retry. The key must be generated before the first attempt and reused on all retries for the same logical operation. If the server generated the key, the client would need another round-trip to retrieve it — defeating the purpose.

How does idempotency relate to transactions?

Idempotency is complementary to database transactions. Transactions ensure atomicity — an operation either fully succeeds or fully fails. Idempotency keys ensure at-most-once execution semantics across retries. In payment systems, you typically need both: a transaction to ensure the charge and the ledger entry are consistent, and an idempotency key to ensure the customer is not charged twice.