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.
What you're building
A checkout flow connects product browsing to a confirmed order. It's not just a Stripe button — it's the full purchase pipeline: customer reviews their cart, enters a shipping address, sees an order summary with tax and shipping calculated server-side, gets redirected to Stripe Checkout to pay, then lands on a confirmation page. Every price is re-read from the database before creating the Stripe session so client-side price tampering is impossible.
Replit's /stripe command auto-provisions a Stripe sandbox, installs the SDK, and creates the webhook handler boilerplate. The most security-critical pattern here is that prices must be fetched server-side and never trusted from the client. A determined user who knows your API could otherwise modify the price in the checkout request.
The webhook handler is the back end of the purchase confirmation loop: when Stripe calls your deployed URL after a successful payment, it creates the order record, decrements stock in a transaction (preventing overselling when two users buy the last item simultaneously), and clears the cart. Webhooks only fire to deployed URLs — not to your development Replit — so deployment is part of testing.
Final result
A production-ready checkout flow with a session-based guest cart, server-side price validation, Stripe Checkout integration, overselling prevention webhook, and order confirmation page.
Tech stack
Prerequisites
- 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
Build steps
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.
1// Step 1: Prompt Replit Agent:2// Build a Node.js Express checkout flow with built-in PostgreSQL using Drizzle ORM.3// Schema in shared/schema.ts:4// * products: id serial pk, name text not null, description text, price integer not null,5// image_url text, stock integer not null default 0, is_active boolean default true6// * cart_items: id serial pk, session_id text not null,7// product_id integer references products not null, quantity integer not null default 1,8// created_at timestamp default now(), unique on (session_id, product_id)9// * orders: id serial pk, customer_email text not null, customer_name text not null,10// shipping_address jsonb not null, items jsonb not null, subtotal integer not null,11// shipping_cost integer not null default 0, tax integer not null default 0,12// total integer not null, status text default 'pending',13// stripe_checkout_session_id text unique, created_at timestamp default now()14// * webhook_events: id serial pk, stripe_event_id text unique not null,15// event_type text, processed_at timestamp default now()16// Routes: GET /api/products, POST /api/cart/add, GET /api/cart,17// PATCH /api/cart/:id, DELETE /api/cart/:id, POST /api/checkout,18// GET /api/orders/:id, POST /api/webhooks/stripe19// Cart session via HTTP-only cookie (session_id = UUID)20// React 4-step checkout: cart review, shipping address, order summary, confirmation2122// Step 2: In Replit Agent chat, type: /stripePro 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.
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.
1import { v4 as uuidv4 } from 'uuid';2import { db } from '../db.js';3import { cartItems, products } from '../../shared/schema.js';4import { eq, and } from 'drizzle-orm';56function getOrCreateSessionId(req, res) {7 let sessionId = req.cookies?.cart_session;8 if (!sessionId) {9 sessionId = uuidv4();10 res.cookie('cart_session', sessionId, {11 httpOnly: true,12 maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days13 secure: process.env.NODE_ENV === 'production',14 sameSite: 'lax',15 });16 }17 return sessionId;18}1920// POST /api/cart/add21export async function addToCart(req, res) {22 const sessionId = getOrCreateSessionId(req, res);23 const { productId, quantity = 1 } = req.body;2425 const [product] = await db.select().from(products).where(26 and(eq(products.id, parseInt(productId)), eq(products.isActive, true))27 );28 if (!product) return res.status(404).json({ error: 'Product not found' });29 if (product.stock < quantity) return res.status(400).json({ error: 'Insufficient stock' });3031 // Insert or increment quantity32 await db.insert(cartItems).values({ sessionId, productId: parseInt(productId), quantity })33 .onConflictDoUpdate({34 target: [cartItems.sessionId, cartItems.productId],35 set: { quantity: db.raw(`cart_items.quantity + ${quantity}`) },36 });3738 res.json({ success: true });39}4041// GET /api/cart42export async function getCart(req, res) {43 const sessionId = req.cookies?.cart_session;44 if (!sessionId) return res.json({ items: [], total: 0 });4546 const items = await db47 .select({48 cartItemId: cartItems.id, quantity: cartItems.quantity,49 productId: products.id, name: products.name, price: products.price,50 imageUrl: products.imageUrl, stock: products.stock,51 })52 .from(cartItems)53 .leftJoin(products, eq(cartItems.productId, products.id))54 .where(eq(cartItems.sessionId, sessionId));5556 const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);57 res.json({ items, subtotal, itemCount: items.reduce((n, item) => n + item.quantity, 0) });58}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.
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.
1import Stripe from 'stripe';2import { db } from '../db.js';3import { cartItems, products } from '../../shared/schema.js';4import { eq } from 'drizzle-orm';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);7const TAX_RATE = 0.08; // 8% tax8const SHIPPING_COST = 500; // $5.00 flat shipping in cents910export async function createCheckout(req, res) {11 const sessionId = req.cookies?.cart_session;12 if (!sessionId) return res.status(400).json({ error: 'Cart is empty' });1314 const { shippingAddress, customerEmail, customerName } = req.body;15 if (!shippingAddress?.line1 || !customerEmail) {16 return res.status(400).json({ error: 'Shipping address and email are required' });17 }1819 // Re-fetch cart items with CURRENT prices from database (security boundary)20 const cartContents = await db21 .select({ cartItemId: cartItems.id, quantity: cartItems.quantity, product: products })22 .from(cartItems)23 .leftJoin(products, eq(cartItems.productId, products.id))24 .where(eq(cartItems.sessionId, sessionId));2526 if (cartContents.length === 0) return res.status(400).json({ error: 'Cart is empty' });2728 // Validate stock29 for (const item of cartContents) {30 if (item.product.stock < item.quantity) {31 return res.status(400).json({ error: `Insufficient stock for ${item.product.name}` });32 }33 }3435 const subtotal = cartContents.reduce((s, i) => s + i.product.price * i.quantity, 0);36 const tax = Math.round(subtotal * TAX_RATE);37 const total = subtotal + SHIPPING_COST + tax;3839 const session = await stripe.checkout.sessions.create({40 payment_method_types: ['card'],41 mode: 'payment',42 line_items: [43 ...cartContents.map(item => ({44 price_data: {45 currency: 'usd',46 unit_amount: item.product.price, // server-fetched price47 product_data: { name: item.product.name, images: item.product.imageUrl ? [item.product.imageUrl] : [] },48 },49 quantity: item.quantity,50 })),51 { price_data: { currency: 'usd', unit_amount: SHIPPING_COST, product_data: { name: 'Shipping' } }, quantity: 1 },52 { price_data: { currency: 'usd', unit_amount: tax, product_data: { name: 'Tax' } }, quantity: 1 },53 ],54 customer_email: customerEmail,55 success_url: `${process.env.APP_URL}/order-confirmation?session_id={CHECKOUT_SESSION_ID}`,56 cancel_url: `${process.env.APP_URL}/cart`,57 metadata: { cartSessionId: sessionId, customerName, shippingAddress: JSON.stringify(shippingAddress) },58 });5960 res.json({ url: session.url });61}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.
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.
1import Stripe from 'stripe';2import { db } from '../db.js';3import { orders, products, cartItems, webhookEvents } from '../../shared/schema.js';4import { eq, sql } from 'drizzle-orm';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);78// Register BEFORE app.use(express.json()) in server/index.js:9// app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), stripeWebhook)10export async function stripeWebhook(req, res) {11 const sig = req.headers['stripe-signature'];12 let event;13 try {14 // constructEvent is synchronous (Node.js) — NOT constructEventAsync (Deno-only)15 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);16 } catch (err) {17 return res.status(400).send(`Webhook Error: ${err.message}`);18 }1920 // Idempotency check21 const [dup] = await db.select().from(webhookEvents).where(eq(webhookEvents.stripeEventId, event.id));22 if (dup) return res.json({ received: true, duplicate: true });23 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });2425 if (event.type === 'checkout.session.completed') {26 const session = event.data.object;27 const { cartSessionId, customerName, shippingAddress } = session.metadata;28 const parsedAddress = JSON.parse(shippingAddress);2930 // Fetch cart to get items snapshot31 const cartContents = await db32 .select({ quantity: cartItems.quantity, product: products })33 .from(cartItems)34 .leftJoin(products, eq(cartItems.productId, products.id))35 .where(eq(cartItems.sessionId, cartSessionId));3637 // Atomically decrement stock — fails gracefully if stock ran out38 const stockOk = await Promise.all(cartContents.map(async item => {39 const result = await db.execute(40 sql`UPDATE products SET stock = stock - ${item.quantity} WHERE id = ${item.product.id} AND stock >= ${item.quantity} RETURNING id`41 );42 return result.rows.length > 0;43 }));4445 if (stockOk.every(Boolean)) {46 const subtotal = cartContents.reduce((s, i) => s + i.product.price * i.quantity, 0);47 await db.insert(orders).values({48 customerEmail: session.customer_email,49 customerName,50 shippingAddress: parsedAddress,51 items: cartContents.map(i => ({ name: i.product.name, qty: i.quantity, price: i.product.price })),52 subtotal,53 total: session.amount_total,54 status: 'paid',55 stripeCheckoutSessionId: session.id,56 });5758 // Clear the cart59 await db.delete(cartItems).where(eq(cartItems.sessionId, cartSessionId));60 }61 }6263 res.json({ received: true });64}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.
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.
1// Deployment checklist:2// 1. Click Deploy → Autoscale in Replit3// 2. Add Deployment Secrets (separate from workspace Secrets):4// STRIPE_SECRET_KEY=sk_test_...5// STRIPE_WEBHOOK_SECRET=whsec_... (get this step 3)6// APP_URL=https://your-deployed-url.replit.app7//8// 3. Register webhook in Stripe Dashboard:9// Developers → Webhooks → Add endpoint10// URL: https://your-deployed-url.replit.app/api/webhooks/stripe11// Events: checkout.session.completed12// Copy the signing secret → add as STRIPE_WEBHOOK_SECRET in Deployment Secrets13//14// 4. Test the full flow:15// - Add product to cart (POST /api/cart/add)16// - Enter shipping address (triggers POST /api/checkout)17// - Stripe Checkout page: use card 4242 4242 4242 4242, any future date, any CVC18// - Confirm redirect to order confirmation page19// - Check orders table in Drizzle Studio — order should be there with status 'paid'20// - Check products table — stock should be decrementedPro 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
1import Stripe from 'stripe';2import { db } from '../db.js';3import { cartItems, products } from '../../shared/schema.js';4import { eq } from 'drizzle-orm';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);78export async function createCheckout(req, res) {9 const sessionId = req.cookies?.cart_session;10 if (!sessionId) return res.status(400).json({ error: 'No cart found' });1112 const { shippingAddress, customerEmail, customerName } = req.body;13 if (!shippingAddress?.line1 || !customerEmail || !customerName) {14 return res.status(400).json({ error: 'shippingAddress, customerEmail, and customerName are required' });15 }1617 const cartContents = await db18 .select({ quantity: cartItems.quantity, product: products })19 .from(cartItems)20 .leftJoin(products, eq(cartItems.productId, products.id))21 .where(eq(cartItems.sessionId, sessionId));2223 if (cartContents.length === 0) return res.status(400).json({ error: 'Cart is empty' });2425 for (const { quantity, product } of cartContents) {26 if (!product?.isActive) return res.status(400).json({ error: `Product ${product?.name} is no longer available` });27 if (product.stock < quantity) return res.status(400).json({ error: `Only ${product.stock} of ${product.name} in stock` });28 }2930 const TAX_RATE = 0.08;31 const SHIP = 500;32 const subtotal = cartContents.reduce((s, { quantity, product }) => s + product.price * quantity, 0);33 const tax = Math.round(subtotal * TAX_RATE);3435 const session = await stripe.checkout.sessions.create({36 payment_method_types: ['card'],37 mode: 'payment',38 customer_email: customerEmail,39 line_items: [40 ...cartContents.map(({ quantity, product }) => ({41 price_data: { currency: 'usd', unit_amount: product.price, product_data: { name: product.name } },42 quantity,43 })),44 { price_data: { currency: 'usd', unit_amount: SHIP, product_data: { name: 'Shipping' } }, quantity: 1 },45 { price_data: { currency: 'usd', unit_amount: tax, product_data: { name: 'Tax (8%)' } }, quantity: 1 },46 ],47 success_url: `${process.env.APP_URL}/order-confirmation?session_id={CHECKOUT_SESSION_ID}`,48 cancel_url: `${process.env.APP_URL}/cart`,49 metadata: {50 cartSessionId: sessionId,51 customerName,52 shippingAddress: JSON.stringify(shippingAddress),53 },54 });5556 res.json({ url: session.url });57}Customization ideas
Discount codes
Add a discount_codes table with code, discount_percent, and uses_remaining. In the checkout endpoint, accept an optional couponCode parameter, validate it against the table, and apply the discount to the subtotal before calculating the Stripe session total.
Order status page with Stripe Payment Intent tracking
Add a GET /api/orders/:id route that returns order status. On the confirmation page, poll this route every 10 seconds. When the webhook fires and sets status to 'paid', the confirmation page updates automatically.
Guest checkout → account creation prompt
After order confirmation, prompt the customer to create an account. If they do (via Replit Auth or email/password), associate their order history with their new account and clear the guest session.
Common pitfalls
Pitfall: Trusting product prices from the client request body
How to avoid: Always re-fetch product prices from the database inside the checkout endpoint. Never use client-supplied price values in the Stripe session creation.
Pitfall: Registering the webhook route after express.json()
How to avoid: 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()).
Pitfall: Testing webhooks without deploying first
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a checkout flow with Express, PostgreSQL, and Stripe. The checkout endpoint creates a Stripe Checkout Session in payment mode. Help me write the complete checkout route that: (1) reads cart items from a cart_items table joined with a products table using the session_id from an HTTP-only cookie, (2) fetches current product prices from the database (never from the request body), (3) validates stock for each item, (4) calculates subtotal, 8% tax, and flat $5 shipping server-side, and (5) creates a Stripe Checkout Session with line_items built from the server-fetched prices.
Add order tracking emails to the checkout flow. When the Stripe webhook confirms payment and creates the order (status: 'paid'), immediately send a confirmation email to the customer using Resend. The email should include the order number, items ordered with prices, shipping address, and estimated delivery. Store RESEND_API_KEY in Replit Secrets. When order status changes to 'shipped', send a shipping notification email with the tracking number.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation