DEV Community

Mean for APIKumo

Posted on

Idempotency Keys: How to Make Your API Requests Safe to Retry

Idempotency Keys: How to Make Your API Requests Safe to Retry

Networks fail. Servers time out. Users double-click submit buttons. When any of these happen with a non-idempotent API request, you end up with duplicate charges, duplicate orders, or corrupted state — the kind of bug that's infuriating to debug at 2am.

Idempotency keys are the standard solution. They're a small addition to your API design that makes retries completely safe. Here's how they work and how to implement them.

What Is an Idempotency Key?

An idempotency key is a unique identifier the client generates and sends with a request. The server uses it to detect duplicate requests and return the same response instead of processing the operation again.

The client sends:

POST /payments
Idempotency-Key: a8098c1a-f86e-11da-bd1a-00112444be1e
Content-Type: application/json

{"amount": 4999, "currency": "usd", "customer_id": "cus_123"}
Enter fullscreen mode Exit fullscreen mode

If the network drops and the client retries with the same key, the server returns the original response — no second charge.

Why GET Isn't Enough

GET requests are idempotent by definition (reading doesn't change state). The problem is POST, PUT, and PATCH requests that mutate data. A naive retry of POST /payments without an idempotency key will create two payments. Your users will notice.

Implementing Idempotency Keys on the Server

Here's a minimal Express.js middleware that adds idempotency support to any route:

const crypto = require('crypto');

// In-memory store — use Redis in production
const idempotencyStore = new Map();

function idempotencyMiddleware(req, res, next) {
  const key = req.headers['idempotency-key'];

  // Skip for non-mutating methods
  if (!key || ['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
    return next();
  }

  if (idempotencyStore.has(key)) {
    const cached = idempotencyStore.get(key);

    // If still processing, return 409 Conflict
    if (cached.status === 'processing') {
      return res.status(409).json({
        error: 'A request with this idempotency key is already in progress'
      });
    }

    // Return the cached response
    return res.status(cached.statusCode).json(cached.body);
  }

  // Mark as processing
  idempotencyStore.set(key, { status: 'processing' });

  // Intercept the response to cache it
  const originalJson = res.json.bind(res);
  res.json = (body) => {
    idempotencyStore.set(key, {
      status: 'complete',
      statusCode: res.statusCode,
      body
    });
    return originalJson(body);
  };

  next();
}

app.use(idempotencyMiddleware);
Enter fullscreen mode Exit fullscreen mode

In production, replace the Map with Redis and set a TTL (24 hours is common):

const redis = require('redis');
const client = redis.createClient();

async function getIdempotencyRecord(key) {
  const data = await client.get(`idempotency:${key}`);
  return data ? JSON.parse(data) : null;
}

async function setIdempotencyRecord(key, record, ttlSeconds = 86400) {
  await client.setEx(
    `idempotency:${key}`,
    ttlSeconds,
    JSON.stringify(record)
  );
}
Enter fullscreen mode Exit fullscreen mode

Generating Keys on the Client

The client is responsible for generating idempotency keys. Use UUIDs — they're long enough to avoid collisions and widely supported:

import uuid
import requests

def create_payment(amount: int, currency: str, customer_id: str) -> dict:
    """
    Creates a payment with automatic retry safety via idempotency key.
    The key is tied to the logical operation, not the HTTP request.
    """
    idempotency_key = str(uuid.uuid4())

    response = requests.post(
        'https://api.example.com/payments',
        json={
            'amount': amount,
            'currency': currency,
            'customer_id': customer_id
        },
        headers={
            'Idempotency-Key': idempotency_key,
            'Authorization': 'Bearer your-token'
        }
    )
    response.raise_for_status()
    return response.json()
Enter fullscreen mode Exit fullscreen mode

Critical rule: generate the key once per logical operation, not per HTTP request. Store it before making the call, so that if your process crashes between attempts you can retry with the same key:

# Persist the key BEFORE the first attempt
operation_id = str(uuid.uuid4())
db.save_pending_payment(order_id, operation_id)

# Now retry safely — same key every time
for attempt in range(1, 4):
    try:
        result = requests.post(
            'https://api.example.com/payments',
            json=payload,
            headers={'Idempotency-Key': operation_id}
        )
        result.raise_for_status()
        break
    except requests.RequestException:
        if attempt == 3:
            raise
        time.sleep(2 ** attempt)  # exponential backoff
Enter fullscreen mode Exit fullscreen mode

What to Return for Duplicate Requests

Always return exactly the same status code and body as the original response — even if the original returned an error. If the first request failed with a 422 validation error, a retry with the same key should return the same 422, not attempt the operation again.

The one exception: if the original request is still in-flight (a race condition), return 409 Conflict to signal that the client should wait before retrying.

Common Mistakes

Scoping keys too broadly. An idempotency key should be scoped to a single resource type and operation. Don't reuse the same key across POST /payments and POST /refunds — treat them as separate operations.

Expiring keys too quickly. If a user comes back to retry an operation three days later, they expect the same outcome. 24 hours is a minimum; 7 days is safer for most payment flows.

Not validating the request body matches. If a client sends the same key but with a different amount, that's likely a bug. Return a 422 and log it — don't silently process a different operation.

Testing Idempotency

Add an explicit test that fires the same request twice and asserts only one side effect occurred:

test('duplicate payment request returns same result without charging twice', async () => {
  const key = 'test-key-' + Date.now();
  const payload = { amount: 1000, currency: 'usd', customer_id: 'cus_test' };

  const first = await api.post('/payments', payload, { 'Idempotency-Key': key });
  const second = await api.post('/payments', payload, { 'Idempotency-Key': key });

  expect(first.body.id).toEqual(second.body.id);  // Same payment ID
  expect(await db.countPayments('cus_test')).toBe(1);  // Only one charge
});
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

Idempotency keys are a small addition that prevent a whole class of serious bugs. Any API that accepts money, creates resources, or triggers irreversible actions should support them — and clients should use them by default.

When you're building and testing APIs that require this kind of reliability, APIKumo makes it easy to set up custom request headers like Idempotency-Key as part of your collection configuration, so every developer on your team sends them consistently without thinking about it.

Top comments (0)