Skip to content
Express.js projects 5 min read

Project: E-Commerce API

An e-commerce backend is where API design stops being academic and starts touching money. You have to model a catalog, let shoppers build a cart, turn that cart into an order, charge a card, and never double-charge a customer who clicked twice. In this project you will build those endpoints with Express, integrate Stripe for payments, protect inventory from overselling, make checkout idempotent, and lock administrative routes behind role-based authorization.

Architecture overview

The domain breaks down into four resources, each with its own router. Products are public to read and admin-only to mutate. Carts and orders are scoped to the authenticated user. Payment lives at checkout, where the order is validated, stock is reserved, and a Stripe charge is created inside a single idempotent flow.

ResourceRead accessWrite accessNotes
ProductsPublicAdmin onlyCatalog with price + stock
CartOwnerOwnerOne active cart per user
OrdersOwner / AdminSystem (checkout)Immutable once placed
PaymentsSystemStripe PaymentIntent per order

Project setup

npm init -y
npm install express stripe zod jsonwebtoken
npm install -D nodemon

Keep secrets in environment variables — Stripe keys especially must never reach the client or your repo.

# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
JWT_SECRET=replace-with-32+-random-bytes

Products and admin authorization

Anyone can browse the catalog, but only administrators can create or update products. A tiny requireRole middleware reads the role off the verified JWT (see the Auth Service project for how that token is minted) and rejects everyone else.

import { Router } from "express";
import { z } from "zod";
import { db } from "./db.js";

export const requireRole = (role) => (req, res, next) => {
  if (req.user?.role !== role) {
    return res.status(403).json({ error: "Forbidden" });
  }
  next();
};

const router = Router();
const productSchema = z.object({
  name: z.string().min(1),
  priceCents: z.number().int().positive(),
  stock: z.number().int().nonnegative(),
});

router.get("/", async (_req, res) => {
  res.json(await db.products.list());
});

router.post("/", requireRole("admin"), async (req, res) => {
  const data = productSchema.parse(req.body);
  const product = await db.products.create(data);
  res.status(201).json(product);
});

export default router;

Always store money as integer cents, never floats. 0.1 + 0.2 !== 0.3 in JavaScript, and rounding errors at scale turn into accounting nightmares. Stripe also expects amounts in the smallest currency unit.

The cart

A cart is a per-user collection of line items. Adding an item upserts a quantity; the cart total is always computed from current product prices rather than stored, so price changes are reflected until checkout freezes them.

const cart = Router();
const lineSchema = z.object({ productId: z.string(), quantity: z.number().int().positive() });

cart.post("/items", async (req, res) => {
  const { productId, quantity } = lineSchema.parse(req.body);
  const product = await db.products.findById(productId);
  if (!product) return res.status(404).json({ error: "Product not found" });

  await db.carts.upsertItem(req.user.sub, productId, quantity);
  res.status(201).json(await db.carts.get(req.user.sub));
});

cart.get("/", async (req, res) => {
  res.json(await db.carts.get(req.user.sub));
});

Output: the cart after adding two items.

{
  "items": [
    { "productId": "sku_42", "name": "Mechanical Keyboard", "priceCents": 8900, "quantity": 1 },
    { "productId": "sku_77", "name": "USB-C Cable",         "priceCents": 1200, "quantity": 2 }
  ],
  "totalCents": 11300
}

Idempotent checkout with Stripe

Checkout is the critical path. Two problems must be solved at once: a customer who submits the form twice should never be charged twice, and two simultaneous orders must not oversell stock. Idempotency keys solve the first; an atomic stock decrement inside a transaction solves the second.

The client sends a unique Idempotency-Key header (a UUID it generates once per checkout attempt). If the same key arrives again, you return the already-created order instead of charging again. Stripe accepts the same key on its API so the retried PaymentIntent is also deduplicated.

import Stripe from "stripe";

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

checkout.post("/", async (req, res, next) => {
  const key = req.header("Idempotency-Key");
  if (!key) return res.status(400).json({ error: "Idempotency-Key header required" });

  const existing = await db.orders.findByIdempotencyKey(key);
  if (existing) return res.status(200).json(existing); // safe replay

  try {
    const order = await db.tx(async (trx) => {
      const cart = await db.carts.get(req.user.sub, trx);
      if (!cart.items.length) throw new HttpError(400, "Cart is empty");

      // Atomically decrement stock; fails if any item is short.
      for (const item of cart.items) {
        const ok = await db.products.decrementStock(item.productId, item.quantity, trx);
        if (!ok) throw new HttpError(409, `Insufficient stock for ${item.productId}`);
      }

      return db.orders.create(
        { userId: req.user.sub, items: cart.items, totalCents: cart.totalCents, status: "pending" },
        key,
        trx
      );
    });

    const intent = await stripe.paymentIntents.create(
      { amount: order.totalCents, currency: "usd", metadata: { orderId: order.id } },
      { idempotencyKey: key }
    );

    await db.carts.clear(req.user.sub);
    res.status(201).json({ orderId: order.id, clientSecret: intent.client_secret });
  } catch (err) {
    next(err);
  }
});

The decrementStock query is what prevents overselling. Push the check into the database with a conditional update so the row lock does the work:

UPDATE products SET stock = stock - $2
WHERE id = $1 AND stock >= $2;
-- returns 0 rows affected when stock is insufficient

Generate the idempotency key on the client, once, before the request — not on the server. A server-generated key changes on every retry and defeats the entire purpose.

Confirming payment via webhook

Never mark an order paid from the browser response; the user can close the tab mid-payment. Instead, listen for Stripe’s payment_intent.succeeded webhook. The raw body is required so the signature can be verified.

import express from "express";

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.header("stripe-signature"),
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch {
      return res.status(400).send("Invalid signature");
    }

    if (event.type === "payment_intent.succeeded") {
      const { orderId } = event.data.object.metadata;
      await db.orders.markPaid(orderId);
    }
    res.json({ received: true });
  }
);

Mount this route before express.json(), otherwise the body parser consumes the stream and signature verification fails.

Best practices

  • Store all monetary values as integer cents and let Stripe handle currency formatting.
  • Require a client-generated idempotency key on checkout and reuse it for the Stripe call so retries never double-charge.
  • Decrement stock with a conditional UPDATE inside a transaction so concurrent orders cannot oversell.
  • Confirm payment from the verified webhook, not the checkout HTTP response, and verify the signature against the raw body.
  • Gate every mutating catalog route behind requireRole("admin") and scope cart and order reads to the owning user.
  • Freeze line-item prices onto the order at checkout time so later catalog edits never rewrite a customer’s history.
  • Make orders immutable once placed; model cancellations and refunds as new state transitions, not edits.
Last updated June 14, 2026
Was this helpful?