← Back to Fixes

How to Set Up Idempotency Key in Next.js (Step by Step)

If Stripe charges run twice in your Next.js app, learn how to add an idempotency key to every request and stop duplicate payments.

TL;DR#

If you're seeing duplicate Stripe charges or the error “Idempotency key missing” in your logs, the cause is that the request to Stripe lacks a unique idempotencyKey. Fix it by generating a deterministic key (UUID or hash of the order ID) and passing it to every Stripe SDK call.

If that doesn't work, scroll to Verify the fix — there are two common variants this guide also covers.

What you'll see#

When the idempotency key is omitted, Stripe either creates a second charge on retry or returns an explicit error. A typical log entry looks like this:

text
[error] StripeError: Duplicate charge detected. 
    at Object.createCharge (/app/pages/api/create-payment.ts:45:13)
    at async Function.handler (/app/pages/api/create-payment.ts:78:5)

The problem surfaces after a user clicks “Pay” and the network hiccups, causing the client to resend the request. In development you may also see the warning:

text
[warn] Missing idempotency key for Stripe request: createCharge

It happens regardless of whether you run npm run dev locally or deploy to Vercel; the underlying Stripe SDK behaves the same way. The duplicate charge appears in the Stripe Dashboard as two separate payment intents with identical amounts and metadata.

Root cause#

Stripe’s API treats each request as a distinct operation unless you supply an idempotencyKey. When a network timeout occurs, the client library automatically retries the HTTP call. Because the retry carries no identifier tying it to the original attempt, Stripe processes it as a brand‑new request, resulting in a second charge.

In a typical Next.js API route you might call the SDK like this:

js
// pages/api/create-payment.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2022-11-15" });
 
export default async function handler(req, res) {
  const { amount, currency, paymentMethodId } = req.body;
  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency,
    payment_method: paymentMethodId,
    confirm: true,
  });
  res.status(200).json({ clientSecret: paymentIntent.client_secret });
}

Notice the absence of an idempotencyKey. The SDK therefore cannot deduplicate retries. The fix is to generate a key that is stable for the same business operation (e.g., the order ID) and pass it as the third argument to the SDK method.

The fix#

Below is a minimal change that adds a deterministic idempotency key based on a Supabase‑backed orders table. The key is a UUID generated once when the order row is created, stored in the idempotency_key column, and then read by the API route.

ts
// lib/idempotency.ts
import { createClient } from "@supabase/supabase-js";
import { v4 as uuidv4 } from "uuid";
 
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
 
/**
 * Returns an existing idempotency key for the given orderId,
 * or creates a new one if none exists.
 */
export async function getIdempotencyKey(orderId: string): Promise<string> {
  const { data, error } = await supabase
    .from("orders")
    .select("idempotency_key")
    .eq("id", orderId)
    .single();
 
  if (error && error.code !== "PGRST116") {
    throw error;
  }
 
  if (data?.idempotency_key) {
    return data.idempotency_key;
  }
 
  const newKey = uuidv4();
  const { error: insertError } = await supabase
    .from("orders")
    .update({ idempotency_key: newKey })
    .eq("id", orderId);
 
  if (insertError) {
    throw insertError;
  }
 
  return newKey;
}

Now modify the API route to use this helper:

ts
// pages/api/create-payment.ts
import Stripe from "stripe";
import { getIdempotencyKey } from "../../lib/idempotency";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2022-11-15",
});
 
export default async function handler(req, res) {
  const { amount, currency, paymentMethodId, orderId } = req.body;
 
  // Guard against missing orderId – it is required for idempotency.
  if (!orderId) {
    res.status(400).json({ error: "orderId is required" });
    return;
  }
 
  const idempotencyKey = await getIdempotencyKey(orderId);
 
  const paymentIntent = await stripe.paymentIntents.create(
    {
      amount,
      currency,
      payment_method: paymentMethodId,
      confirm: true,
    },
    { idempotencyKey }
  );
 
  res.status(200).json({ clientSecret: paymentIntent.client_secret });
}

That single change addresses the cause because every retry now carries the same idempotencyKey, allowing Stripe to recognize the duplicate and ignore it.

Step by step#

  1. Create the orders table in Supabase with columns id (UUID primary key) and idempotency_key (text, nullable).
    sql
    create table orders (
      id uuid primary key default uuid_generate_v4(),
      amount integer not null,
      currency text not null,
      idempotency_key text
    );
  2. Add the uuid npm package to your project: npm install uuid.
  3. Copy lib/idempotency.ts into your repo, adjusting the table name if needed.
  4. Update the API route (pages/api/create-payment.ts) exactly as shown above.
  5. Deploy or run npm run dev locally; the server will now fetch or create a key for each order.
  6. Pass orderId from the client when you call the endpoint. For example, in your checkout component:
    ts
    const response = await fetch("/api/create-payment", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        amount: 1999,
        currency: "usd",
        paymentMethodId,
        orderId, // generated when you insert the order row
      }),
    });

Verify the fix#

Run the endpoint with a fresh order and then intentionally trigger a retry (e.g., by disabling your network for a second). Use curl to simulate the request:

bash
curl -X POST http://localhost:3000/api/create-payment \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 1999,
    "currency": "usd",
    "paymentMethodId": "pm_card_visa",
    "orderId": "c1a2b3d4-5678-90ab-cdef-1234567890ab"
  }'

You should see a JSON response containing a single clientSecret. Check the Stripe Dashboard; there will be one payment intent with the idempotency_key you generated. If you repeat the curl command with the same orderId, Stripe will return the same payment intent instead of creating a new one, and the logs will show:

text
[info] Stripe request succeeded with idempotency key: 3f9c2e1a-4b5d-6e7f-8a9b-0c1d2e3f4a5b

If you still see duplicate charges, verify that:

  • The orderId you send matches a row in Supabase.
  • The idempotency_key column is populated after the first request.
  • Your Stripe SDK version is recent enough to support the idempotencyKey option (2022‑11‑15 or later).

Variant A — Missing orderId in the client payload#

Some developers forget to include orderId when calling the endpoint, causing the guard clause to return a 400 error. The fix is simply to generate the order row before invoking the payment API and pass its id forward. See the checkout flow in the SaaS guide “Next.js & Supabase Stripe Subscriptions: SaaS Guide” for a complete example.

Variant B — Using a random UUID per request#

If you generate a fresh UUID inside the API route instead of reusing the one stored with the order, each retry gets a new key and Stripe treats it as a new charge. The correct pattern is to store the key once when the order is created (as shown in lib/idempotency.ts). The “Stripe Webhook Idempotency Pattern: One-Page Guide” (/guides/stripe-webhook-idempotency-pattern) explains why persisting the key matters for webhook processing as well.

Why this happens (and how to avoid it next time)#

Stripe’s idempotency model assumes the client supplies a stable identifier for each logical operation. When the identifier changes between retries, Stripe cannot deduplicate, and you end up with duplicate financial records. The safest way to avoid regression is to make the idempotency key part of your domain model—store it alongside the order, subscription, or invoice record. Adding a tiny helper like getIdempotencyKey centralizes the logic, so any future endpoint (webhooks, refunds, subscription updates) can reuse the same pattern. Consider adding a unit test that simulates a network timeout and asserts that two calls with the same orderId result in a single Stripe request.

Related fixes & guides