Idempotent Stripe PaymentIntents on a multi-step checkout: order-id as the key
The decision this framework is for
A checkout that issues a Stripe PaymentIntent is one button click away from issuing two. The customer hits "Pay," the request hangs for a second on a slow network, the customer hits "Pay" again, and now your server has two intents for the same order. One of them will succeed. The other will hang around forever, eating quota, confusing reconciliation, and occasionally — depending on the customer's card — getting authorised against funds your platform was not supposed to touch.
The framework below is for any team integrating Stripe into a multi-step checkout where the same browser session may attempt payment more than once before the user sees a result. That covers almost every event-ticketing flow, every booking flow, every cart-driven purchase. The framework is not for one-shot terminal-style payments where the client and server are co-located and the user never sees a retry button.
The decision is what to use as the idempotency key. Stripe accepts an idempotency_key header on intent creation; the question is what value to put in it so that the second click reads back the first intent instead of creating a new one.
The framework — order-id as the idempotency key
Four steps. Each one is small. The discipline is in not skipping any of them.
- Persist the Order before the PaymentIntent.
- Use the Order id as the Stripe idempotency key, with no other input.
- Store the returned PaymentIntent id on the Order.
- On every payment-screen load, fetch by stored intent id first, create only if absent.
The name worth giving this is "order-keyed payment intents." The order is the contract; the intent is a derived resource.
Each step with one paragraph of explanation
Persist the Order before the PaymentIntent. The Order is the user's intent; the PaymentIntent is your communication with Stripe about that intent. Reversing the order — creating the PaymentIntent first and using its id as your local foreign key — means every retry creates a new intent until the local Order finally exists. Always: Order first, intent second.
Use the Order id as the Stripe idempotency key, with no other input. Stripe's idempotency layer hashes the request body against the key. Same key, same body, returns the original response. Same key, different body, returns a 400. So your retry must not include any field that varies between attempts — no timestamp, no client-generated nonce, no "attempt count." The idempotency key is the Order id, the body is deterministic from the Order, and the second call returns the first call's intent.
Store the returned PaymentIntent id on the Order. This is the single piece of state that turns the rest of the contract from "compute on every load" into "remember once." stripePaymentIntentId String? on the Order. After Stripe replies, write it. Once written, the Order knows about the intent without going back to Stripe.
On every payment-screen load, fetch by stored intent id first, create only if absent. When the payment page renders, the server checks whether the Order already has a stripePaymentIntentId. If yes, it paymentIntents.retrieve(). If no, it paymentIntents.create() with the Order id as the idempotency key. The second branch is the safety net for the case where the original create call succeeded but the response never reached your server — Stripe replays the response, your server saves the id, and the customer sees a coherent state.
Walk the framework through a real artifact
Here is the actual endpoint that serves the payment page in an event-ticketing flow. It runs on every load of the payment screen, on every retry, on every back-button-then-refresh sequence the customer might construct:
// app/(api)/api/gateway/[orderId]/payment-intent/stripe/route.ts
import Stripe from 'stripe';
import { prisma } from '@/libs/prisma';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function GET(req: Request, { params }: Params) {
const { orderId } = params;
const order = await prisma.eventSessionOrder.findUnique({
where: { id: orderId },
include: { seats: true, eventSession: true },
});
if (!order) return Response.json({ error: 'order-not-found' }, { status: 404 });
// Step 4: fetch first.
if (order.stripePaymentIntentId) {
const existing = await stripe.paymentIntents.retrieve(
order.stripePaymentIntentId,
);
return Response.json({
clientSecret: existing.client_secret,
status: existing.status,
});
}
// Step 2 + 3: create with the order id as idempotency key, then persist.
const amountInCents = computeOrderTotalInCents(order);
const intent = await stripe.paymentIntents.create(
{
amount: amountInCents,
currency: order.currency.toLowerCase(),
metadata: { eventSessionOrderId: order.id },
},
{ idempotencyKey: order.id },
);
await prisma.eventSessionOrder.update({
where: { id: order.id },
data: {
stripePaymentIntentId: intent.id,
stripePaymentIntentClientSecret: intent.client_secret,
stripePaymentIntentStatus: intent.status,
},
});
return Response.json({
clientSecret: intent.client_secret,
status: intent.status,
});
}
function computeOrderTotalInCents(order: { seats: { id: string }[]; eventSession: { sectionPrices: unknown } }): number {
const sectionPrices = order.eventSession.sectionPrices as
| { sectionId: string; price: number }[]
| null;
if (!sectionPrices) return 0;
// ...sum seat prices via section lookup, return in minor units.
return 0; // simplified for brevity
}
The endpoint reads as plain procedural code, but it is enforcing every step of the framework. The fetch-first path catches the case where the intent was already created. The create call passes the Order id as idempotencyKey, which means a duplicate request from the same Order during retry windows comes back as the same intent. The persistence step closes the loop so the next page load no longer needs to retry.
The metadata field is the other piece of the contract. Stripe's webhook handler can read the eventSessionOrderId back from the intent and update the Order's status. This is what makes the relationship two-way: the Order references the intent in our database; the intent references the Order in Stripe's metadata.
// services/PaymentService/StripeService.ts (webhook handler sketch)
case 'payment_intent.succeeded': {
const intent = event.data.object as Stripe.PaymentIntent;
const orderId = intent.metadata.eventSessionOrderId;
if (!orderId) break;
await prisma.eventSessionOrder.update({
where: { id: orderId },
data: {
status: 'COMPLETED',
stripePaymentIntentStatus: intent.status,
expireIfNotCompletedAt: null,
},
});
break;
}
Three changes on success: the Order moves to COMPLETED, the intent status is mirrored, and the expiry is cleared so the cron stops worrying about this Order. Each one is a single column write. None of them depends on the original create call's success — the webhook is the eventual-consistency channel.
Where the framework fails
Two failure modes. Neither is fatal, but both deserve names so the team can spot them.
Amount changes between attempts. If the customer adds or removes a seat after the intent is created, the stored intent's amount is wrong. The fetch-first path returns the stale intent, and the customer pays the wrong total. The fix is to add a tripwire: before returning the existing intent, recompute the order total in cents and compare it to existing.amount. If they differ, cancel the existing intent (paymentIntents.cancel(id)) and create a new one. Most payment providers support this in-band; some require a status-aware retry.
The Order id is reused across environments. If your staging database is a copy of prod and an Order id collides, the Stripe idempotency layer thinks the staging request is a retry of the prod request and returns the prod intent. This is rare but disorienting. The fix is environment-scoped Stripe accounts (preferred) or a key prefix (order.id + '-' + process.env.STAGE). The latter still respects idempotency within a stage.
The framework does not protect against the customer using a different browser session or a different device to retry. Those cases involve different Order ids (because checkout always starts from a fresh Order in a new session), so the idempotency layer is by-design indifferent. The protection there is the seat-hold contract (see related post on order-expiry) — only one Order can hold a given seat at a time.
CTA — the prompt or question that triggers the framework
If you are about to write stripe.paymentIntents.create(...) anywhere in a checkout, ask: what would happen if the same user clicked twice while the first request was still in flight? If the honest answer is "I would create two intents," the framework above is the cheapest fix. Three lines: persist the Order first, pass the Order id as idempotencyKey, store the result. The fourth line — the fetch-first guard — is the bonus that makes the page load idempotent too.
If you are debugging a customer who has two intents on the same Order, ask: was the idempotency key set, and was it the Order id? Most "duplicate intent" tickets resolve to "the key was not set" or "the key was a timestamp." Both are fixable in the same patch.
Trade-off
The framework leans on the Order being the meaningful unit of idempotency. That works when the Order is durable for the life of the checkout, which is the common case. It breaks when the Order itself is recreated mid-flow — for example, if a customer clears the cart and starts over. In that case the new Order has a new id, the new intent is correctly distinct, and the old intent eventually expires on Stripe's side. The cost is one orphan intent per abandoned checkout, which Stripe's dashboard handles gracefully.
The other trade is on the amount-change failure mode above. The fetch-first cache is the thing that makes the page load fast and the second click safe, and the same cache is the thing that returns the wrong total when the cart changes. The tripwire is straightforward but adds a request to Stripe on every page load, which doubles the latency. Most teams accept that latency because the rest of the page is already loading; some prefer to invalidate the cached intent the moment the cart changes, which is a one-line data: { stripePaymentIntentId: null } write in the cart-mutation endpoint.
Business impact
Duplicate PaymentIntents are not just a technical mess — they are a finance mess. Reconciliation between Stripe payouts and internal Order records breaks when one Order has two intents and one of them was authorised but never captured. The customer-support team starts seeing tickets about "I was charged twice" that resolve to "no you were not, but you do have two authorisations on file." Refunds, holds, chargebacks all start to live in a fog.
The framework keeps the relationship 1:1. Every Order has at most one intent. Every reconciliation report has at most one row to match. Every customer email about a charge points at one record in the dashboard. That clarity is the business outcome — fewer support tickets, faster month-end close, no awkward conversations with a payment provider about "ghost" intents.
What to do next
If you have an existing Stripe checkout and no idempotency key on paymentIntents.create, the patch is one line. Pass the Order id as the second argument: stripe.paymentIntents.create({ ... }, { idempotencyKey: order.id }). That alone solves the duplicate-on-double-click problem.
If you want the full framework, add stripePaymentIntentId and stripePaymentIntentClientSecret to your Order model, write the fetch-first guard at the top of the payment-page endpoint, and add the cart-mutation invalidation that nulls the stored intent id when the order total changes. Three columns, two branches, one consistent contract between your database and Stripe's.
The artifact to copy: the idempotencyKey: order.id line, the stripePaymentIntentId column, and the paymentIntents.retrieve branch in the route handler. Everything else — webhook handling, error paths, the metadata bridge — is downstream of those three.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox