Every tutorial shows you the happy path: create a Checkout Session, redirect, done. Nobody shows you what happens when a webhook fails silently, a payment succeeds but your database doesn't update, or a customer refreshes the page mid-checkout.
I built an e-commerce store for a client selling handmade wire bouquets. Here's what the tutorials skip.
The basic setup works great
Stripe Checkout is genuinely well-designed. Creating a session is straightforward:
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: cartItems.map((item) => ({
price_data: {
currency: "usd",
product_data: { name: item.name },
unit_amount: item.price * 100, // Stripe uses cents
},
quantity: item.quantity,
})),
mode: "payment",
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/cart`,
});This works. The checkout page looks professional, handles cards, Apple Pay, Google Pay. But this is where the tutorials stop and the real problems begin.
Webhook handling — where things go wrong
The success_url redirect is for the user. The actual order confirmation must come from webhooks. Here's why: users can close the browser tab before the redirect. They can lose internet. The redirect is not reliable.
// This runs on your server, not the browser
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
// ALWAYS verify the signature
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
const session = event.data.object;
// Fulfill the order here
await fulfillOrder(session);
}
return new Response(null, { status: 200 });
}Critical: Always return a 200 response quickly. Stripe retries webhooks that don't get a 200, which means you might process the same order twice. Use idempotency keys — check if you've already processed this event ID before fulfilling.
The inventory race condition
Here's a fun one: two people buy the same last item at the same time. Both checkout sessions succeed. Both webhooks fire. You now have two orders for one item.
Solutions:
- Reserve inventory when creating the Checkout Session (set a 30-minute TTL)
- Check inventory again in the webhook handler before confirming
- Use database transactions with row-level locking
Failed payment recovery
Cards get declined. Checkout sessions expire after 24 hours. You need a strategy:
- Store the cart in your database with the session ID
- If the session expires, email the customer with a new checkout link
- Track abandoned checkouts — they're a huge revenue opportunity
The order confirmation email
Stripe doesn't send order emails. You need to:
- Listen for
checkout.session.completed - Generate a nice HTML email with order details
- Send it via your email provider (I use Nodemailer)
Don't skip this. Customers expect confirmation. If they don't get one, they'll think the payment failed and try again.
Security checklist
- Webhook signature verification — never skip this
- Idempotency on order creation — prevent duplicates from retries
- Rate limit your checkout endpoint — prevent abuse
- Never trust client-side prices — always calculate server-side
- Store Stripe IDs, not amounts — use Stripe as the source of truth
That last one is important. Don't store $49.99 in your database. Store the Stripe Price ID and look up the amount from Stripe. This prevents price manipulation.
The parts that took longer than expected
- Shipping integration — calculating rates, tracking numbers, updating order status
- Inventory sync — keeping stock counts accurate across webhooks and admin edits
- Image optimization — product photos are huge. Use Next.js
<Image>with proper sizing. - SEO for products — each product page needs its own metadata, structured data, and OG image
The Stripe integration itself took two days. The rest of the store took three weeks.
Want an e-commerce site that works?
I build online stores with Next.js and Stripe — properly, with webhooks, inventory management, and email confirmations from day one. Let's talk.