DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Build Idempotent APIs in Node.js (With Real Examples)

How to Build Idempotent APIs in Node.js (With Real Examples)

If you've ever seen a user get charged twice because they clicked "Pay" and their browser retried the request, you know why idempotency matters. In this guide, you'll learn how to make your Node.js APIs safe from duplicate requests — a critical pattern for payment processing, order creation, and any operation that shouldn't happen twice.

What Is Idempotency?

An API endpoint is idempotent if making the same request multiple times produces the same result as making it once. GET, PUT, and DELETE are naturally idempotent by design. POST is not — and that's where things get dangerous.

Consider this scenario:

  1. User clicks "Place Order"
  2. Request times out (but the server processed it)
  3. Client retries automatically
  4. User now has two orders

This isn't theoretical. As of February 2026, Stripe, PayPal, and virtually every major payment API requires idempotency keys for exactly this reason.

The Idempotency Key Pattern

The solution is simple: the client sends a unique key with each request. The server checks if it's already processed that key and returns the cached response instead of processing it again.

POST /api/orders
Idempotency-Key: ord_a1b2c3d4e5f6
Content-Type: application/json

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

Here's the flow:

  1. Client generates a unique Idempotency-Key (typically a UUID)
  2. Server receives the request and checks if it's seen this key before
  3. If new: process the request, store the result, return it
  4. If seen: return the stored result without reprocessing

Implementation: Express + Redis

We'll use Express 5 and Redis for storing idempotency records.

Step 1: Project Setup

mkdir idempotent-api && cd idempotent-api
npm init -y
npm install express@5 ioredis@5 uuid@11
Enter fullscreen mode Exit fullscreen mode

Step 2: The Idempotency Middleware

// middleware/idempotency.js
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
const IDEMPOTENCY_TTL = 86400; // 24 hours

export function idempotent(options = {}) {
  const { ttl = IDEMPOTENCY_TTL, headerName = 'idempotency-key' } = options;

  return async (req, res, next) => {
    const key = req.headers[headerName];
    if (!key) return next();

    if (key.length > 255) {
      return res.status(400).json({
        error: 'idempotency_key_too_long',
        message: 'Idempotency key must be 255 characters or less',
      });
    }

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

    try {
      const cached = await redis.get(cacheKey);
      if (cached) {
        const { statusCode, body, headers } = JSON.parse(cached);
        res.set('X-Idempotent-Replayed', 'true');
        Object.entries(headers || {}).forEach(([k, v]) => res.set(k, v));
        return res.status(statusCode).json(body);
      }

      const lockKey = `${cacheKey}:lock`;
      const locked = await redis.set(lockKey, '1', 'EX', 30, 'NX');
      if (!locked) {
        return res.status(409).json({
          error: 'request_in_progress',
          message: 'A request with this idempotency key is already being processed',
        });
      }

      const originalJson = res.json.bind(res);
      res.json = (body) => {
        const record = { statusCode: res.statusCode, body, headers: { 'content-type': 'application/json' } };
        redis.set(cacheKey, JSON.stringify(record), 'EX', ttl)
          .then(() => redis.del(lockKey))
          .catch(console.error);
        return originalJson(body);
      };
      next();
    } catch (err) {
      console.error('Idempotency middleware error:', err);
      next();
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Using the Middleware

// server.js
import express from 'express';
import { idempotent } from './middleware/idempotency.js';
import { v4 as uuidv4 } from 'uuid';

const app = express();
app.use(express.json());
const orders = new Map();

app.post('/api/orders', idempotent({ ttl: 3600 }), async (req, res) => {
  const { product_id, quantity } = req.body;
  await new Promise((resolve) => setTimeout(resolve, 100));
  const order = {
    id: uuidv4(), product_id, quantity,
    status: 'created', created_at: new Date().toISOString(),
  };
  orders.set(order.id, order);
  res.status(201).json({ data: order });
});

app.get('/api/orders/:id', (req, res) => {
  const order = orders.get(req.params.id);
  if (!order) return res.status(404).json({ error: 'not_found' });
  res.json({ data: order });
});

app.listen(3000, () => console.log('Server running on :3000'));
Enter fullscreen mode Exit fullscreen mode

Step 4: Client-Side Usage

import { v4 as uuidv4 } from 'uuid';

async function createOrder(productId, quantity) {
  const idempotencyKey = uuidv4();
  const makeRequest = async (retries = 3) => {
    try {
      const res = await fetch('http://localhost:3000/api/orders', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify({ product_id: productId, quantity }),
      });
      if (res.headers.get('X-Idempotent-Replayed') === 'true') {
        console.log('Response was replayed from cache');
      }
      return await res.json();
    } catch (err) {
      if (retries > 0) return makeRequest(retries - 1);
      throw err;
    }
  };
  return makeRequest();
}
Enter fullscreen mode Exit fullscreen mode

Handling Edge Cases

1. Request Body Mismatch

import { createHash } from 'node:crypto';

function fingerprint(body) {
  return createHash('sha256').update(JSON.stringify(body)).digest('hex');
}

// Inside middleware, after finding cached response:
const cachedFingerprint = await redis.get(`${cacheKey}:fingerprint`);
const currentFingerprint = fingerprint(req.body);

if (cachedFingerprint && cachedFingerprint !== currentFingerprint) {
  return res.status(422).json({
    error: 'idempotency_key_reused',
    message: 'This idempotency key was used with a different request body',
  });
}
Enter fullscreen mode Exit fullscreen mode

2. Don't Cache 5xx Errors

res.json = (body) => {
  if (res.statusCode < 500) {
    redis.set(cacheKey, JSON.stringify({ statusCode: res.statusCode, body }), 'EX', ttl);
  } else {
    redis.del(lockKey);
  }
  return originalJson(body);
};
Enter fullscreen mode Exit fullscreen mode

3. Database Transactions for Payments

import { Pool } from 'pg';
const pool = new Pool();

app.post('/api/payments', idempotent(), async (req, res) => {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const existing = await client.query(
      'SELECT * FROM payments WHERE reference = $1',
      [req.headers['idempotency-key']]
    );
    if (existing.rows.length > 0) {
      await client.query('ROLLBACK');
      return res.json({ data: existing.rows[0] });
    }
    const result = await client.query(
      'INSERT INTO payments (reference, amount, status) VALUES ($1, $2, $3) RETURNING *',
      [req.headers['idempotency-key'], req.body.amount, 'completed']
    );
    await client.query('COMMIT');
    res.status(201).json({ data: result.rows[0] });
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
});
Enter fullscreen mode Exit fullscreen mode

How the Big Players Do It

Provider Header TTL Notes
Stripe Idempotency-Key 24h Required for all POST
PayPal PayPal-Request-Id 72h All mutation endpoints
Square Idempotency-Key 24h Required for Create
Shopify Idempotency-Key 60s High-volume commerce

Testing

# First request — creates the order
curl -s -X POST http://localhost:3000/api/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: test-key-123' \
  -d '{"product_id": "prod_1", "quantity": 2}'

# Second request — returns cached response
curl -s -D- -X POST http://localhost:3000/api/orders \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: test-key-123' \
  -d '{"product_id": "prod_1", "quantity": 2}'
# => X-Idempotent-Replayed: true (same order ID!)
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Always support idempotency on POST endpoints that create resources or trigger side effects
  2. Use Redis for the idempotency cache
  3. Add a distributed lock to handle concurrent duplicates
  4. Include a body fingerprint to detect key reuse with different payloads
  5. Don't cache 5xx errors — let clients retry
  6. Set a reasonable TTL — 24 hours covers most scenarios
  7. Document it — tell consumers to send Idempotency-Key headers

Idempotency isn't optional for production APIs. Implement it early, and your users will thank you.


Building APIs? 1xAPI provides developer tools including email verification, SMS APIs, and more — all designed with idempotency built in.

Top comments (0)