Asia/Karachi
BlogOctober 4, 2026

Stripe Checkout in a MERN Stack App: A Practical Guide

Server-side prices, a redirect, and a webhook you can trust
Fawaz Israr
Stripe Checkout in a MERN Stack App: A Practical Guide
The simplest Stripe integration for a MERN stack app is Stripe Checkout. Your Express server creates a Checkout Session, your React app redirects the browser to the session URL, and a webhook tells your server when the payment actually succeeded. Stripe hosts the payment page, so you never touch card numbers. This guide walks through that flow with the official stripe Node SDK. I am a final-year Computer Science student who integrated Stripe into My-Shop, a custom gift builder store, and this is the flow I would recommend to anyone starting out. There are three steps, and each one belongs to a different part of your app.
  1. The React client sends the cart (product ids and quantities) to your Express API.
  2. The server looks up prices in MongoDB, creates a Checkout Session, and returns its url. The client sets window.location.href to that URL.
  3. After payment, Stripe calls your webhook endpoint with a checkout.session.completed event. Only then do you mark the order as paid.
The success page the customer lands on afterwards is just a thank-you screen. It is not proof of payment, and I come back to that in the mistakes section. Hosted Checkout also handles card validation and extra authentication steps such as 3D Secure for you, which is a lot of edge-case code you do not have to write or maintain. The Checkout overview lists what the hosted page supports. Anything the browser sends can be edited. If your client posts { total: 1 } and your server forwards it to Stripe, someone will eventually pay one dollar for everything in the store. So the client sends only product ids and quantities. The server reads the real price from its own database and builds the line items itself. The same rule applies to tax, shipping and discounts: work them out on the server before you create the session, and pass the results in as line items or as the shipping_options parameter. On the client, a cart kept in Redux is fine for display. I covered that setup in Build a Shopping Cart with Redux Toolkit, and the last section there explains why the server has to recompute the total anyway. Install the SDK and create one client at startup.
Bash
npm install stripe express mongoose
Store prices as integers in the smallest currency unit (cents for USD). Stripe expects unit_amount as an integer, and keeping it that way in MongoDB avoids rounding problems later.
Js
// routes/checkout.js
import express from "express";
import Stripe from "stripe";
import Product from "../models/Product.js";
import Order from "../models/Order.js";

const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

router.post("/create-checkout-session", async (req, res) => {
  const items = req.body.items; // [{ productId, quantity }]
  const ids = items.map((i) => i.productId);
  const products = await Product.find({ _id: { $in: ids } });

  const lineItems = items.map((item) => {
    const product = products.find((p) => p.id === item.productId);
    if (!product) throw new Error("Unknown product");
    return {
      quantity: item.quantity,
      price_data: {
        currency: "usd",
        unit_amount: product.priceInCents,
        product_data: { name: product.name },
      },
    };
  });

  const order = await Order.create({ items, status: "pending" });

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: lineItems,
    success_url: `${process.env.CLIENT_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.CLIENT_URL}/cart`,
    metadata: { orderId: order.id },
  });

  res.json({ url: session.url });
});

export default router;
In production code you would also validate the quantities, wrap the handler so thrown errors become a 400 response, and put your auth middleware in front of the route. The JWT authentication in Express post shows one way to do that part. The metadata field is how you connect the session back to your own order. Stripe returns it untouched in the webhook event. The client side is short:
Js
const res = await fetch("/api/create-checkout-session", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ items: cart }),
});
const data = await res.json();
window.location.href = data.url;
A laptop on a white desk with code open in an editor
Stripe replaces {CHECKOUT_SESSION_ID} in the success URL with the real session id, so the page can read session_id from the query string. Send that id to your API and ask for the order status, then show "payment received" only when the server says the order is paid. The webhook can arrive a moment after the redirect, so the page may need to ask again after a second or two, or show a "confirming your payment" message until the status changes. Either way, the only thing the page does is read the status. It never changes it. Orders that stay pending (the customer closed the Checkout tab, for example) simply never get a webhook. A scheduled job that clears old pending orders keeps the collection tidy. Stripe signs every webhook request. To check the signature, constructEvent needs the exact bytes Stripe sent, so this one route must receive the raw body instead of parsed JSON.
Js
// app.js
import express from "express";
import checkoutRouter from "./routes/checkout.js";
import { handleWebhook } from "./routes/webhook.js";

const app = express();

// Register the raw-body route BEFORE express.json()
app.post(
  "/api/stripe/webhook",
  express.raw({ type: "application/json" }),
  handleWebhook
);

app.use(express.json());
app.use("/api", checkoutRouter);
Js
// routes/webhook.js
import Stripe from "stripe";
import Order from "../models/Order.js";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function handleWebhook(req, res) {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers["stripe-signature"],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook error: ${err.message}`);
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;
    if (session.payment_status === "paid") {
      // Only flips orders that are still pending, so retries do nothing
      await Order.findOneAndUpdate(
        { _id: session.metadata.orderId, status: "pending" },
        { status: "paid", stripeSessionId: session.id }
      );
    }
  }

  res.json({ received: true });
}
Stripe retries webhooks that do not get a 2xx response, and it can occasionally deliver the same event more than once. The filter on status: "pending" makes the update idempotent: the second delivery matches nothing and changes nothing. If fulfilment also sends an email or reduces stock, put that work behind the same check so it runs once. The payment_status check matters for payment methods that settle later. The session can be completed before the money has arrived. Use test mode keys (they start with sk_test_) and the Stripe CLI, which forwards real test events to your local server. Follow the Stripe CLI documentation to install it, then run:
Bash
stripe login
stripe listen --forward-to localhost:5000/api/stripe/webhook
The listen command prints a signing secret that starts with whsec_. Put that in your .env as STRIPE_WEBHOOK_SECRET for local work. The secret for a deployed endpoint is a different one, created in the Stripe dashboard. Then pay with the test card 4242 4242 4242 4242, any future expiry date and any CVC. You should see the checkout.session.completed event arrive in the CLI output and your order flip to paid. The webhook documentation covers the event format and retry behaviour in more detail.
Bash
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CLIENT_URL=http://localhost:5173
Keep the secret key on the server only. The publishable key is only needed if you use Stripe.js in the browser, which the redirect flow does not require. Add .env to .gitignore before your first commit, and switch to live keys only when the test flow works end to end.
  • Putting express.json() above the webhook route. The body gets parsed, the raw bytes are gone, and constructEvent fails with a signature error that looks like a wrong secret.
  • Marking the order paid on the success page. A customer can type that URL by hand, or close the tab before it loads. The webhook is the only source you can trust.
  • Trusting totals from the client, as described earlier.
  • Doing money math with floats. In JavaScript, 0.1 + 0.2 is 0.30000000000000004. Store integer cents, and round once at the edges if you must convert.
  • Forgetting the idempotent update, then sending two confirmation emails when Stripe retries.
If your store has several sellers, the money flow gets more involved. How to Build a Multi-Vendor E-Commerce Platform shows where payments fit in that larger picture.
  1. Prices, tax, shipping and discounts are all computed on the server.
  2. The webhook route uses express.raw and sits above express.json().
  3. Signature verification is on, and the event handler is idempotent.
  4. Orders are fulfilled from the webhook, never from the success page.
  5. Live keys and a live webhook endpoint secret are set in your hosting environment, not in the repository.
Cover photo from Unsplash.
Share this post: