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)
In development the SDK is more forgiving and only warns:
[warn] Missing idempotency key for Stripe request: createCharge
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 });
}
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
);
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;
}
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 });
}
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
}),
});
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"
}'
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
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 itsidcan 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
- Next.js & Supabase Stripe Subscriptions: SaaS Guide
- Stripe Webhooks vs Polling: Production Guide
- Stripe webhook signature verification failed in Next.js — the retry loop that makes idempotency mandatory in the first place: a signature check that rejects the payload makes Stripe redeliver the same event for days.
- Next.js webhook handling and event-driven architecture — where the key lives once more than one producer writes the same row, and why the uniqueness constraint belongs in Postgres rather than in the handler.
Originally published at https://www.iloveblogs.blog
Top comments (0)