Flint blog

Integration

Next.js and Stripe: What the Tutorials Leave Out

The Next.js Stripe tutorials stop at the Checkout Session and the webhook. Stripe's own docs ask for more, and production asks for more still.

6 min readBy Flint Pay, Product & API Team

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.

TypeScript
// 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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

sql
create table fulfillments (
  checkout_session_id text primary key,
  fulfilled_at timestamptz not null default now()
);
TypeScript
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:

TypeScript
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:

TypeScript
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:

Bash
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" } }
    ]
  }'
JSON
{
  "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:

Bash
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:

Bash
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.

Next step

Start from the order

Create an order, open a checkout session against it, and refund a single line without writing the allocation math.