# How to Build a Checkout Flow with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Core or higher
- Last updated: April 2026

## TL;DR

Build a complete Stripe checkout flow in Replit in 1-2 hours using Express, PostgreSQL, and Drizzle ORM. You'll get a cart, shipping address form, server-side price validation, Stripe Checkout redirect, and order confirmation — plus a webhook that prevents overselling.

## Before you start

- A Replit Core account (required for Replit Auth and built-in PostgreSQL)
- A Stripe account — sign up free at stripe.com, test mode costs nothing
- Your product catalog ready (names, prices in cents, and initial stock quantities)
- An understanding that webhooks only work after deployment — have a deployed Replit URL ready for testing

## Step-by-step guide

### 1. Scaffold the project and run /stripe

Generate the Express + Drizzle project with Agent, then run /stripe to set up Stripe automatically. The schema is specific — the cart uses session_id (not user auth) and the orders table stores a price snapshot.

```
// Step 1: Prompt Replit Agent:
// Build a Node.js Express checkout flow with built-in PostgreSQL using Drizzle ORM.
// Schema in shared/schema.ts:
// * products: id serial pk, name text not null, description text, price integer not null,
//   image_url text, stock integer not null default 0, is_active boolean default true
// * cart_items: id serial pk, session_id text not null,
//   product_id integer references products not null, quantity integer not null default 1,
//   created_at timestamp default now(), unique on (session_id, product_id)
// * orders: id serial pk, customer_email text not null, customer_name text not null,
//   shipping_address jsonb not null, items jsonb not null, subtotal integer not null,
//   shipping_cost integer not null default 0, tax integer not null default 0,
//   total integer not null, status text default 'pending',
//   stripe_checkout_session_id text unique, created_at timestamp default now()
// * webhook_events: id serial pk, stripe_event_id text unique not null,
//   event_type text, processed_at timestamp default now()
// Routes: GET /api/products, POST /api/cart/add, GET /api/cart,
// PATCH /api/cart/:id, DELETE /api/cart/:id, POST /api/checkout,
// GET /api/orders/:id, POST /api/webhooks/stripe
// Cart session via HTTP-only cookie (session_id = UUID)
// React 4-step checkout: cart review, shipping address, order summary, confirmation

// Step 2: In Replit Agent chat, type: /stripe
```

> Pro tip: After running /stripe, verify the webhook route is registered BEFORE app.use(express.json()) in server/index.js. The /stripe command sometimes adds it in the wrong order. Check and fix manually if needed.

**Expected result:** Project running with all schema tables. /stripe installs stripe package and adds STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY to workspace Secrets.

### 2. Build the cart with session-based persistence

The cart identifies users by a UUID stored in an HTTP-only cookie — no account required. On first visit, a new session_id is generated and set. All subsequent requests include the cookie automatically.

```
import { v4 as uuidv4 } from 'uuid';
import { db } from '../db.js';
import { cartItems, products } from '../../shared/schema.js';
import { eq, and } from 'drizzle-orm';

function getOrCreateSessionId(req, res) {
  let sessionId = req.cookies?.cart_session;
  if (!sessionId) {
    sessionId = uuidv4();
    res.cookie('cart_session', sessionId, {
      httpOnly: true,
      maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
    });
  }
  return sessionId;
}

// POST /api/cart/add
export async function addToCart(req, res) {
  const sessionId = getOrCreateSessionId(req, res);
  const { productId, quantity = 1 } = req.body;

  const [product] = await db.select().from(products).where(
    and(eq(products.id, parseInt(productId)), eq(products.isActive, true))
  );
  if (!product) return res.status(404).json({ error: 'Product not found' });
  if (product.stock < quantity) return res.status(400).json({ error: 'Insufficient stock' });

  // Insert or increment quantity
  await db.insert(cartItems).values({ sessionId, productId: parseInt(productId), quantity })
    .onConflictDoUpdate({
      target: [cartItems.sessionId, cartItems.productId],
      set: { quantity: db.raw(`cart_items.quantity + ${quantity}`) },
    });

  res.json({ success: true });
}

// GET /api/cart
export async function getCart(req, res) {
  const sessionId = req.cookies?.cart_session;
  if (!sessionId) return res.json({ items: [], total: 0 });

  const items = await db
    .select({
      cartItemId: cartItems.id, quantity: cartItems.quantity,
      productId: products.id, name: products.name, price: products.price,
      imageUrl: products.imageUrl, stock: products.stock,
    })
    .from(cartItems)
    .leftJoin(products, eq(cartItems.productId, products.id))
    .where(eq(cartItems.sessionId, sessionId));

  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  res.json({ items, subtotal, itemCount: items.reduce((n, item) => n + item.quantity, 0) });
}
```

> Pro tip: Install the cookie-parser middleware: npm install cookie-parser. Add app.use(cookieParser()) in server/index.js before your routes. Without it, req.cookies is undefined.

**Expected result:** POST /api/cart/add adds a product to the cart and sets the cart_session cookie. GET /api/cart returns cart items with product details and a calculated subtotal.

### 3. Build the secure checkout endpoint

This is the security boundary. Re-read every product price from the database, calculate the total server-side, and create the Stripe Checkout Session. Never use a price from the request body.

```
import Stripe from 'stripe';
import { db } from '../db.js';
import { cartItems, products } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const TAX_RATE = 0.08; // 8% tax
const SHIPPING_COST = 500; // $5.00 flat shipping in cents

export async function createCheckout(req, res) {
  const sessionId = req.cookies?.cart_session;
  if (!sessionId) return res.status(400).json({ error: 'Cart is empty' });

  const { shippingAddress, customerEmail, customerName } = req.body;
  if (!shippingAddress?.line1 || !customerEmail) {
    return res.status(400).json({ error: 'Shipping address and email are required' });
  }

  // Re-fetch cart items with CURRENT prices from database (security boundary)
  const cartContents = await db
    .select({ cartItemId: cartItems.id, quantity: cartItems.quantity, product: products })
    .from(cartItems)
    .leftJoin(products, eq(cartItems.productId, products.id))
    .where(eq(cartItems.sessionId, sessionId));

  if (cartContents.length === 0) return res.status(400).json({ error: 'Cart is empty' });

  // Validate stock
  for (const item of cartContents) {
    if (item.product.stock < item.quantity) {
      return res.status(400).json({ error: `Insufficient stock for ${item.product.name}` });
    }
  }

  const subtotal = cartContents.reduce((s, i) => s + i.product.price * i.quantity, 0);
  const tax = Math.round(subtotal * TAX_RATE);
  const total = subtotal + SHIPPING_COST + tax;

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    mode: 'payment',
    line_items: [
      ...cartContents.map(item => ({
        price_data: {
          currency: 'usd',
          unit_amount: item.product.price, // server-fetched price
          product_data: { name: item.product.name, images: item.product.imageUrl ? [item.product.imageUrl] : [] },
        },
        quantity: item.quantity,
      })),
      { price_data: { currency: 'usd', unit_amount: SHIPPING_COST, product_data: { name: 'Shipping' } }, quantity: 1 },
      { price_data: { currency: 'usd', unit_amount: tax, product_data: { name: 'Tax' } }, quantity: 1 },
    ],
    customer_email: customerEmail,
    success_url: `${process.env.APP_URL}/order-confirmation?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/cart`,
    metadata: { cartSessionId: sessionId, customerName, shippingAddress: JSON.stringify(shippingAddress) },
  });

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

> Pro tip: Pass shippingAddress as metadata in the Stripe session (as JSON string). Your webhook handler reads it from session.metadata when creating the order record — this way shipping info is available even if the user closes their browser before reaching the confirmation page.

**Expected result:** POST /api/checkout with shipping address and email returns a Stripe Checkout URL. The checkout page shows correct item names, quantities, and server-calculated prices — not client-supplied ones.

### 4. Build the webhook handler with overselling prevention

The webhook fires after successful payment. It creates the order record, decrements stock using an atomic UPDATE (preventing overselling), and clears the cart. Webhooks only fire on deployed URLs.

```
import Stripe from 'stripe';
import { db } from '../db.js';
import { orders, products, cartItems, webhookEvents } from '../../shared/schema.js';
import { eq, sql } from 'drizzle-orm';

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

// Register BEFORE app.use(express.json()) in server/index.js:
// app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), stripeWebhook)
export async function stripeWebhook(req, res) {
  const sig = req.headers['stripe-signature'];
  let event;
  try {
    // constructEvent is synchronous (Node.js) — NOT constructEventAsync (Deno-only)
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Idempotency check
  const [dup] = await db.select().from(webhookEvents).where(eq(webhookEvents.stripeEventId, event.id));
  if (dup) return res.json({ received: true, duplicate: true });
  await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    const { cartSessionId, customerName, shippingAddress } = session.metadata;
    const parsedAddress = JSON.parse(shippingAddress);

    // Fetch cart to get items snapshot
    const cartContents = await db
      .select({ quantity: cartItems.quantity, product: products })
      .from(cartItems)
      .leftJoin(products, eq(cartItems.productId, products.id))
      .where(eq(cartItems.sessionId, cartSessionId));

    // Atomically decrement stock — fails gracefully if stock ran out
    const stockOk = await Promise.all(cartContents.map(async item => {
      const result = await db.execute(
        sql`UPDATE products SET stock = stock - ${item.quantity} WHERE id = ${item.product.id} AND stock >= ${item.quantity} RETURNING id`
      );
      return result.rows.length > 0;
    }));

    if (stockOk.every(Boolean)) {
      const subtotal = cartContents.reduce((s, i) => s + i.product.price * i.quantity, 0);
      await db.insert(orders).values({
        customerEmail: session.customer_email,
        customerName,
        shippingAddress: parsedAddress,
        items: cartContents.map(i => ({ name: i.product.name, qty: i.quantity, price: i.product.price })),
        subtotal,
        total: session.amount_total,
        status: 'paid',
        stripeCheckoutSessionId: session.id,
      });

      // Clear the cart
      await db.delete(cartItems).where(eq(cartItems.sessionId, cartSessionId));
    }
  }

  res.json({ received: true });
}
```

> Pro tip: The UPDATE products SET stock = stock - qty WHERE id = :id AND stock >= qty RETURNING id pattern is atomic — it both checks and decrements in one SQL statement. If stock is insufficient, the WHERE clause fails and no rows are returned. Check result.rows.length > 0 to detect this.

**Expected result:** After Stripe Checkout payment with test card 4242 4242 4242 4242, the webhook fires, creates the order record, decrements product stock, and clears the cart. The order appears in your orders table in Drizzle Studio.

### 5. Deploy on Autoscale and test the full payment flow

Deploy to get a public URL, register it as your Stripe webhook endpoint, then test the complete flow end-to-end in Stripe test mode.

```
// Deployment checklist:
// 1. Click Deploy → Autoscale in Replit
// 2. Add Deployment Secrets (separate from workspace Secrets):
//    STRIPE_SECRET_KEY=sk_test_...
//    STRIPE_WEBHOOK_SECRET=whsec_... (get this step 3)
//    APP_URL=https://your-deployed-url.replit.app
//
// 3. Register webhook in Stripe Dashboard:
//    Developers → Webhooks → Add endpoint
//    URL: https://your-deployed-url.replit.app/api/webhooks/stripe
//    Events: checkout.session.completed
//    Copy the signing secret → add as STRIPE_WEBHOOK_SECRET in Deployment Secrets
//
// 4. Test the full flow:
//    - Add product to cart (POST /api/cart/add)
//    - Enter shipping address (triggers POST /api/checkout)
//    - Stripe Checkout page: use card 4242 4242 4242 4242, any future date, any CVC
//    - Confirm redirect to order confirmation page
//    - Check orders table in Drizzle Studio — order should be there with status 'paid'
//    - Check products table — stock should be decremented
```

> Pro tip: Stripe Dashboard → Developers → Webhooks → your endpoint → Recent deliveries shows all webhook attempts and their responses. If something fails, check the response body there to diagnose the error.

**Expected result:** End-to-end test passes: cart → checkout → Stripe payment → order confirmation → order in database with stock decremented.

## Complete code example

File: `server/routes/checkout.js`

```javascript
import Stripe from 'stripe';
import { db } from '../db.js';
import { cartItems, products } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

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

export async function createCheckout(req, res) {
  const sessionId = req.cookies?.cart_session;
  if (!sessionId) return res.status(400).json({ error: 'No cart found' });

  const { shippingAddress, customerEmail, customerName } = req.body;
  if (!shippingAddress?.line1 || !customerEmail || !customerName) {
    return res.status(400).json({ error: 'shippingAddress, customerEmail, and customerName are required' });
  }

  const cartContents = await db
    .select({ quantity: cartItems.quantity, product: products })
    .from(cartItems)
    .leftJoin(products, eq(cartItems.productId, products.id))
    .where(eq(cartItems.sessionId, sessionId));

  if (cartContents.length === 0) return res.status(400).json({ error: 'Cart is empty' });

  for (const { quantity, product } of cartContents) {
    if (!product?.isActive) return res.status(400).json({ error: `Product ${product?.name} is no longer available` });
    if (product.stock < quantity) return res.status(400).json({ error: `Only ${product.stock} of ${product.name} in stock` });
  }

  const TAX_RATE = 0.08;
  const SHIP = 500;
  const subtotal = cartContents.reduce((s, { quantity, product }) => s + product.price * quantity, 0);
  const tax = Math.round(subtotal * TAX_RATE);

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    mode: 'payment',
    customer_email: customerEmail,
    line_items: [
      ...cartContents.map(({ quantity, product }) => ({
        price_data: { currency: 'usd', unit_amount: product.price, product_data: { name: product.name } },
        quantity,
      })),
      { price_data: { currency: 'usd', unit_amount: SHIP, product_data: { name: 'Shipping' } }, quantity: 1 },
      { price_data: { currency: 'usd', unit_amount: tax, product_data: { name: 'Tax (8%)' } }, quantity: 1 },
    ],
    success_url: `${process.env.APP_URL}/order-confirmation?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/cart`,
    metadata: {
      cartSessionId: sessionId,
      customerName,
      shippingAddress: JSON.stringify(shippingAddress),
    },
  });

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

## Common mistakes

- **Trusting product prices from the client request body** — If you create the Stripe Checkout Session using a price value from req.body, a malicious user can modify the price to $0.01 and purchase for almost nothing. Fix: Always re-fetch product prices from the database inside the checkout endpoint. Never use client-supplied price values in the Stripe session creation.
- **Registering the webhook route after express.json()** — Stripe's constructEvent() needs the raw request body bytes to verify the HMAC signature. express.json() parses the body and destroys the raw bytes. Fix: In server/index.js, register app.post('/api/webhooks/stripe', express.raw({type:'application/json'}), stripeWebhook) as the very first route, before app.use(express.json()).
- **Testing webhooks without deploying first** — Replit development servers don't have a publicly accessible URL. Stripe can't reach your dev server to fire webhooks. Fix: Deploy on Autoscale to get a public URL, register it in Stripe Dashboard, and test the full payment flow using Stripe's test cards in test mode.

## Best practices

- Re-fetch all product prices from the database inside the checkout route — never trust client-supplied prices.
- Register the Stripe webhook route with express.raw() BEFORE app.use(express.json()) in server/index.js.
- Use UPDATE ... WHERE stock >= qty RETURNING id to atomically check and decrement stock in the webhook handler.
- Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in Deployment Secrets separately from workspace Secrets.
- Add idempotency to the webhook handler by checking webhook_events for the event.id before processing.
- Use constructEvent() (synchronous) not constructEventAsync() — the async version is for Deno environments.
- Deploy on Autoscale — checkout traffic is spiky (promotions, product launches) and cold starts are hidden by cart browsing time.

## Frequently asked questions

### Why do I have to deploy before I can test Stripe webhooks?

Stripe webhooks are HTTP POST requests from Stripe's servers to your server. Your development Replit doesn't have a public URL — only deployed apps do. You must deploy on Autoscale to get a permanent URL like https://your-app.replit.app, then register that URL in Stripe Dashboard as your webhook endpoint.

### Can customers check out without creating an account?

Yes — that's the design. The cart uses a session_id stored in an HTTP-only cookie, so customers can add items and check out without signing in. Only the customer_email (from the checkout form) is required. To track order history for returning customers, you can offer an optional account creation step after order confirmation.

### What happens if a product sells out between a customer adding it to cart and checking out?

The checkout endpoint validates stock against the database before creating the Stripe session. If stock is insufficient, it returns an error telling the customer which item is out of stock. The webhook also uses an atomic UPDATE to prevent overselling in the rare case of concurrent checkouts.

### How do I add real tax calculation instead of a flat 8%?

Use the TaxJar or Avalara API to calculate tax based on the customer's shipping address. Both have free tiers. Pass the shipping address to their API before creating the Stripe session and use the returned tax amount. Store the tax API key in Replit Secrets.

### Do Deployment Secrets automatically inherit from workspace Secrets?

No. Workspace Secrets (the lock icon in the Replit sidebar) are only available during development. When you deploy, the app runs in a separate environment. You must add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and APP_URL again under Deployments → Secrets.

### Can RapidDev help build a custom checkout flow for my e-commerce store?

Yes. RapidDev has built 600+ apps including e-commerce checkouts with multi-currency support, custom tax calculation, promo codes, and post-purchase upsells. Contact us for a free consultation.

### What Stripe test cards can I use to test different scenarios?

Use 4242 4242 4242 4242 for successful payments. Use 4000 0000 0000 9995 to simulate a declined card. Use 4000 0027 6000 3184 to test 3D Secure authentication. All test cards use any future expiry date and any 3-digit CVC.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/checkout-flow
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/checkout-flow
