Skip to main content
Idempotent APIs: Same Request, Same Result

Idempotent APIs: Same Request, Same Result

Jul 19, 2025

User clicks "Pay Now". Request times out. They click again. Now theyve been charged twice.

This happens way more then you'd think. Networks are unreliable. Clients retry. If your API isnt idempotent, you get double charges, duplicate orders, and angry customers.

What Is Idempotency?

An operation is idempotent if doing it multiple times has the same effect as doing it once.

  • GET: Always idempotent. Reading doesnt change anything.
  • DELETE: Idempotent. Delete twice? Still deleted.
  • PUT: Idempotent. Full replacement, same input = same result.
  • POST: Usually NOT idempotent. Creates new resource each time.
  • PATCH: Depends. Increment operations aren't idempotent.

The Idempotency Key Pattern

For non-idempotent operations, use a client-generated unique key:

Implementation

async function handlePayment(req: Request) {
  const idempotencyKey = req.headers['idempotency-key'];

  if (!idempotencyKey) {
    return res.status(400).json({
      error: 'Idempotency-Key header required'
    });
  }

  // Check if we've seen this key
  const existing = await db.query(`
    SELECT response, status_code
    FROM idempotency_keys
    WHERE key = $1 AND endpoint = $2
  `, [idempotencyKey, '/payments']);

  if (existing.rows[0]) {
    // Return cached response
    return res
      .status(existing.rows[0].status_code)
      .json(JSON.parse(existing.rows[0].response));
  }

  // Process the payment
  const result = await processPayment(req.body);

  // Store the response
  await db.query(`
    INSERT INTO idempotency_keys (key, endpoint, response, status_code)
    VALUES ($1, $2, $3, $4)
  `, [idempotencyKey, '/payments', JSON.stringify(result), 200]);

  return res.json(result);
}

Key Storage Schema

CREATE TABLE idempotency_keys (
  key VARCHAR(255) NOT NULL,
  endpoint VARCHAR(255) NOT NULL,
  response JSONB NOT NULL,
  status_code INT NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  PRIMARY KEY (key, endpoint)
);

-- Clean up old keys periodically
CREATE INDEX idx_created_at ON idempotency_keys(created_at);

Handle In-Progress Requests

What if the same request comes while we're still processing?

async function withIdempotency(key: string, fn: () => Promise<any>) {
  // Try to acquire lock
  const lock = await db.query(`
    INSERT INTO idempotency_keys (key, status)
    VALUES ($1, 'processing')
    ON CONFLICT (key) DO NOTHING
    RETURNING key
  `, [key]);

  if (!lock.rows[0]) {
    // Key exists - check if done or still processing
    const existing = await db.query(`
      SELECT status, response FROM idempotency_keys WHERE key = $1
    `, [key]);

    if (existing.rows[0].status === 'processing') {
      throw new ConflictError('Request in progress');
    }

    return JSON.parse(existing.rows[0].response);
  }

  try {
    const result = await fn();

    await db.query(`
      UPDATE idempotency_keys
      SET status = 'complete', response = $1
      WHERE key = $2
    `, [JSON.stringify(result), key]);

    return result;
  } catch (error) {
    // Clean up on failure so client can retry
    await db.query(`DELETE FROM idempotency_keys WHERE key = $1`, [key]);
    throw error;
  }
}

Client-Side Best Practices

Generate keys that are:

  • Unique: UUIDs work great
  • Stable: Same action = same key
// Generate once, reuse on retries
const idempotencyKey = `order-${orderId}-${Date.now()}`;

async function createOrderWithRetry(order: Order) {
  for (let i = 0; i < 3; i++) {
    try {
      return await fetch('/api/orders', {
        method: 'POST',
        headers: {
          'Idempotency-Key': idempotencyKey
        },
        body: JSON.stringify(order)
      });
    } catch (e) {
      if (i === 2) throw e;
      await sleep(1000 * Math.pow(2, i));
    }
  }
}

Key Expiration

Dont store keys forever. 24 hours is usually enough:

// Cleanup job
async function cleanupOldKeys() {
  await db.query(`
    DELETE FROM idempotency_keys
    WHERE created_at < NOW() - INTERVAL '24 hours'
  `);
}

// Run daily
cron.schedule('0 3 * * *', cleanupOldKeys);

Further Reading

Idempotency isnt optional for financial operations. But even for non-critical endpoints, it makes your API more robust and your clients' lives easier. A little upfront work saves a lot of debugging later.

© 2026 Tawan. All rights reserved.