Next.js and Stripe: What the Tutorials Leave Out
Almost every Next.js and Stripe tutorial ends the same way: a Server Action creates a Checkout Session, the buyer pays, a route handler catches checkout.session.completed, and the guide finishes. That is a correct demo, and it is roughly half of what Stripe's own fulfillment documentation asks you to do. The other half is what breaks in production, usually in a fixed order.
Here is that order, with the gaps named.
The redirect is not the confirmation#
The tutorial pattern sends the buyer to success_url with {CHECKOUT_SESSION_ID} in the query string, then fulfills the order on that page. Stripe's fulfillment guide is direct about why that fails:
You can't rely on triggering fulfillment only from your Checkout landing page, because your customers aren't guaranteed to visit that page. For example, someone can pay successfully in Checkout and then lose their connection to the internet before your landing page loads.
The money moves; the order never gets created. The landing page call is a latency optimization, not a source of truth. Webhooks are the source of truth.
There is a second detail worth knowing before you write the handler: Checkout waits up to 10 seconds for your server to respond to the webhook before redirecting the buyer. A slow handler is a buyer staring at a spinner. Acknowledge fast, do the work after.
In the App Router, signature verification needs the raw body. req.json() consumes it and verification fails with "No signatures found matching the expected signature for payload"; that error and its two sibling causes are cataloged in the signature failure reference.
// app/api/stripe/webhook/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw string, not req.json()
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// ...
return new Response(null, { status: 200 });
}
Advice written for the Pages Router (export const config = { api: { bodyParser: false } }) still circulates widely and does nothing here.
"Completed" does not mean "paid"#
This is the gap that costs real money, and it is missing from every tutorial we checked.
checkout.session.completed fires when the buyer finishes Checkout. For delayed notification payment methods, including ACH direct debit, bank transfers and vouchers, the funds have not arrived at that point. The session sits at payment_status: "unpaid" and status processing until the bank settles, which can take days. Fulfill on completed alone and you ship goods for payments that may still fail.
Stripe emits separate events for the resolution:
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
await fulfill(event.data.object.id);
break;
case "checkout.session.async_payment_failed":
await notifyPaymentFailed(event.data.object.id);
break;
}
And the fulfillment function checks payment status before doing anything, because completed arrives for both instant and delayed methods:
async function fulfill(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items"],
});
if (session.payment_status === "unpaid") return; // still processing
// fulfill here
}
If you only ever accept cards this costs you nothing today. It costs you the first time someone turns on a bank debit method.
Your handler will run more than once#
Stripe states the requirement plainly: your fulfillment function
might be called multiple times, possibly concurrently, for the same Checkout Session.
Three things cause it. Stripe retries failed deliveries. The landing page calls fulfillment too. And completed followed by async_payment_succeeded is two events for one purchase.
The usual fix is a read then a write, which loses the race:
const existing = await db.findFulfillment(sessionId); // two handlers
if (existing) return; // both read null
await db.createFulfillment(sessionId); // both proceed
Let the database decide instead. A unique constraint and one statement:
create table fulfillments (
checkout_session_id text primary key,
fulfilled_at timestamptz not null default now()
);
const { rowCount } = await db.query(
`insert into fulfillments (checkout_session_id)
values ($1) on conflict do nothing`,
[sessionId],
);
if (rowCount === 0) return; // someone else already has it
The client told you the price#
Tutorials build line_items from the cart the browser sent:
line_items: cart.map((item) => ({
price_data: {
currency: "usd",
product_data: { name: item.name },
unit_amount: item.price, // supplied by the browser
},
quantity: item.quantity,
})),
Anyone can edit that request and buy a sofa for a dollar. Stripe charges what it is told to charge; there is no business logic on their side to catch it.
The reason this pattern spreads is that price_data and price sit side by side in Stripe's API and look equally official. Inline price_data has real uses. It is also the shape of the bug.
The client should send an id and a quantity, and nothing else:
const catalog = await db.getPrices(cart.map((item) => item.id));
line_items: cart.map((item) => ({
price: catalog[item.id].stripePriceId,
quantity: item.quantity,
})),
Now work out what they bought#
Line items are not in the webhook payload. Retrieve the session with expand: ["line_items"], and paginate if an order can exceed the page limit.
Then comes the part no tutorial covers, because it is not a Stripe feature: there is no order object. Stripe has Checkout Sessions, PaymentIntents and Charges. None of them is the record your support team opens, your warehouse picks from, or your accountant reconciles.
So you design one, usually in the twenty minutes after the webhook starts firing. Every later feature inherits that schema: order history, partial refunds, exports, returns, subscription upgrades. The table written in a hurry on day three is the one still in production in year two.
The month-two problems#
Inventory. Nothing decrements stock. When you add it, two buyers checking out the last unit both read quantity 1 and both pass. Fixing it means reserving at session creation, releasing on expiry, and committing on payment, which is three new states and a scheduled job.
Refunds. Stripe refunds an amount against a PaymentIntent. "Refund the blue shirt" is arithmetic you write: the line total, its share of the discount, its share of the tax, and what that leaves on the order. Get the tax share wrong and the difference surfaces at year end.
Reconciliation. Payouts arrive as an aggregate. Tying one to the orders inside it means walking balance transactions and matching them back to records Stripe never held.
What you now own#
Counting only the items above, a working store on the tutorial stack needs an orders table, a fulfillment ledger with a uniqueness guarantee, a server-side price authority, handlers for four Checkout events, an inventory reservation with a release path, refund allocation math, and a reconciliation job.
None of it is hard. All of it is yours to keep correct.
The other shape#
The alternative is that the order exists before the payment does, and the payment settles against it.
An order is created with line items, and the totals come back computed rather than asserted:
curl -X POST https://api.withflintpay.com/v1/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"line_items": [
{ "name": "Trail Runner 2", "quantity": 1,
"unit_price_money": { "amount": 12800, "currency": "USD" } }
]
}'
{
"data": {
"order_id": "ord_1kmn0aExample",
"status": "open",
"payment_status": "unpaid",
"pricing_amounts": {
"total_money": { "amount": 12800, "currency": "USD" }
},
"settlement_amounts": {
"outstanding_money": { "amount": 12800, "currency": "USD" }
}
}
}
Checkout opens against that order, so there is no second record to reconcile afterwards and no schema to invent:
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"order_id": "ord_1kmn0aExample",
"redirects": { "success_redirect_url": "https://example.com/thanks" }
}'
Refunds name a line and a quantity, and tax follows automatically:
curl -X POST https://api.withflintpay.com/v1/refunds \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"order_id": "ord_1kmn0aExample",
"line_items": [
{ "order_line_item_id": "li_1kmn0aExample", "quantity": 1,
"tax_refund_mode": "automatic" }
]
}'
Payment status still arrives by webhook, because that part is not a Stripe quirk, it is how card payments work. What changes is that the event points at a record that already existed.
When the tutorial is enough#
One product, one price, digital delivery, no stock to track, no partial refunds, no bank debit methods. Then the Checkout Session genuinely is the order, and adding a commerce API buys you nothing. Ship the tutorial version and spend the time elsewhere.
The gap opens when a second thing becomes true: a catalog, a warehouse, a support team issuing partial refunds, or a finance team asking which orders are inside Tuesday's payout.
Sources#
Everything attributed to Stripe above comes from their documentation, checked on 2026-07-25.
- Fulfill orders: landing pages are not guaranteed, concurrent fulfillment calls, the 10 second redirect wait, async payment events, expanding line items.
- Payment status updates: checking payment status before acting.
- Types of events:
checkout.session.async_payment_succeededandcheckout.session.async_payment_failed. - Resolve webhook signature verification errors: raw body requirements for signature verification.
