Skip to main content

API Idempotency: Preventing Duplicate Operations in Payment Flows

June 2, 2026

</>

Here is a scenario every production application eventually faces: a user clicks "Pay Now." The request leaves the browser, reaches your server, the charge is processed successfully — but the network drops before the response returns. The browser shows an error. The user clicks "Pay Now" again. Your server processes the charge a second time. The user is billed twice.

Idempotency is the API design property that prevents this. An idempotent operation can be executed multiple times and will always produce the same result as if it had been executed once. For payment processing and any mutation that has real-world side effects, idempotency is not an optimization — it is a correctness requirement.


The Idempotency Key Pattern

The standard solution used by Stripe, Braintree, and every major payment processor is the idempotency key: a unique identifier that the client generates for each logical operation and includes with every request. The server uses this key to detect and suppress duplicate requests:

IDEMPOTENCY FLOW:

Request 1 (first attempt):
  POST /api/payments { amount: 100, idempotencyKey: "order-123-attempt-1" }
   Server: Key not seen before  Process payment  Store result  Return success

Request 2 (retry after network failure):
  POST /api/payments { amount: 100, idempotencyKey: "order-123-attempt-1" }
   Server: Key seen before  Return cached result (no charge)  Return success

The client generates the key before the first request. The server stores the key and the result. Retries with the same key get the same result without re-processing.


Client-Side: Generating Idempotency Keys

// lib/api-client.ts
import { randomUUID } from 'crypto';

// Generate a key per user action — not per request
// The key is stable across retries for the same action
export function generateIdempotencyKey(prefix: string): string {
  return `${prefix}-${randomUUID()}`;
}

// Store the key per session action so retries use the same key
export function getOrCreateCheckoutKey(orderId: string): string {
  const storageKey = `checkout-key-${orderId}`;

  let key = sessionStorage.getItem(storageKey);
  if (!key) {
    key = generateIdempotencyKey(`checkout-${orderId}`);
    sessionStorage.setItem(storageKey, key);
  }

  return key;
}

// Retry logic with exponential backoff
export async function fetchWithRetry(
  url: string,
  options: RequestInit,
  retries = 3
): Promise<Response> {
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      const response= await fetch(url, options);

      // Don't retry on client errors (4xx) — only on server errors (5xx) or network failures
      if (response.status < 500) return response;

      if (attempt < retries - 1) {
        await new Promise(resolve=> setTimeout(resolve, 2 ** attempt * 1000));
      }
    } catch (error) {
      if (attempt= retries - 1) throw error;
      await new Promise(resolve=> setTimeout(resolve, 2 ** attempt * 1000));
    }
  }
  throw new Error('Max retries exceeded');
}

// Usage in checkout flow
async function processPayment(orderId: string, amount: number) {
  const idempotencyKey= getOrCreateCheckoutKey(orderId);

  const response= await fetchWithRetry('/api/payments', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({ orderId, amount }),
  });

  // After success, clear the key so a new purchase generates a fresh key
  if (response.ok) {
    sessionStorage.removeItem(`checkout-key-${orderId}`);
  }

  return response.json();
}

Server-Side: Storing and Checking Idempotency Keys

// app/api/payments/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function POST(request: Request) {
  const idempotencyKey = request.headers.get('Idempotency-Key');

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

  // 1. Check if we've seen this key before
  const existing = await db.query(
    'SELECT result, status FROM idempotency_store WHERE key = $1 AND expires_at > NOW()',
    [idempotencyKey]
  );

  if (existing.rows.length > 0) {
    const cached = existing.rows[0];

    if (cached.status === 'processing') {
      // The original request is still in progress — return 409
      return NextResponse.json(
        { error: 'Request is still being processed. Please wait and retry.' },
        { status: 409 }
      );
    }

    // Return the cached result
    return NextResponse.json(
      JSON.parse(cached.result),
      { status: 200, headers: { 'Idempotent-Replayed': 'true' } }
    );
  }

  // 2. Mark as processing to prevent concurrent duplicate requests
  await db.query(
    `INSERT INTO idempotency_store (key, status, expires_at)
     VALUES ($1, 'processing', NOW() + INTERVAL '24 hours')`,
    [idempotencyKey]
  );

  try {
    // 3. Process the payment
    const { orderId, amount } = await request.json();
    const session = await getAuthSession();

    if (!session) {
      await clearIdempotencyKey(idempotencyKey);
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const payment = await processStripePayment({ orderId, amount, userId: session.user.id });

    const result = { success: true, paymentId: payment.id, status: payment.status };

    // 4. Store the result and mark as complete
    await db.query(
      `UPDATE idempotency_store
       SET status = 'completed', result = $2
       WHERE key = $1`,
      [idempotencyKey, JSON.stringify(result)]
    );

    return NextResponse.json(result);

  } catch (error) {
    // 5. On error, clear the key so the client can retry
    await clearIdempotencyKey(idempotencyKey);
    throw error;
  }
}

async function clearIdempotencyKey(key: string) {
  await db.query('DELETE FROM idempotency_store WHERE key = $1', [key]);
}

The Idempotency Store Table

CREATE TABLE idempotency_store (
  key         TEXT PRIMARY KEY,
  status      TEXT NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
  result      JSONB,
  created_at  TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  expires_at  TIMESTAMP WITH TIME ZONE NOT NULL
);

-- Index for expiry cleanup
CREATE INDEX idx_idempotency_expires ON idempotency_store (expires_at);

-- Cleanup job: delete expired keys daily
-- Add this as a cron job or scheduled task:
-- DELETE FROM idempotency_store WHERE expires_at < NOW();

Database-Level Idempotency with Unique Constraints

For database inserts, idempotency can be enforced at the schema level:

-- Prevent duplicate orders for the same user and session
CREATE TABLE orders (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL,
  idempotency_key TEXT NOT NULL,
  amount          DECIMAL(10,2) NOT NULL,
  status          TEXT NOT NULL,
  created_at      TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  
  -- Unique constraint prevents duplicate inserts
  UNIQUE (user_id, idempotency_key)
);
// Use INSERT ... ON CONFLICT DO NOTHING for database-level idempotency
const result = await db.query(
  `INSERT INTO orders (user_id, idempotency_key, amount, status)
   VALUES ($1, $2, $3, 'pending')
   ON CONFLICT (user_id, idempotency_key) DO UPDATE
   SET updated_at = NOW()
   RETURNING *`,
  [userId, idempotencyKey, amount]
);
// Whether it's the first or 100th request, you get the same order back

Stripe's Built-In Idempotency

When using Stripe, pass the idempotency key directly to the SDK:

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const paymentIntent = await stripe.paymentIntents.create(
  {
    amount: 2000,
    currency: 'usd',
    customer: stripeCustomerId,
  },
  {
    idempotencyKey: `payment-intent-${orderId}-${userId}`,
  }
);
// Stripe guarantees: same idempotency key = same PaymentIntent, no duplicate charge

Conclusion

Idempotency is the engineering discipline that makes distributed systems safe under the conditions that are guaranteed to occur in production: network timeouts, client retries, double-submits, and infrastructure restarts. The pattern is always the same: client generates a unique key before the first attempt, sends it with every retry, server uses the key to detect and suppress duplicates. For anything with a real-world side effect — payments, emails, order creation, account changes — idempotency is not a feature. It is a correctness requirement.

Recommended Posts