DEV Community

Cover image for How to Set Up Idempotency Key in Next.js (Step by Step)
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

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

A user clicks Pay, the network hiccups for half a second, and the Stripe dashboard now shows two identical charges for the same order. The missing piece is one field: idempotencyKey. Without it, Stripe cannot tell a retry from a new request, so it processes both. This guide walks through wiring that key into a Next.js + Supabase checkout, end to end.

The duplicate-charge bug

The failure is loud in production and quiet in development. In production logs you see a charge created twice for the same order:

[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)
Enter fullscreen mode Exit fullscreen mode

In development the SDK is more forgiving and only warns:

[warn] Missing idempotency key for Stripe request: createCharge
Enter fullscreen mode Exit fullscreen mode

Both environments run the same underlying Stripe SDK, so the bug is not environment-specific. It surfaces whenever the client retries — a flaky mobile network, a user double-tapping the button, or Vercel's function timeout forcing a second invocation. The dashboard tells the real story: two payment intents with identical amounts and metadata.

Why retries create a second charge

Stripe treats every request as a distinct operation unless you attach an idempotencyKey. When a network timeout fires, the client library automatically retries the HTTP call. The retry carries no identifier tying it back to the first attempt, so Stripe processes it as a brand-new request and creates a second charge.

A typical Next.js API route looks like this — note the absence of any key:

// 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 });
}
Enter fullscreen mode Exit fullscreen mode

The fix is a key that is stable for the same business operation — the order ID, persisted once — passed as the third argument to the SDK method. When a retry carries the same key, Stripe recognizes the duplicate and returns the original payment intent instead of creating a new one.

Step-by-step fix

The key insight is that the idempotency key belongs to the order, not the request. Generate it once when the order row is created, store it, and reuse it on every Stripe call for that order.

1. Create the orders table in Supabase with a column to hold the key:

create table orders (
  id uuid primary key default uuid_generate_v4(),
  amount integer not null,
  currency text not null,
  idempotency_key text
);
Enter fullscreen mode Exit fullscreen mode

2. Add the uuid package: npm install uuid.

3. Add a helper that fetches or creates the key per order. This is the single source of truth — every endpoint that talks to Stripe for this order reuses the same key.

// 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;
}
Enter fullscreen mode Exit fullscreen mode

4. Update the API route to resolve the key and pass it to Stripe:

// 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;

  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 });
}
Enter fullscreen mode Exit fullscreen mode

5. Deploy or run npm run dev; the server now resolves a key for each order on first request and reuses it on retries.

6. Pass orderId from the client. The order row must exist before the payment call, so its id can seed the key:

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
  }),
});
Enter fullscreen mode Exit fullscreen mode

Confirm it holds

Run the endpoint with a fresh order, then simulate a retry by sending the exact same payload twice:

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"
  }'
Enter fullscreen mode Exit fullscreen mode

You should get a JSON response with a single clientSecret. Check the Stripe Dashboard: there is one payment intent carrying the idempotency_key you generated. Repeat the curl with the same orderId and Stripe returns the same payment intent instead of creating a new one. The logs confirm it:

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

Two failure modes are worth checking when duplicates still appear:

  • The client omits orderId. The guard clause returns a 400 and no charge is created — but the order row must be inserted before the payment call so its id can seed the key. See the checkout flow in Next.js & Supabase Stripe Subscriptions: SaaS Guide for a complete wiring.
  • A fresh UUID is generated inside the route on every call. If you call uuidv4() in the handler instead of reusing the stored key, each retry gets a new key and Stripe treats it as new. The key must be persisted once, at order creation — the same rule applies to webhooks.

Keep it from coming back

Stripe's idempotency model assumes the client supplies a stable identifier per logical operation. When that identifier changes between retries, Stripe cannot deduplicate, and you get duplicate financial records. The safest defense is to make the key part of your domain model — store it alongside the order, subscription, or invoice. The getIdempotencyKey helper centralizes this so future endpoints (webhooks, refunds, subscription updates) reuse the same pattern instead of reinventing it. A small unit test that simulates a network timeout and asserts two calls with the same orderId produce a single Stripe request is worth its weight in regressions.

Related


Originally published at https://www.iloveblogs.blog

Top comments (0)