API Idempotency: Complete Implementation Guide for Production APIs

by API Status Check Team
Staff Pick

📡 Monitor your APIs — know when they go down before your users do

Better Stack checks uptime every 30 seconds with instant Slack, email & SMS alerts. Free tier available.

Start Free →

Affiliate link — we may earn a commission at no extra cost to you

API Idempotency: Complete Implementation Guide for Production APIs

Idempotency is a critical property for building reliable APIs. An idempotent operation can be performed multiple times without changing the result beyond the initial application. This guide covers everything you need to implement idempotency correctly in production.

Table of Contents

  1. What is Idempotency?
  2. Why Idempotency Matters
  3. When to Use Idempotency
  4. Idempotency Keys
  5. Server-Side Implementation
  6. Client-Side Implementation
  7. Database Patterns
  8. Real-World Examples
  9. Testing Idempotency
  10. Common Mistakes
  11. Production Checklist

Why Idempotency Matters

1. Network Failures

Network issues can cause requests to be sent multiple times:

Client sends payment request → Network timeout (no response)
↓
Client retries → Server processed original request
↓
Result: Double charge without idempotency ❌
Result: Single charge with idempotency ✅

2. Duplicate Prevention

Without idempotency:

  • Double charges: User clicks "Pay" twice, gets charged twice
  • Duplicate orders: Order submitted multiple times creates inventory issues
  • Inconsistent state: Retry logic creates conflicting records

3. Real-World Impact

Shopify (2019): A bug caused some customers to be charged 100+ times for a single purchase. Proper idempotency would have prevented this.

Stripe: Processes billions in payments—idempotency is mandatory for all payment operations.


🔐 Protect your API keys and credentials. Idempotency is only secure if your API keys are. 1Password stores and auto-fills your API keys, database credentials, and secrets across all devices — with vault sharing for your team.

When to Use Idempotency

Operations That NEED Idempotency

Payment processing

  • Charging credit cards
  • Creating invoices
  • Processing refunds

Order creation

  • E-commerce checkouts
  • Subscription signups
  • Reservation systems

State-changing webhooks

  • Processing webhook events
  • Third-party integrations
  • Event-driven workflows

Distributed transactions

  • Microservice communication
  • Saga patterns
  • Event sourcing

Operations That DON'T Need Idempotency

Read operations (GET, search, list) ❌ Analytics events (logging, tracking—duplicates are acceptable) ❌ Fire-and-forget notifications (email sends where duplication is tolerable)


Idempotency Keys

The standard pattern is to use idempotency keys—unique identifiers sent by the client with each request.

How It Works

1. Client generates unique key (UUID)
2. Client sends request with Idempotency-Key header
3. Server checks if key exists
   - If yes: Return cached response (don't process again)
   - If no: Process request, cache response with key
4. Return response

Header Format

Most APIs use a custom header:

POST /payments
Idempotency-Key: a7f8d9e2-3c4b-5d6e-7f8g-9h0i1j2k3l4m
Content-Type: application/json

{
  "amount": 5000,
  "currency": "usd"
}

Key Generation (Client-Side)

import { v4 as uuidv4 } from 'uuid';

// Generate a new idempotency key
const idempotencyKey = uuidv4();
// Example: "a7f8d9e2-3c4b-5d6e-7f8g-9h0i1j2k3l4m"

// Use it in your API call
await fetch('https://api.example.com/payments', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: JSON.stringify({ amount: 5000, currency: 'usd' }),
});

Key Requirements:

  • Must be unique per operation (UUIDs are perfect)
  • Should be generated before the request (not on retry)
  • Should be persisted (in case client crashes before receiving response)

Server-Side Implementation

Complete TypeScript/Express Example

import express from 'express';
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';

const app = express();
const prisma = new PrismaClient();

app.use(express.json());

// Idempotency middleware
const idempotencyMiddleware = async (
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) => {
  const idempotencyKey = req.headers['idempotency-key'] as string;

  // Only check for POST/PATCH (state-changing operations)
  if (!['POST', 'PATCH'].includes(req.method)) {
    return next();
  }

  // Idempotency key is optional but recommended
  if (!idempotencyKey) {
    return next(); // Proceed without idempotency check
  }

  // Validate key format (UUID)
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
  if (!uuidRegex.test(idempotencyKey)) {
    return res.status(400).json({
      error: 'Invalid idempotency key format. Must be a UUID v4.',
    });
  }

  try {
    // Check if we've seen this key before
    const existing = await prisma.idempotencyKey.findUnique({
      where: { key: idempotencyKey },
    });

    if (existing) {
      // Key exists—return cached response
      if (existing.status === 'processing') {
        // Request is still being processed (concurrent request)
        return res.status(409).json({
          error: 'Request with this idempotency key is already being processed',
        });
      }

      // Return cached response
      return res
        .status(existing.responseStatus)
        .set(existing.responseHeaders as Record<string, string{'>'}
        .send(existing.responseBody);
    }

    // New key—create a processing record
    await prisma.idempotencyKey.create({
      data: {
        key: idempotencyKey,
        status: 'processing',
        requestMethod: req.method,
        requestPath: req.path,
        requestBody: req.body,
        createdAt: new Date(),
      },
    });

    // Store the key in res.locals for later use
    res.locals.idempotencyKey = idempotencyKey;
    next();
  } catch (error) {
    console.error('Idempotency check failed:', error);
    return res.status(500).json({ error: 'Internal server error' });
  }
};

// Response caching interceptor
const cacheIdempotentResponse = async (
  req: express.Request,
  res: express.Response,
  originalSend: any
) => {
  const idempotencyKey = res.locals.idempotencyKey;
  if (!idempotencyKey) {
    return originalSend.call(res);
  }

  // Intercept the response
  const originalJson = res.json.bind(res);
  res.json = function (body: any) {
    // Cache the response
    prisma.idempotencyKey
      .update({
        where: { key: idempotencyKey },
        data: {
          status: 'completed',
          responseStatus: res.statusCode,
          responseHeaders: res.getHeaders(),
          responseBody: body,
          completedAt: new Date(),
        },
      })
      .catch((err) => console.error('Failed to cache response:', err));

    return originalJson(body);
  };
};

// Apply middleware
app.use(idempotencyMiddleware);

// Example: Payment endpoint
app.post('/payments', async (req, res) => {
  const { amount, currency, customerId } = req.body;

  // Validate input
  if (!amount || !currency || !customerId) {
    return res.status(400).json({ error: 'Missing required fields' });
  }

  try {
    // Process payment (idempotent operation)
    const payment = await prisma.payment.create({
      data: {
        amount,
        currency,
        customerId,
        status: 'succeeded',
        createdAt: new Date(),
      },
    });

    // Charge the customer (external API call)
    // await stripeClient.charges.create({ ... });

    return res.status(201).json({
      id: payment.id,
      amount: payment.amount,
      currency: payment.currency,
      status: payment.status,
    });
  } catch (error) {
    console.error('Payment processing failed:', error);
    return res.status(500).json({ error: 'Payment failed' });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

Key Implementation Details

  1. Check before processing: Look up the idempotency key before doing any work
  2. Handle concurrent requests: Return 409 Conflict if the same key is being processed
  3. Cache the response: Store the response with the key for future lookups
  4. Set expiration: Keys should expire after 24-48 hours (see database schema)

Client-Side Implementation

Retry Logic with Idempotency

interface PaymentRequest {
  amount: number;
  currency: string;
  customerId: string;
}

async function createPaymentWithRetry(
  payment: PaymentRequest,
  maxRetries = 3
): Promise<any{'>'} {
  // Generate idempotency key ONCE (before first attempt)
  const idempotencyKey = uuidv4();

  let lastError: Error | null = null;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch('https://api.example.com/payments', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey, // Same key on every retry
        },
        body: JSON.stringify(payment),
      });

      if (!response.ok) {
        if (response.status === 409) {
          // Concurrent request—wait and retry
          await sleep(1000 * attempt);
          continue;
        }

        if (response.status {'>'}= 500) {
          // Server error—retry
          await sleep(1000 * Math.pow(2, attempt)); // Exponential backoff
          continue;
        }

        // Client error (4xx)—don't retry
        throw new Error(`Payment failed: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      lastError = error as Error;

      if (attempt === maxRetries) {
        break; // Max retries reached
      }

      // Network error—retry with exponential backoff
      await sleep(1000 * Math.pow(2, attempt));
    }
  }

  throw new Error(
    `Payment failed after ${maxRetries} attempts: ${lastError?.message}`
  );
}

function sleep(ms: number): Promise<void{'>'} {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// Usage
try {
  const payment = await createPaymentWithRetry({
    amount: 5000,
    currency: 'usd',
    customerId: 'cus_123',
  });
  console.log('Payment succeeded:', payment);
} catch (error) {
  console.error('Payment failed:', error);
}

Key Client-Side Patterns

  1. Generate key before first attempt (not on each retry)
  2. Use the same key for all retries (that's the whole point!)
  3. Handle 409 Conflict (concurrent request with same key)
  4. Implement exponential backoff for retries
  5. Persist the key (in case client crashes before response)

Database Patterns

Schema for Idempotency Keys

// schema.prisma
model IdempotencyKey {
  id             String   @id @default(cuid())
  key            String   @unique // The idempotency key (UUID)
  status         String   // 'processing' | 'completed' | 'failed'
  
  // Request details
  requestMethod  String   // POST, PATCH, etc.
  requestPath    String   // /payments, /orders, etc.
  requestBody    Json?    // Original request body
  
  // Response cache
  responseStatus Int?     // 200, 201, 400, etc.
  responseHeaders Json?   // Response headers
  responseBody   Json?    // Cached response
  
  // Timestamps
  createdAt      DateTime @default(now())
  completedAt    DateTime?
  expiresAt      DateTime // Auto-delete after 24-48h
  
  @@index([key])
  @@index([expiresAt])
}

Automatic Cleanup

// Cron job to delete expired keys
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function cleanupExpiredKeys() {
  const result = await prisma.idempotencyKey.deleteMany({
    where: {
      expiresAt: {
        lt: new Date(), // Delete keys older than expiration
      },
    },
  });

  console.log(`Deleted ${result.count} expired idempotency keys`);
}

// Run every hour
setInterval(cleanupExpiredKeys, 60 * 60 * 1000);

Alternative: Unique Constraints

For simple cases, you can use database unique constraints:

model Payment {
  id              String   @id @default(cuid())
  idempotencyKey  String   @unique // Enforce uniqueness at DB level
  amount          Int
  currency        String
  customerId      String
  status          String
  createdAt       DateTime @default(now())
  
  @@index([idempotencyKey])
}
// Create payment with idempotency
app.post('/payments', async (req, res) => {
  const { amount, currency, customerId } = req.body;
  const idempotencyKey = req.headers['idempotency-key'] as string;

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

  try {
    const payment = await prisma.payment.create({
      data: {
        idempotencyKey, // Will fail if key already exists
        amount,
        currency,
        customerId,
        status: 'succeeded',
      },
    });

    return res.status(201).json(payment);
  } catch (error: any) {
    if (error.code === 'P2002') {
      // Unique constraint violation—key already used
      const existing = await prisma.payment.findUnique({
        where: { idempotencyKey },
      });
      return res.status(200).json(existing); // Return existing payment
    }

    throw error;
  }
});

Pros:

  • Simpler implementation
  • Database enforces uniqueness

Cons:

  • Less control over response caching
  • Harder to handle concurrent requests
  • Can't distinguish between "processing" and "completed"

Real-World Examples

Stripe API

Stripe requires idempotency keys for all POST requests:

curl https://api.stripe.com/v1/charges \
  -u sk_test_YOUR_KEY: \
  -H "Idempotency-Key: a7f8d9e2-3c4b-5d6e-7f8g-9h0i1j2k3l4m" \
  -d amount=5000 \
  -d currency=usd \
  -d source=tok_visa

Stripe's Implementation:

  • Keys expire after 24 hours
  • Returns 409 Conflict for concurrent requests with same key
  • Caches the entire response (status, headers, body)
  • Supports all POST operations (charges, refunds, customers, subscriptions)

GitHub API

GitHub uses idempotency for webhook deliveries:

{
  "id": "12345",
  "event": "push",
  "delivery_id": "a7f8d9e2-3c4b-5d6e-7f8g-9h0i1j2k3l4m",
  "action": "created"
}

The delivery_id acts as an idempotency key—if GitHub retries the webhook, you can detect duplicates.

AWS S3

AWS S3 PUT operations are naturally idempotent:

# Uploading the same file multiple times produces the same result
aws s3 cp file.txt s3://bucket/file.txt
aws s3 cp file.txt s3://bucket/file.txt # Same result

Testing Idempotency

Unit Tests

import request from 'supertest';
import { app } from './app';
import { v4 as uuidv4 } from 'uuid';

describe('Payment Idempotency', () => {
  it('should create payment on first request', async () => {
    const idempotencyKey = uuidv4();

    const res = await request(app)
      .post('/payments')
      .set('Idempotency-Key', idempotencyKey)
      .send({
        amount: 5000,
        currency: 'usd',
        customerId: 'cus_123',
      });

    expect(res.status).toBe(201);
    expect(res.body).toHaveProperty('id');
    expect(res.body.amount).toBe(5000);
  });

  it('should return cached response for duplicate request', async () => {
    const idempotencyKey = uuidv4();

    // First request
    const res1 = await request(app)
      .post('/payments')
      .set('Idempotency-Key', idempotencyKey)
      .send({
        amount: 5000,
        currency: 'usd',
        customerId: 'cus_123',
      });

    expect(res1.status).toBe(201);
    const paymentId = res1.body.id;

    // Second request with same key
    const res2 = await request(app)
      .post('/payments')
      .set('Idempotency-Key', idempotencyKey)
      .send({
        amount: 5000,
        currency: 'usd',
        customerId: 'cus_123',
      });

    expect(res2.status).toBe(201);
    expect(res2.body.id).toBe(paymentId); // Same payment ID
  });

  it('should reject invalid idempotency key format', async () => {
    const res = await request(app)
      .post('/payments')
      .set('Idempotency-Key', 'invalid-key')
      .send({
        amount: 5000,
        currency: 'usd',
        customerId: 'cus_123',
      });

    expect(res.status).toBe(400);
    expect(res.body.error).toContain('Invalid idempotency key');
  });

  it('should handle concurrent requests with same key', async () => {
    const idempotencyKey = uuidv4();

    // Send two requests concurrently
    const [res1, res2] = await Promise.all([
      request(app)
        .post('/payments')
        .set('Idempotency-Key', idempotencyKey)
        .send({ amount: 5000, currency: 'usd', customerId: 'cus_123' }),
      request(app)
        .post('/payments')
        .set('Idempotency-Key', idempotencyKey)
        .send({ amount: 5000, currency: 'usd', customerId: 'cus_123' }),
    ]);

    // One should succeed (201), one should get 409 Conflict
    const statuses = [res1.status, res2.status].sort();
    expect(statuses).toEqual([201, 409]);
  });
});

Load Testing

import { v4 as uuidv4 } from 'uuid';

async function loadTestIdempotency() {
  const idempotencyKey = uuidv4();
  const concurrentRequests = 100;

  // Send 100 requests with the same idempotency key
  const promises = Array.from({ length: concurrentRequests }, () =>
    fetch('https://api.example.com/payments', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey,
      },
      body: JSON.stringify({
        amount: 5000,
        currency: 'usd',
        customerId: 'cus_123',
      }),
    })
  );

  const responses = await Promise.all(promises);

  // Count unique payment IDs
  const paymentIds = new Set(
    await Promise.all(
      responses.map(async (r) => {
        const data = await r.json();
        return data.id;
      })
    )
  );

  console.log(`${concurrentRequests} requests created ${paymentIds.size} payments`);
  // Expected: 1 payment created (all others returned cached response)
}

Common Mistakes

1. ❌ Generating New Key on Retry

// WRONG: New key on each retry
for (let attempt = 0; attempt < 3; attempt++) {
  const idempotencyKey = uuidv4(); // ❌ New key every time
  await createPayment({ idempotencyKey });
}

// CORRECT: Same key for all retries
const idempotencyKey = uuidv4(); // ✅ Generate once
for (let attempt = 0; attempt < 3; attempt++) {
  await createPayment({ idempotencyKey }); // ✅ Same key
}

2. ❌ Not Validating Key Format

// WRONG: Accept any string
const idempotencyKey = req.headers['idempotency-key'];

// CORRECT: Validate UUID format
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!/^[0-9a-f-]{36}$/i.test(idempotencyKey)) {
  return res.status(400).json({ error: 'Invalid key format' });
}

3. ❌ Making Idempotency Optional for Critical Operations

// WRONG: Idempotency is optional for payments
app.post('/payments', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key']; // May be undefined
  // Process payment anyway ❌
});

// CORRECT: Require idempotency for payments
app.post('/payments', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'];
  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Idempotency-Key required' });
  }
  // Process payment ✅
});

4. ❌ Not Handling Concurrent Requests

// WRONG: No concurrent request handling
const existing = await db.findKey(idempotencyKey);
if (existing) {
  return existing.response; // ✅ Return cached
}

// Process request... (multiple requests could reach here simultaneously) ❌

// CORRECT: Lock or return 409 for concurrent requests
const existing = await db.findKey(idempotencyKey);
if (existing && existing.status === 'processing') {
  return res.status(409).json({ error: 'Request in progress' }); // ✅
}

5. ❌ Storing Keys Forever

// WRONG: No expiration
model IdempotencyKey {
  key String @unique
  // No expiresAt field ❌
}

// CORRECT: Auto-expire after 24-48 hours
model IdempotencyKey {
  key       String   @unique
  expiresAt DateTime // ✅ Automatic cleanup
}

6. ❌ Not Testing with Production Traffic Patterns

Many idempotency bugs only appear under:

  • High concurrent requests (race conditions)
  • Network flakiness (retry storms)
  • Database replication lag (stale reads)

Always load test with realistic traffic before going to production.

7. ❌ Caching Error Responses

// WRONG: Cache all responses (including errors)
await cacheResponse(idempotencyKey, response); // ❌ Caches 500 errors

// CORRECT: Only cache successful responses
if (response.status < 400) {
  await cacheResponse(idempotencyKey, response); // ✅
}

Why? If a request fails due to a transient error (DB timeout, external API down), you want the client to retry—not get a cached error.


Production Checklist

Implementation

  • ✅ Idempotency keys required for all POST/PATCH operations
  • ✅ UUIDs validated (reject invalid formats)
  • ✅ Response caching implemented (status, headers, body)
  • ✅ Concurrent request handling (return 409 Conflict if processing)
  • ✅ Key expiration configured (24-48 hours)
  • ✅ Automatic cleanup job running (delete expired keys)
  • ✅ Database indexes on key column for fast lookups
  • ✅ Error responses NOT cached (only successful responses)

Client-Side

  • ✅ Keys generated before first request (not on retry)
  • ✅ Same key used for all retries
  • ✅ Keys persisted (in case client crashes)
  • ✅ Exponential backoff implemented
  • 409 Conflict handled (wait and retry)

Monitoring

  • ✅ Track idempotency key hit rate (how often keys are reused)
  • ✅ Alert on high 409 Conflict rate (indicates retry storms)
  • ✅ Monitor key cache size (shouldn't grow unbounded)
  • ✅ Track key expiration job success

📡 Monitor idempotency key collisions and API retry rates. Better Stack combines uptime monitoring, incident management, and log aggregation — catch duplicate request issues before they become payment problems. Free tier available.

Testing

  • ✅ Unit tests for duplicate requests
  • ✅ Unit tests for concurrent requests
  • ✅ Unit tests for invalid keys
  • ✅ Load tests with realistic traffic patterns
  • ✅ Chaos testing (simulate network failures, retries)

Documentation

  • ✅ API docs explain idempotency requirements
  • ✅ Header format documented (Idempotency-Key: UUID)
  • ✅ Response codes documented (200, 201, 409, etc.)
  • ✅ Client retry examples provided

Related Resources


🛡️ Building payment APIs? Protect your personal data too. When your name is attached to production systems processing real transactions, your personal data exposure matters. Optery removes your personal information from 350+ data broker sites — reducing your exposure to targeted phishing.

Conclusion

Idempotency is non-negotiable for production APIs that handle payments, orders, or any state-changing operation. The cost of getting it wrong—double charges, duplicate orders, data corruption—far outweighs the implementation effort.

Key Takeaways:

  1. Use idempotency keys (UUIDs) for all POST/PATCH operations
  2. Generate keys on the client before the first request
  3. Use the same key for retries (that's the entire point)
  4. Cache successful responses (status, headers, body)
  5. Handle concurrent requests (return 409 Conflict if processing)
  6. Expire keys after 24-48 hours (prevent unbounded growth)
  7. Test with production traffic patterns (concurrent requests, retries)

With proper idempotency, your API can handle network failures, retries, and duplicate requests gracefully—without creating duplicate charges or inconsistent state.

Need help monitoring your third-party APIs? API Status Check tracks 160+ APIs including Stripe, PayPal, AWS, GitHub, and more. Get real-time status updates and embeddable badges for your docs.

🛠 Tools We Use & Recommend

Tested across our own infrastructure monitoring 200+ APIs daily

Better StackBest for API Teams

Uptime Monitoring & Incident Management

Used by 100,000+ websites

Monitors your APIs every 30 seconds. Instant alerts via Slack, email, SMS, and phone calls when something goes down.

We use Better Stack to monitor every API on this site. It caught 23 outages last month before users reported them.

Free tier · Paid from $24/moStart Free Monitoring
1PasswordBest for Credential Security

Secrets Management & Developer Security

Trusted by 150,000+ businesses

Manage API keys, database passwords, and service tokens with CLI integration and automatic rotation.

After covering dozens of outages caused by leaked credentials, we recommend every team use a secrets manager.

SEMrushBest for SEO

SEO & Site Performance Monitoring

Used by 10M+ marketers

Track your site health, uptime, search rankings, and competitor movements from one dashboard.

We use SEMrush to track how our API status pages rank and catch site health issues early.

From $129.95/moTry SEMrush Free
View full comparison & more tools →Affiliate links — we earn a commission at no extra cost to you

API Status Check

Stop checking API status pages manually

Get instant email alerts when OpenAI, Stripe, AWS, and 100+ APIs go down. Know before your users do.

Start Free Trial →

14-day free trial · $0 due today · $9/mo after · Cancel anytime

Browse Free Dashboard →