Build a complete Stripe recurring billing system in Replit in 1-2 hours using Express, PostgreSQL, and Drizzle ORM. You'll get subscription plans, Stripe Checkout, Customer Portal, invoice history, and automated payment failure recovery — not just a one-time payment button.
What you're building
A billing system is more than a checkout button — it's the full lifecycle of a recurring revenue relationship: customer creation, plan selection, subscription activation, invoice generation, payment failure handling, and self-service management. This is the infrastructure that powers your SaaS revenue.
Replit's /stripe command auto-provisions a Stripe sandbox environment with test keys, installs the Stripe SDK, and generates a basic webhook handler. From that foundation, this guide builds the complete recurring billing stack: Stripe Checkout for sign-up (hosted by Stripe, no card UI to build), Customer Portal for plan management (also hosted by Stripe), and a webhook handler that keeps your PostgreSQL database in sync with every Stripe event.
The database stores your view of each customer's billing state — plan, subscription status, and invoice history. Stripe is the source of truth for actual payments; your database is the cache that your app queries to render billing UI without making API calls on every page load. The webhook pipeline is how your database stays synchronized with Stripe.
Final result
A production-ready billing system with subscription plans, Stripe Checkout sign-up, Customer Portal for self-service, invoice history, and automatic past_due detection when payments fail — ready to plug into any SaaS app.
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)
- Basic understanding of what subscriptions and invoices are (no coding experience needed)
- Know your pricing: plan names, prices per month/year, and features included in each
Build steps
Scaffold the project and run the /stripe command
Generate the Express + Drizzle foundation with Agent, then run /stripe to auto-provision Stripe. The schema must be precise — billing systems can't easily migrate schema after customers are subscribed.
1// Step 1: Prompt Replit Agent:2// Build a Node.js Express billing system with Replit Auth and built-in PostgreSQL using Drizzle ORM.3// Schema in shared/schema.ts:4// * customers: id serial pk, user_id text not null unique, stripe_customer_id text unique,5// email text not null, company_name text, billing_address jsonb, created_at timestamp default now()6// * plans: id serial pk, name text not null, description text, stripe_price_id text not null unique,7// amount integer not null, interval text not null, features jsonb, is_active boolean default true8// * subscriptions: id serial pk, customer_id integer references customers not null,9// plan_id integer references plans not null, stripe_subscription_id text unique,10// status text not null default 'active', current_period_start timestamp,11// current_period_end timestamp, cancel_at_period_end boolean default false,12// trial_end timestamp, created_at timestamp default now()13// * invoices: id serial pk, customer_id integer references customers not null,14// subscription_id integer references subscriptions, stripe_invoice_id text unique,15// amount_due integer not null, amount_paid integer default 0, status text default 'draft',16// hosted_invoice_url text, pdf_url text, due_date timestamp, paid_at timestamp,17// created_at timestamp default now()18// * webhook_events: id serial pk, stripe_event_id text unique not null, event_type text,19// processed_at timestamp default now()20// Routes: POST /api/checkout/subscribe, POST /api/billing-portal,21// GET /api/billing/invoices, GET /api/billing/subscription, POST /api/webhooks/stripe2223// Step 2: After Agent finishes, type /stripe in the Agent chat24// This installs stripe package and adds basic webhook setupPro tip: Run npx drizzle-kit push in the Shell tab after schema.ts is ready to create the tables. Open Drizzle Studio (database icon) to verify all five tables were created correctly.
Expected result: Project structure with shared/schema.ts containing all five tables. The /stripe command adds stripe to package.json and creates a basic webhook handler file.
Create plans in Stripe and store price IDs
Plans are defined in Stripe Dashboard, not in your code. Your database stores the stripe_price_id linking your plan record to the Stripe product. Create the plans in Stripe first, then seed your plans table.
1// In Stripe Dashboard (test mode):2// Products → Add product → Name: 'Pro Plan' → Pricing: $29/month recurring3// Copy the Price ID (starts with price_test_...)4// Repeat for each plan tier56// Then seed the plans table using Drizzle Studio or this route:7// POST /api/admin/plans (admin-only)8import { db } from '../db.js';9import { plans } from '../../shared/schema.js';1011export async function seedPlans(req, res) {12 const starterPlan = await db.insert(plans).values({13 name: 'Starter',14 description: 'For small teams getting started',15 stripePriceId: 'price_test_REPLACE_WITH_YOURS',16 amount: 2900, // $29.00 in cents17 interval: 'monthly',18 features: JSON.stringify([19 { key: 'users', label: 'Up to 5 users', included: true },20 { key: 'storage', label: '10GB storage', included: true },21 { key: 'api', label: 'API access', included: false },22 ]),23 isActive: true,24 }).returning();2526 res.json({ created: starterPlan });27}Pro tip: Use separate Price IDs for monthly and yearly variants of the same plan. Create two plan rows in your database: 'Pro Monthly' and 'Pro Yearly', each with their own stripe_price_id. The Customer Portal handles switching between them.
Expected result: The plans table in Drizzle Studio shows your plan records with valid stripe_price_id values matching the price IDs from your Stripe Dashboard.
Build the Stripe Checkout subscription endpoint
When a customer clicks Subscribe, create a Stripe Checkout Session in subscription mode. Stripe hosts the payment UI — you don't need to build a card form. On success, Stripe redirects back to your app.
1import Stripe from 'stripe';2import { db } from '../db.js';3import { customers, plans } from '../../shared/schema.js';4import { eq } from 'drizzle-orm';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);78export async function createCheckoutSession(req, res) {9 const userId = req.get('X-Replit-User-Id');10 const { planId } = req.body;1112 if (!userId) return res.status(401).json({ error: 'Not authenticated' });1314 const [plan] = await db.select().from(plans).where(eq(plans.id, parseInt(planId)));15 if (!plan || !plan.isActive) return res.status(404).json({ error: 'Plan not found' });1617 // Find or create Stripe customer18 let [customer] = await db.select().from(customers).where(eq(customers.userId, userId));1920 if (!customer) {21 const stripeCustomer = await stripe.customers.create({22 metadata: { replitUserId: userId },23 });24 [customer] = await db.insert(customers).values({25 userId,26 stripeCustomerId: stripeCustomer.id,27 email: req.get('X-Replit-User-Email') || '',28 }).returning();29 }3031 const session = await stripe.checkout.sessions.create({32 customer: customer.stripeCustomerId,33 payment_method_types: ['card'],34 mode: 'subscription',35 line_items: [{ price: plan.stripePriceId, quantity: 1 }],36 subscription_data: {37 trial_period_days: 14, // 14-day free trial38 metadata: { planId: String(plan.id), userId },39 },40 success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,41 cancel_url: `${process.env.APP_URL}/pricing`,42 });4344 res.json({ url: session.url });45}Pro tip: Adding trial_period_days: 14 means customers can sign up without entering a card (if you configure Stripe to allow trial without payment method). This dramatically increases trial conversions. Remove this line if you want upfront payment.
Expected result: POST /api/checkout/subscribe with a valid planId returns a Stripe Checkout URL. Opening the URL shows Stripe's hosted payment form with your plan name and price.
Build the webhook handler for subscription events
This is how your database stays in sync with Stripe. The webhook fires on every subscription event — created, updated, deleted, invoice paid, invoice failed. The idempotency check prevents double-processing.
1import Stripe from 'stripe';2import { db } from '../db.js';3import { subscriptions, invoices, webhookEvents } from '../../shared/schema.js';4import { eq } from 'drizzle-orm';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);78// IMPORTANT: This route MUST use express.raw({ type: 'application/json' })9// Register it BEFORE app.use(express.json()) in server/index.js10export async function stripeWebhook(req, res) {11 const sig = req.headers['stripe-signature'];12 let event;1314 try {15 // constructEvent is synchronous (Node.js) — NOT constructEventAsync (Deno-only)16 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);17 } catch (err) {18 return res.status(400).json({ error: `Webhook verification failed: ${err.message}` });19 }2021 // Idempotency check: skip if already processed22 const [existing] = await db.select().from(webhookEvents).where(eq(webhookEvents.stripeEventId, event.id));23 if (existing) return res.json({ received: true, skipped: true });2425 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });2627 const obj = event.data.object;2829 switch (event.type) {30 case 'customer.subscription.updated':31 case 'customer.subscription.created':32 await db.update(subscriptions)33 .set({34 status: obj.status,35 currentPeriodStart: new Date(obj.current_period_start * 1000),36 currentPeriodEnd: new Date(obj.current_period_end * 1000),37 cancelAtPeriodEnd: obj.cancel_at_period_end,38 trialEnd: obj.trial_end ? new Date(obj.trial_end * 1000) : null,39 })40 .where(eq(subscriptions.stripeSubscriptionId, obj.id));41 break;4243 case 'invoice.paid':44 await db.update(invoices)45 .set({ status: 'paid', amountPaid: obj.amount_paid, paidAt: new Date() })46 .where(eq(invoices.stripeInvoiceId, obj.id));47 break;4849 case 'invoice.payment_failed':50 // Mark subscription as past_due and update invoice status51 await db.update(subscriptions)52 .set({ status: 'past_due' })53 .where(eq(subscriptions.stripeSubscriptionId, obj.subscription));54 await db.update(invoices)55 .set({ status: 'open' })56 .where(eq(invoices.stripeInvoiceId, obj.id));57 break;5859 case 'customer.subscription.deleted':60 await db.update(subscriptions)61 .set({ status: 'canceled' })62 .where(eq(subscriptions.stripeSubscriptionId, obj.id));63 break;64 }6566 res.json({ received: true });67}Pro tip: The idempotency check (query webhook_events before processing) is essential. Stripe retries webhooks up to 3 times if your server returns a non-200. Without the check, retried events trigger duplicate database updates.
Expected result: After subscription events in Stripe test mode, the subscriptions and invoices tables in Drizzle Studio update automatically. Check webhook_events to confirm events are being recorded.
Add Customer Portal and deploy on Autoscale
The Customer Portal is a Stripe-hosted page where subscribers manage their own plan, payment method, and cancellation. One API endpoint is all you need to implement this entire feature.
1import Stripe from 'stripe';2import { db } from '../db.js';3import { customers } from '../../shared/schema.js';4import { eq } from 'drizzle-orm';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);78// POST /api/billing-portal — create a Customer Portal session9export async function createBillingPortalSession(req, res) {10 const userId = req.get('X-Replit-User-Id');11 if (!userId) return res.status(401).json({ error: 'Not authenticated' });1213 const [customer] = await db.select().from(customers).where(eq(customers.userId, userId));14 if (!customer?.stripeCustomerId) {15 return res.status(404).json({ error: 'No billing account found' });16 }1718 const session = await stripe.billingPortal.sessions.create({19 customer: customer.stripeCustomerId,20 return_url: `${process.env.APP_URL}/billing`,21 });2223 res.json({ url: session.url });24}2526// Deployment notes (add to Deployment Secrets, not workspace Secrets):27// STRIPE_SECRET_KEY=sk_test_...28// STRIPE_WEBHOOK_SECRET=whsec_... (from Stripe Dashboard after registering webhook)29// APP_URL=https://your-deployed-url.replit.app30//31// After deploying, register webhook in Stripe Dashboard:32// Developers → Webhooks → Add endpoint33// URL: https://your-deployed-url.replit.app/api/webhooks/stripe34// Events: customer.subscription.created, customer.subscription.updated,35// customer.subscription.deleted, invoice.paid, invoice.payment_failedPro tip: Configure the Customer Portal in Stripe Dashboard (Settings → Billing → Customer portal) to allow plan switching, subscription cancellation, and payment method updates. These settings control what customers can do in the portal — no code changes needed.
Expected result: POST /api/billing-portal returns a URL that redirects to Stripe's hosted Customer Portal. After making changes there (cancel, upgrade, update card), the webhook fires and your database updates automatically.
Complete code
1import Stripe from 'stripe';2import { db } from '../db.js';3import { subscriptions, invoices, webhookEvents } from '../../shared/schema.js';4import { eq } from 'drizzle-orm';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);78// Register with express.raw BEFORE 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;1314 try {15 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);16 } catch (err) {17 console.error('Webhook verification failed:', err.message);18 return res.status(400).send(`Webhook Error: ${err.message}`);19 }2021 const [dup] = await db.select().from(webhookEvents)22 .where(eq(webhookEvents.stripeEventId, event.id));23 if (dup) return res.json({ received: true, duplicate: true });2425 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });2627 const obj = event.data.object;28 try {29 if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.created') {30 await db.update(subscriptions).set({31 status: obj.status,32 currentPeriodStart: new Date(obj.current_period_start * 1000),33 currentPeriodEnd: new Date(obj.current_period_end * 1000),34 cancelAtPeriodEnd: obj.cancel_at_period_end,35 }).where(eq(subscriptions.stripeSubscriptionId, obj.id));36 } else if (event.type === 'invoice.paid') {37 await db.update(invoices).set({ status: 'paid', amountPaid: obj.amount_paid, paidAt: new Date() })38 .where(eq(invoices.stripeInvoiceId, obj.id));39 } else if (event.type === 'invoice.payment_failed') {40 await db.update(subscriptions).set({ status: 'past_due' })41 .where(eq(subscriptions.stripeSubscriptionId, obj.subscription));42 } else if (event.type === 'customer.subscription.deleted') {43 await db.update(subscriptions).set({ status: 'canceled' })44 .where(eq(subscriptions.stripeSubscriptionId, obj.id));45 }46 } catch (err) {47 console.error('Webhook processing error:', err);48 return res.status(500).json({ error: 'Processing failed' });49 }5051 res.json({ received: true });52}Customization ideas
Usage-based billing
Add a usage_records table. At end of billing period, count usage events per customer and report to Stripe via stripe.subscriptionItems.createUsageRecord(). Stripe calculates the final invoice amount based on reported usage.
Dunning email sequence
When invoice.payment_failed fires, trigger a series of email reminders (day 0, day 3, day 7) using SendGrid or Resend. Store reminder_sent_count on the subscription and check it before each outreach.
Promo codes and discounts
Create coupons in Stripe Dashboard. Add a coupon_code field to the Checkout Session endpoint. Validate the code, pass it as discounts: [{ coupon: couponId }] to the session, and log usage in a promo_redemptions table.
Common pitfalls
Pitfall: Registering the Stripe 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: Not re-adding Stripe secrets in Deployment Secrets
How to avoid: After deploying, go to Deployments → your deployment → Secrets. Add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and APP_URL as deployment secrets. These are separate from workspace secrets.
Pitfall: Not implementing idempotency in the webhook handler
How to avoid: Before processing any event, check webhook_events for the event.id. If found, return 200 immediately without processing. If not found, insert it and then process.
Best practices
- Store all amounts in cents (integers) — never store $29.00 as a float, always as 2900.
- Register the Stripe webhook route with express.raw() BEFORE express.json() middleware.
- Always check webhook_events for duplicate event IDs before processing to ensure idempotency.
- Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in both workspace Secrets (dev) and Deployment Secrets (production) — they don't carry over automatically.
- Use Stripe Customer Portal for plan management — it handles upgrade prorations, cancellation flows, and card updates with zero custom code.
- Use constructEvent() (synchronous) not constructEventAsync() — the async version is for Deno/edge environments, not Node.js.
- Use Drizzle Studio (built into Replit) to verify webhook events are being recorded in the webhook_events table during testing.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a Stripe recurring billing system with Express and PostgreSQL using Drizzle ORM. I have a subscriptions table with status, stripe_subscription_id, current_period_end, and cancel_at_period_end columns. Help me write a webhook handler for these Stripe events: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed. Include idempotency check using a webhook_events table to prevent duplicate processing on Stripe retries.
Add a subscription status banner to the React billing UI. The banner should show different messages based on subscription.status: trialing shows 'Trial ends [date] — add payment method to continue', past_due shows 'Payment failed — update your card to avoid losing access' with a link to Customer Portal, canceled shows 'Subscription canceled — resubscribe to regain access', and active shows nothing. Fetch subscription status from GET /api/billing/subscription and display the banner on every page the user visits.
Frequently asked questions
Do I need a Stripe account before I start building?
Yes, but it's free to create and test mode costs nothing. Sign up at stripe.com, verify your email, and you can use test mode immediately without providing banking details. Test mode uses fake card numbers like 4242 4242 4242 4242 and never charges real money.
Why does Stripe webhook verification keep failing in development?
Webhooks require incoming HTTP connections, which Replit development servers don't expose publicly. You have two options: deploy first and register the deployed URL with Stripe, or use the Stripe CLI on a separate machine (stripe listen --forward-to your-repl-url). The /stripe command in Replit sets up the webhook route but can't receive real webhooks until you deploy.
How do I handle customers who downgrade from Pro to Free mid-cycle?
The Customer Portal handles the UX. When a customer downgrades, Stripe fires customer.subscription.updated with the new plan's price ID. Your webhook handler updates the subscription's plan_id to the free plan. Stripe prorates the billing difference automatically based on your Stripe proration settings.
What's the difference between Autoscale and Reserved VM for a billing system?
Autoscale works for most billing systems. The webhook endpoint needs to respond within 30 seconds, and even a 15-second cold start leaves plenty of margin. Use Reserved VM only if you also have a real-time feature (WebSockets, SSE) in the same app that can't tolerate cold starts.
How do I go live with real payments?
In Stripe Dashboard, switch from Test to Live mode. Install the Replit Integrated Payments app from Stripe Marketplace to activate your account for live charges. Swap sk_test_ for sk_live_ in your Deployment Secrets. Re-register your webhook endpoint in Stripe Live mode (webhooks are separate per mode).
Can I build annual billing (yearly plans) alongside monthly?
Yes. Create separate Price objects in Stripe for monthly and yearly variants (e.g., $29/month vs $290/year). Add two plan rows to your database, one per price. The Customer Portal handles upgrading from monthly to annual with automatic proration. Show a toggle on your pricing page to switch between billing periods.
Can RapidDev help build a billing system for my SaaS?
Yes. RapidDev has built 600+ apps including SaaS billing systems with metered usage, multi-seat team plans, revenue reporting, and Stripe Connect marketplace payouts. Book a free consultation to discuss your billing requirements.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation