Stripe Payments in Production: Checkout, PaymentIntents, 3DS, Idempotency and Webhooks
Key takeaways
The Stripe API calls are the easy part. What breaks in production is fulfilling orders from the success redirect, webhook signature checks failing behind a JSON body parser, retried requests creating duplicate charges, 3D Secure leaving payments in requires_action, and webhook events arriving twice or out of order. This guide covers each of those with Next.js and Express examples.
What This Post Covers
Stripe makes the first working payment easy: create a Checkout Session, redirect, done. The problems show up later, and they are almost never about the API call itself. They are about when you trust that a payment happened, what happens when a request is retried, and how your webhook handler copes with the real delivery behaviour of Stripe events.
This post walks through the two main integration styles (Checkout Sessions and PaymentIntents with the Payment Element), then spends most of its time on the parts that cause real incidents: fulfillment, 3D Secure, idempotency, webhook signature verification, event duplicates and ordering, and test versus live mode. Examples use the official stripe Node library with Next.js App Router and Express.
Setup
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
The secret key (sk_...) must never reach the browser; only the publishable key (pk_...) is safe to expose. Recent versions of stripe-node send the API version the library release was built against, which keeps the TypeScript types and the actual response shapes in sync. If you pass an explicit apiVersion that does not match the installed SDK, TypeScript will complain, and more importantly you can end up with types that describe fields the API does not return. Upgrade the SDK and API version together, deliberately, after reading the changelog.
Option 1: Checkout Sessions (Hosted or Embedded)
Checkout is a Stripe-built payment page. You create a session on the server and send the customer to it. It handles card input, wallets, 3D Secure, many local payment methods, tax and promotion codes without you building UI for them.
// app/api/checkout/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
const PRICES: Record<string, string> = {
basic: process.env.STRIPE_PRICE_BASIC!,
pro: process.env.STRIPE_PRICE_PRO!,
};
export async function POST(req: Request) {
const { plan, orderId } = await req.json();
const price = PRICES[plan];
if (!price) return NextResponse.json({ error: 'unknown plan' }, { status: 400 });
const origin = process.env.APP_URL!; // do not trust the Origin header for redirects
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price, quantity: 1 }],
client_reference_id: orderId, // links the session back to your order
metadata: { orderId },
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/cart`,
});
return NextResponse.json({ url: session.url });
}
The client just redirects:
'use client';
export default function CheckoutButton({ plan, orderId }: { plan: string; orderId: string }) {
const handleCheckout = async () => {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan, orderId }),
});
const { url } = await res.json();
window.location.href = url;
};
return <button onClick={handleCheckout}>Checkout</button>;
}
Note what the client sends: a plan name, not a price or an amount. The server maps it to a Price ID. Anything the browser sends can be edited.
Do not fulfill on the success page
The redirect to success_url is a convenience for the customer, not a payment confirmation. The customer may close the tab after paying, their connection may drop before the redirect, and anyone can open /success?session_id=... directly. If fulfillment lives on that page, some paid orders never get fulfilled and a determined user can try to trigger fulfillment without paying.
Fulfill from the checkout.session.completed webhook instead, and check session.payment_status. For card payments it is usually 'paid' at that point. For delayed methods such as bank debits it can be 'unpaid', and the actual result arrives later as checkout.session.async_payment_succeeded or checkout.session.async_payment_failed. The success page can still retrieve the session to show “payment received” or “processing”, but it should not be what ships the order.
Option 2: PaymentIntents with the Payment Element
Use PaymentIntents when you need the payment form inside your own page and flow. A PaymentIntent is a state machine for one payment attempt. The statuses you will actually see:
| Status | Meaning |
|---|---|
requires_payment_method | Created, or the last attempt failed; needs a (new) payment method |
requires_confirmation | Has a payment method, waiting for confirm |
requires_action | Customer must do something, usually 3D Secure |
processing | Submitted, result pending (common for bank-based methods) |
succeeded | Money captured |
requires_capture | Authorized only, when you use manual capture |
canceled | Canceled, cannot be reused |
The server creates the intent and returns its client_secret:
// app/api/payment-intent/route.ts
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';
import { getOrderTotal } from '@/lib/orders';
export async function POST(req: Request) {
const { orderId } = await req.json();
const amount = await getOrderTotal(orderId); // integer, smallest currency unit
const paymentIntent = await stripe.paymentIntents.create(
{
amount,
currency: 'usd',
automatic_payment_methods: { enabled: true },
metadata: { orderId },
},
{ idempotencyKey: `pi-create-${orderId}` }
);
return NextResponse.json({ clientSecret: paymentIntent.client_secret });
}
Two corrections over many tutorials:
- Compute the amount on the server. A route that accepts
{ amount }from the browser lets anyone pay 1 cent for anything. amountis an integer in the smallest currency unit, not “dollars times 100” in general. For USD, 1999 means $19.99. Zero-decimal currencies such as JPY and KRW are already in whole units, so multiplying by 100 overcharges by a factor of 100. Keep money as integers end to end and never compute it with floating point.
The client wraps the form in Elements and calls confirmPayment:
'use client';
import { loadStripe } from '@stripe/stripe-js';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { useEffect, useState } from 'react';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setSubmitting(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/payment/complete` },
});
// Only reached if confirmation failed immediately (card declined, validation error).
// On success or 3DS, the browser is redirected to return_url instead.
if (error) setMessage(error.message ?? 'Payment failed');
setSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit" disabled={!stripe || submitting}>Pay</button>
{message && <div role="alert">{message}</div>}
</form>
);
}
export default function CheckoutPage({ orderId }: { orderId: string }) {
const [clientSecret, setClientSecret] = useState('');
useEffect(() => {
fetch('/api/payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId }),
})
.then((res) => res.json())
.then((data) => setClientSecret(data.clientSecret));
}, [orderId]);
if (!clientSecret) return null;
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm />
</Elements>
);
}
Disabling the button while submitting matters more than it looks: double-clicking “Pay” is one of the easiest ways to get two confirmation attempts in flight.
3D Secure and SCA
Under Strong Customer Authentication rules (mandatory for many European cards) and whenever an issuer decides a payment is risky, the bank requires the customer to authenticate. The PaymentIntent goes to requires_action, and confirmPayment shows the challenge or redirects to the bank, then sends the customer to your return_url with payment_intent, payment_intent_client_secret and redirect_status query parameters.
On that return page, retrieve the PaymentIntent with the client secret to display its status. But as with Checkout, the page is for the customer, not for your order state: processing can turn into succeeded or a failure minutes or days later for some payment methods. Your order system should move on payment_intent.succeeded and payment_intent.payment_failed webhooks.
Off-session payments (charging a saved card later, without the customer present) need setup_future_usage: 'off_session' when the card is first collected, so the initial authentication covers future charges. Even then, an issuer can still demand authentication; the off-session charge then fails with the error code authentication_required, and you have to bring the customer back on-session to complete it.
Idempotency Keys
Any POST to Stripe can time out on your side after Stripe has already done the work. If your code simply retries, you can create two PaymentIntents, two customers, or two refunds. Idempotency keys fix this: send the same key with the retry, and Stripe returns the stored result of the first request instead of executing it again.
await stripe.refunds.create(
{ payment_intent: paymentIntentId, amount: 500 },
{ idempotencyKey: `refund-${refundRequestId}` }
);
Guidelines that come from how the mechanism works:
- Derive the key from your own business identifier (order ID, refund request ID), not a random UUID generated per attempt. A fresh UUID on each retry defeats the purpose.
- A key is bound to its parameters. Reusing it with a different body fails with
Keys for idempotent requests can only be used with the same parameters they were first used with.If the amount on an order legitimately changes, that is a new operation and needs a new key, or better, update the existing PaymentIntent. - Keys are not stored forever. Stripe prunes them after at least 24 hours, so they protect against retries, not against re-running a job days later. Your own database still needs a record like “refund for request X already created”.
- The Node library already retries some failed requests on network errors and adds idempotency keys to those automatic retries (
maxNetworkRetriescontrols this), but that does not help if your own process crashes and retries the whole operation later.
Webhooks: Signature Verification Needs the Raw Body
Stripe signs each webhook with an HMAC over the timestamp and the exact bytes of the request body. constructEvent recomputes it and compares. If anything parses and re-serializes the JSON first (key order, whitespace, unicode escaping), the bytes differ and verification fails with:
StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
Express
The classic mistake is app.use(express.json()) at the top of the app, which consumes the body for every route including the webhook. Register the webhook route with a raw parser before the global JSON parser:
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // a Buffer here, because of express.raw
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
await enqueueStripeEvent(event); // hand off, respond fast
res.json({ received: true });
});
app.use(express.json()); // everything else gets parsed JSON
Next.js App Router
Route handlers do not parse the body automatically, so read it as text:
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import type Stripe from 'stripe';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
const message = err instanceof Error ? err.message : 'invalid signature';
return new Response(`Webhook Error: ${message}`, { status: 400 });
}
await handleEvent(event);
return Response.json({ received: true });
}
The other cause: the wrong secret
Each webhook endpoint has its own signing secret. stripe listen --forward-to localhost:3000/api/webhooks/stripe prints a whsec_... value for the CLI session that is different from the secret of the endpoint you registered in the Dashboard, and test and live endpoints have different secrets too. If the raw body is definitely intact and verification still fails, check which secret the running environment actually loaded.
I have watched this exact failure play out more than once: webhooks verify perfectly with stripe listen locally, and after deploy every event returns 400. The cause is either a global body parser added by a framework or middleware that local testing bypassed, or the production environment still holding the CLI’s whsec_ value. Since Stripe keeps retrying failed deliveries, nothing looks broken to customers at first; the only symptom is orders stuck in “pending” and a growing list of failed deliveries in the Dashboard.
Webhooks: Duplicates, Ordering and Retries
Treat webhook delivery as at-least-once and unordered:
- Duplicates happen. A delivery that succeeds on your side but times out on Stripe’s side is retried. Record processed
event.idvalues (a table with a unique constraint works) and skip ones you have already handled. - Order is not guaranteed. You can receive
invoice.paidbeforecustomer.subscription.created, or apayment_intent.succeededbefore your own code has saved the order row. Do not write handlers that assume the previous event already arrived. - Prefer current state over event payload. For state-changing decisions, re-fetch the object (
stripe.paymentIntents.retrieve(id),stripe.subscriptions.retrieve(id)) instead of trusting a possibly stale snapshot in an older event. Then the order of arrival matters much less: whichever event comes last, you act on the latest state. - Respond with 2xx quickly. Stripe treats slow responses and non-2xx codes as failures and retries with exponential backoff; in live mode it keeps trying for up to three days. Verify the signature, persist or enqueue the event, return 200, and do slow work (emails, provisioning, PDFs) in a background job.
- Subscribe only to the events you handle when registering the endpoint, and return 200 for types you do not care about rather than an error, so they are not retried.
A handler following those rules:
async function handleEvent(event: Stripe.Event) {
const firstTime = await db.processedEvents.insertIfAbsent(event.id); // unique on id
if (!firstTime) return;
switch (event.type) {
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded': {
const session = await stripe.checkout.sessions.retrieve(
(event.data.object as Stripe.Checkout.Session).id
);
if (session.payment_status === 'paid') {
await orders.markPaid(session.client_reference_id!, session.id);
}
break;
}
case 'checkout.session.async_payment_failed':
await orders.markFailed((event.data.object as Stripe.Checkout.Session).client_reference_id!);
break;
default:
break; // acknowledged, not handled
}
}
orders.markPaid should itself be idempotent (for example “set status to paid where status is pending”), so a replay through a different event type cannot ship the order twice.
Subscriptions
Create products and recurring prices once, in the Dashboard or with a setup script, and store the Price IDs in config:
const product = await stripe.products.create({ name: 'Premium Plan' });
const price = await stripe.prices.create({
product: product.id,
unit_amount: 1999, // $19.99
currency: 'usd',
recurring: { interval: 'month' },
});
Start a subscription with Checkout in mode: 'subscription':
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: stripeCustomerId, // reuse the same Customer for the same user
line_items: [{ price: process.env.STRIPE_PRICE_PREMIUM!, quantity: 1 }],
success_url: `${process.env.APP_URL}/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
Things that matter once subscriptions are live:
- Drive access from webhooks, not from the checkout redirect.
invoice.paidis the signal that a billing period is paid;invoice.payment_failedmeans a renewal failed and Stripe’s retry and dunning settings take over;customer.subscription.updatedandcustomer.subscription.deletedtell you about plan changes and cancellations. Store the subscription’sstatusand current period end and gate features on that. - Cancel at period end for user-initiated cancellations.
stripe.subscriptions.cancel(id)ends it immediately. Most products wantstripe.subscriptions.update(id, { cancel_at_period_end: true })so the customer keeps what they paid for. - Plan changes prorate by default. Updating the price on a subscription item creates proration line items on the next invoice. Decide whether that is what you want and set
proration_behaviorexplicitly:
const sub = await stripe.subscriptions.retrieve(subscriptionId);
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: sub.items.data[0].id, price: process.env.STRIPE_PRICE_PRO! }],
proration_behavior: 'create_prorations',
});
- Consider the Customer Portal (
stripe.billingPortal.sessions.create) for card updates, invoices and cancellation instead of building those screens yourself.
Test Mode vs Live Mode
Test and live are separate worlds that share nothing: customers, products, prices, webhook endpoints and their secrets all exist in one mode only. The key prefix tells you which you are using (sk_test_ / sk_live_, pk_test_ / pk_live_). A live key used with a test Price ID fails with an error like No such price: 'price_...'; a similar object exists in test mode, but a live mode key was used to make this request.
In practice:
- Keep Price IDs, webhook secrets and keys in per-environment configuration, never in code.
- Recreate products, prices and webhook endpoints in live mode before launch; a script that creates them from a definition file makes this repeatable.
- Test with Stripe’s test cards, including the ones that trigger 3D Secure and declines, and with
stripe trigger <event>from the CLI to exercise webhook handlers. - Check the key prefix at startup and refuse to boot production with a test key (and vice versa).
The failure I would guard against first is the silent mode mismatch at launch: the site is switched to live keys, but the webhook endpoint or its secret is still the test one. Payments succeed in live mode, the live webhook endpoint either does not exist or rejects the signature, and nothing is fulfilled. A launch checklist item of “make one real low-value payment and watch the order reach paid” catches it in minutes.