DEV Community

Young Gao
Young Gao

Posted on

Designing Idempotent APIs: Why Your POST Endpoint Needs to Handle Duplicates

Designing Idempotent APIs: Why Your POST Endpoint Needs to Handle Duplicates

A user clicks Buy. Nothing happens. They click again. Two charges.

What Idempotency Means

Same request N times = same result. GET, PUT, DELETE are idempotent. POST is not.

Why This Matters

  1. Network retries: Mobile app retries on timeout. Server already processed the first request.
  2. Load balancer retries: Upstream timeout triggers retry to different backend.
  3. User double-clicks: Button not disabled fast enough.

Without idempotency, each retry creates duplicates.

The Idempotency Key Pattern

Client generates a UUID and sends it as a header. Server checks before processing.

POST /api/orders
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{"product_id": "prod_123", "quantity": 2}
Enter fullscreen mode Exit fullscreen mode

Server: check if key exists in Redis. If yes, return cached response. If no, process and cache.

Express Middleware Implementation

import { Request, Response, NextFunction } from "express";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL\!);
const TTL = 86400; // 24 hours

interface CachedResponse {
  status: number;
  body: unknown;
}

export function idempotency() {
  return async (req: Request, res: Response, next: NextFunction) => {
    if (req.method \!== "POST") return next();

    const key = req.headers["idempotency-key"] as string;
    if (\!key) return next();

    const cacheKey = `idem:${req.path}:${key}`;

    // Check cache
    const cached = await redis.get(cacheKey);
    if (cached) {
      const r: CachedResponse = JSON.parse(cached);
      return res.status(r.status).json(r.body);
    }

    // Lock to prevent concurrent duplicates
    const lock = await redis.set(`lock:${cacheKey}`, "1", "EX", 30, "NX");
    if (\!lock) {
      return res.status(409).json({ error: "Request in progress" });
    }

    // Intercept response to cache it
    const origJson = res.json.bind(res);
    res.json = function (body: unknown) {
      redis.setex(cacheKey, TTL, JSON.stringify({ status: res.statusCode, body }));
      redis.del(`lock:${cacheKey}`);
      return origJson(body);
    };

    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Database-Level Idempotency

CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  idempotency_key UUID UNIQUE NOT NULL,
  product_id TEXT NOT NULL,
  quantity INTEGER NOT NULL,
  total DECIMAL(10,2) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

INSERT INTO orders (idempotency_key, product_id, quantity, total)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING *;
Enter fullscreen mode Exit fullscreen mode

If conflict, query existing row and return it. The UNIQUE constraint guarantees no duplicates even under concurrent load.

How Stripe Does It

Stripe idempotency is the gold standard:

1. Key scoping: Keys scoped per API key, not global.

2. Request fingerprinting: Reusing a key with different params returns 400, not silent cache hit.

3. 24-hour TTL: Short enough to reclaim storage, long enough for retries.

4. In-progress detection: Same key currently processing returns 409.

function validateIdempotencyReuse(
  cached: CachedRequest, incoming: Request
): void {
  const hash = crypto.createHash("sha256")
    .update(JSON.stringify(incoming.body)).digest("hex");
  if (cached.bodyHash \!== hash) {
    throw new HttpError(400,
      "Idempotency key reused with different request body");
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing

Test: duplicate key = same response. Different body = 400. Concurrent = one 201, one 409.

Common Pitfalls

  1. Never let server generate keys - client controls retry identity
  2. Scope keys: idem:user:path:key not just idem:key
  3. Only cache 2xx/4xx, not 5xx (transient errors should retry)
  4. Always set TTL (24h is standard)
  5. Use DB transactions for multi-step ops - idempotency key in same tx

Conclusion

Idempotent APIs prevent duplicate charges, orders, and emails.

  1. Client sends unique key
  2. Server checks if key exists
  3. If seen: return cached response
  4. If new: process, cache, return

Start with DB UNIQUE constraints. Add Redis for speed. Add request fingerprinting for Stripe-level safety.

Your users will double-click. Your network will retry. Design for it.


Part of my Production Backend Patterns series. Follow for more practical backend engineering.


If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.

Top comments (0)