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.
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 symptom is visible in the Stripe Dashboard rather than in an exception: two PaymentIntents with the same amount, currency and customer, created seconds apart for what was a single order. Nothing errors, because from Stripe's point of view both requests were valid and distinct.
The trigger is almost always on your side of the wire: a user double-clicking Pay, a flaky mobile connection that makes the browser or your client code send the same POST again, or a client-side retry wrapper around fetch. Each of those runs your API route a second time, and each run sends a fresh request to Stripe.
Why retries create a second charge#
Stripe treats two requests as the same operation only when they carry the same idempotency key. The official stripe Node library already handles its own network retries: since stripe-node v13 it makes one automatic reattempt for failed requests that are safe to retry, and "idempotency keys are added where appropriate to prevent duplication". That protects a single SDK call. It does nothing when your route handler is invoked twice, because the second invocation is a new SDK call with a new auto-generated key.
A typical Next.js API route looks like this — note the absence of any key tied to the order:
// 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 — stored with the order — passed in the request options object, the second argument to the SDK method (stripe.paymentIntents.create(params, { idempotencyKey })). Stripe's idempotent requests documentation describes the mechanism: "For API v1, Stripe saves the status code and body of the first request made with a given idempotency key after endpoint execution begins." A later request with the same key gets that saved result back instead of creating a second PaymentIntent. Three details from the same page and from Stripe's low-level error guide shape the implementation:
- Sending the same key with different parameters returns an error rather than the original result, so the amount and currency must not change between retries of one order.
- A request that conflicts with another request still executing with the same key is not saved; Stripe answers
409 Conflict, which the client should treat as "retry shortly", not "pay again". - Keys are retained for at least 24 hours and can be removed after that, so a retry days later is not guaranteed to be deduplicated. Check the order's payment status in your database before calling Stripe again.
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 the key generated by the database at insert time:
create table orders (
id uuid primary key default gen_random_uuid(),
amount integer not null,
currency text not null,
idempotency_key uuid not null default gen_random_uuid()
);gen_random_uuid() is built into PostgreSQL 13 and later (PostgreSQL UUID functions), so no extension or npm package is needed. Because the key is written in the same insert that creates the order, two concurrent payment requests can never generate two different keys for one order — a "read the key, and if it is empty generate and save one" helper has exactly that race.
2. Keep the Supabase secret key on the server. The helper below runs only in an API route, so it uses a server-side secret key. With the public anon key, Row Level Security applies, and Supabase's API keys documentation notes that "a policy that matches no rows returns an empty result" — the lookup would silently find nothing instead of returning the key. Never expose the secret key to the browser.
3. Add a helper that reads the key for an 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";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY! // server only, never NEXT_PUBLIC_
);
/**
* Returns the idempotency key stored on the order row.
* The key is created by the database when the order is inserted.
*/
export async function getIdempotencyKey(orderId: string): Promise<string> {
const { data, error } = await supabase
.from("orders")
.select("idempotency_key")
.eq("id", orderId)
.single();
if (error) {
throw error; // includes "no row found" for an unknown orderId
}
return data.idempotency_key;
}4. Update the API route to resolve the key and pass it to Stripe:
// pages/api/create-payment.ts
import type { NextApiRequest, NextApiResponse } from "next";
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: NextApiRequest, res: NextApiResponse) {
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; every request for the same order now sends the same key to Stripe.
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 clientSecret. Repeat the curl with the same orderId and the same body: the response carries the same clientSecret, and the Stripe Dashboard lists one PaymentIntent for the order, not two. Stripe marks a replayed response with the Idempotent-Replayed: true header, as described in its low-level error guide. Changing the amount while keeping the same orderId returns an idempotency error instead — the expected behaviour, not a bug.
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
crypto.randomUUID()or similar in the handler instead of reading the stored key, each retry gets a new key and Stripe treats it as new. The key must be persisted once, at order creation.
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 centralises this so future endpoints that create Stripe objects (refunds, subscription updates) reuse the same pattern instead of reinventing it. Incoming webhooks are a different problem: there you deduplicate on the Stripe event ID, not on an idempotency key. A small unit test that calls the route twice with the same orderId against a mocked Stripe client, and asserts both calls passed the same idempotencyKey, catches the regression where someone starts generating keys inside the handler.
Further reading: Stripe Webhook Signature Verification Failed in Next.js, Fix Docker Compose 'Exec format error' loading a library.
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Related fixes & guides
- Deploying Next.js + Supabase to Production
- Error Handling and Observability for Next.js + Supabase
- Next.js + Supabase Production Launch Checklist (47 Items)
- Fix Docker: Client Version Is Too Old
- Fix RabbitMQ ECONNREFUSED 5672 between Docker containers
- Fix: Sequelize authenticate() Hangs With No Success or Error
- Docker 'OCI runtime create failed': a flag after the image

