Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 014 / 100

Idempotency in your own APIs: what developers should know

Protect checkout endpoints from double submits.

Expona Admin8 min read

The risk of duplicate charges

Payment processing is unforgiving. A network hiccup, a browser refresh, or a race condition between your frontend and backend can trigger the same request twice—and if your checkout endpoint isn't defended, you'll process two charges instead of one. Idempotency in your own APIs: what developers should know is critical for any team handling financial transactions, because the cost of a mistake here is both technical and human. This article walks through building idempotent endpoints so that retries and duplicates land safely.

What idempotency actually means

Idempotency is a mathematical concept that's become essential in distributed systems: an operation is idempotent if calling it multiple times with the same inputs produces the same result as calling it once, with no side effects on subsequent calls.

In payment terms, this means:

  • First request with ID abc123 creates a charge and returns a receipt.
  • Second request with the same ID abc123 returns the same receipt, no new charge.
  • Third request with the same ID returns the same receipt again.

Without idempotency, your checkout endpoint becomes a gun pointed at your customers' wallets. Stripe itself is idempotent—if you send the same Idempotency-Key to Stripe twice, it will only charge once. We need the same guarantee in our own systems, especially for the orchestration layer that sits between our frontend and payment provider.

Accept an Idempotency-Key header

The client (mobile app, web browser, or backend service) generates a unique key and sends it with every request. By convention, we call this the Idempotency-Key header. The key is typically a UUID, and it stays the same across retries.

Here's a middleware that enforces this pattern:

typescript
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';

export function idempotencyMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Allow client to provide key, or generate one for tracking
  const idempotencyKey = req.headers['idempotency-key'] as string || uuidv4();
  
  if (!idempotencyKey) {
    return res.status(400).json({
      error: 'Missing Idempotency-Key header'
    });
  }
  
  // Attach to request for later use
  (req as any).idempotencyKey = idempotencyKey;
  
  // Store in response headers so client sees it
  res.set('Idempotency-Key', idempotencyKey);
  
  next();
}

On the frontend, this is straightforward. Generate a UUID once per user action, and include it:

typescript
async function submitCheckout(cartData) {
  const idempotencyKey = generateUUID(); // crypto.randomUUID() in modern browsers
  
  const response = await fetch('/api/checkout', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey
    },
    body: JSON.stringify(cartData)
  });
  
  return response.json();
}

This is a contract: the client commits to sending the same key on retries, and the server commits to treating those retries as idempotent.

Store the response per key

Once a request completes, we cache the entire response keyed by the idempotency key. If the same key arrives again, we return the cached response instead of re-executing business logic.

A simple in-memory approach works for small-scale systems (or development), but for production, we use a persistent store like Redis or PostgreSQL:

typescript
import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT
});

interface CachedResponse {
  statusCode: number;
  body: any;
  timestamp: number;
}

async function cacheResponse(
  idempotencyKey: string,
  statusCode: number,
  body: any
): Promise {
  const cached: CachedResponse = {
    statusCode,
    body,
    timestamp: Date.now()
  };
  
  // Expire after 24 hours to avoid unbounded growth
  await redis.setex(
    `idempotency:${idempotencyKey}`,
    86400,
    JSON.stringify(cached)
  );
}

async function getCachedResponse(
  idempotencyKey: string
): Promise {
  const raw = await redis.get(`idempotency:${idempotencyKey}`);
  
  if (!raw) return null;
  
  try {
    return JSON.parse(raw);
  } catch (e) {
    return null;
  }
}

The key design decision: expire these cached responses. If you cache forever, your storage grows unbounded. A 24-hour TTL is reasonable for most checkout flows—if a customer retries after a day, a new charge is defensible. Adjust based on your SLA and legal requirements.

Return the stored response on retry

Now we wrap the checkout endpoint itself. Before executing any business logic, check if we've already processed this key:

typescript
app.post('/api/checkout', idempotencyMiddleware, async (req, res) => {
  const idempotencyKey = (req as any).idempotencyKey;
  
  // Check for cached response
  const cached = await getCachedResponse(idempotencyKey);
  if (cached) {
    return res.status(cached.statusCode).json(cached.body);
  }
  
  // Acquire lock (see next section)
  const lock = await acquireLock(idempotencyKey);
  if (!lock) {
    // Request is already in flight, wait and retry
    return res.status(409).json({
      error: 'Request already in progress'
    });
  }
  
  try {
    // Your actual checkout logic
    const charge = await stripe.charges.create({
      amount: req.body.amount,
      currency: req.body.currency,
      source: req.body.token,
      idempotency_key: idempotencyKey // Also send to Stripe!
    });
    
    const responseBody = {
      chargeId: charge.id,
      amount: charge.amount,
      status: charge.status
    };
    
    // Cache successful response
    await cacheResponse(idempotencyKey, 200, responseBody);
    
    res.status(200).json(responseBody);
  } catch (error) {
    // Cache error response too
    const errorBody = {
      error: error.message,
      code: error.code
    };
    
    await cacheResponse(idempotencyKey, error.statusCode || 400, errorBody);
    
    res.status(error.statusCode || 400).json(errorBody);
  } finally {
    await releaseLock(idempotencyKey);
  }
});

Notice that we cache both success and error responses. If the first attempt failed (e.g., card declined), the second attempt with the same key should also return the same error, not retry the charge. This prevents accidental retries that might succeed and cause duplicate charges.

Lock while the first request runs

Here's the subtlety: what if two requests with the same idempotency key arrive simultaneously before either has cached their response? Without a lock, both will process the checkout, resulting in a double charge.

We use a distributed lock to ensure only one request with a given key can execute at a time:

typescript
async function acquireLock(idempotencyKey: string): Promise {
  const lockKey = `lock:${idempotencyKey}`;
  const lockValue = uuidv4();
  
  // SET NX EX: set only if not exists, expire in 30 seconds
  const result = await redis.set(
    lockKey,
    lockValue,
    'NX',
    'EX',
    30
  );
  
  // If set() returns 'OK', we acquired the lock
  return result === 'OK';
}

async function releaseLock(idempotencyKey: string): Promise {
  const lockKey = `lock:${idempotencyKey}`;
  await redis.del(lockKey);
}

The lock has a TTL of 30 seconds. If a request crashes mid-checkout, the lock auto-releases instead of deadlocking subsequent requests. If a second request arrives while the first holds the lock, we return a 409 Conflict, signaling the client to back off and retry.

For a better user experience, the client should wait and retry automatically:

typescript
async function submitCheckoutWithRetry(
  cartData,
  maxAttempts = 5,
  backoffMs = 500
) {
  const idempotencyKey = generateUUID();
  
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const response = await fetch('/api/checkout', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey
      },
      body: JSON.stringify(cartData)
    });
    
    if (response.status !== 409) {
      return response.json();
    }
    
    // Lock conflict; back off exponentially
    if (attempt < maxAttempts) {
      await new Promise(r => setTimeout(r, backoffMs * Math.pow(2, attempt - 1)));
    }
  }
  
  throw new Error('Checkout failed after retries');
}

Common mistakes

  • Not caching error responses: If you only cache successful responses, failed attempts will retry and potentially succeed on the second try, defeating idempotency.
  • Using session-based keys: If the idempotency key is tied to a user session (e.g., session ID), a user who logs out and back in will get a new key, allowing duplicate charges. Always use a stable, action-specific key (UUID generated per checkout attempt).
  • Forgetting to pass the key to your payment provider: Stripe, PayPal, and other gateways also support idempotency keys. Always forward your idempotency key to them. Otherwise, you're only protected at your layer, not end-to-end.
  • Infinite cache TTLs: Caching forever wastes storage. Set a reasonable expiry (24 hours is typical) and accept that very old retries might be re-processed.
  • Returning 500 when the lock is held: Returning 409 Conflict signals to the client that the request is already in flight and should be retried, not that the server is broken.

Key takeaways

  • Idempotency ensures that network retries and duplicate submits don't trigger duplicate charges.
  • Require clients to send an Idempotency-Key header; generate a UUID per action and keep it stable across retries.
  • Cache the full response (status code and body) keyed by the idempotency key, with a reasonable TTL (24 hours).
  • Use a distributed lock to prevent simultaneous execution of the same idempotency key; return 409 Conflict if the lock cannot be acquired.
  • Cache both successful and error responses to prevent accidental retries that might have different outcomes.
  • Always forward the idempotency key to your payment provider (Stripe, etc.) to extend the guarantee end-to-end.
  • Set lock TTLs to auto-expire (e.g., 30 seconds) to avoid deadlocks if a request crashes mid-execution.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles