Skip to main content
RapidDev - Software Development Agency

How to Build a Billing System with Replit

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'll build

  • PostgreSQL schema for customers, plans, subscriptions, invoices, and webhook_events tables
  • Stripe Checkout Session endpoint for new subscription sign-up (mode: 'subscription')
  • Stripe Customer Portal integration for self-service plan changes, cancellation, and card updates
  • Stripe webhook handler with constructEvent() verification and idempotency via webhook_events table
  • Invoice history API with status badges (paid, open, past_due, void)
  • Automated payment failure recovery: past_due status flag + UI banner when invoice.payment_failed fires
  • React frontend with pricing table, current plan indicator, invoice history, and Manage Billing button
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate13 min read1-2 hoursReplit Core or higherApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
StripePayments & Billing
Replit AuthAuth
ReactFrontend

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

1

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.

prompt.txt
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 true
8// * 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/stripe
22
23// Step 2: After Agent finishes, type /stripe in the Agent chat
24// This installs stripe package and adds basic webhook setup

Pro 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.

2

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.

server/routes/plans.js
1// In Stripe Dashboard (test mode):
2// Products → Add product → Name: 'Pro Plan' → Pricing: $29/month recurring
3// Copy the Price ID (starts with price_test_...)
4// Repeat for each plan tier
5
6// 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';
10
11export 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 cents
17 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();
25
26 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.

3

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.

server/routes/checkout.js
1import Stripe from 'stripe';
2import { db } from '../db.js';
3import { customers, plans } from '../../shared/schema.js';
4import { eq } from 'drizzle-orm';
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
7
8export async function createCheckoutSession(req, res) {
9 const userId = req.get('X-Replit-User-Id');
10 const { planId } = req.body;
11
12 if (!userId) return res.status(401).json({ error: 'Not authenticated' });
13
14 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' });
16
17 // Find or create Stripe customer
18 let [customer] = await db.select().from(customers).where(eq(customers.userId, userId));
19
20 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 }
30
31 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 trial
38 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 });
43
44 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.

4

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.

server/routes/webhook.js
1import Stripe from 'stripe';
2import { db } from '../db.js';
3import { subscriptions, invoices, webhookEvents } from '../../shared/schema.js';
4import { eq } from 'drizzle-orm';
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
7
8// IMPORTANT: This route MUST use express.raw({ type: 'application/json' })
9// Register it BEFORE app.use(express.json()) in server/index.js
10export async function stripeWebhook(req, res) {
11 const sig = req.headers['stripe-signature'];
12 let event;
13
14 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 }
20
21 // Idempotency check: skip if already processed
22 const [existing] = await db.select().from(webhookEvents).where(eq(webhookEvents.stripeEventId, event.id));
23 if (existing) return res.json({ received: true, skipped: true });
24
25 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });
26
27 const obj = event.data.object;
28
29 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;
42
43 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;
48
49 case 'invoice.payment_failed':
50 // Mark subscription as past_due and update invoice status
51 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;
58
59 case 'customer.subscription.deleted':
60 await db.update(subscriptions)
61 .set({ status: 'canceled' })
62 .where(eq(subscriptions.stripeSubscriptionId, obj.id));
63 break;
64 }
65
66 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.

5

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.

server/routes/billing-portal.js
1import Stripe from 'stripe';
2import { db } from '../db.js';
3import { customers } from '../../shared/schema.js';
4import { eq } from 'drizzle-orm';
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
7
8// POST /api/billing-portal — create a Customer Portal session
9export 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' });
12
13 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 }
17
18 const session = await stripe.billingPortal.sessions.create({
19 customer: customer.stripeCustomerId,
20 return_url: `${process.env.APP_URL}/billing`,
21 });
22
23 res.json({ url: session.url });
24}
25
26// 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.app
30//
31// After deploying, register webhook in Stripe Dashboard:
32// Developers → Webhooks → Add endpoint
33// URL: https://your-deployed-url.replit.app/api/webhooks/stripe
34// Events: customer.subscription.created, customer.subscription.updated,
35// customer.subscription.deleted, invoice.paid, invoice.payment_failed

Pro 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

server/routes/webhook.js
1import Stripe from 'stripe';
2import { db } from '../db.js';
3import { subscriptions, invoices, webhookEvents } from '../../shared/schema.js';
4import { eq } from 'drizzle-orm';
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
7
8// 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;
13
14 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 }
20
21 const [dup] = await db.select().from(webhookEvents)
22 .where(eq(webhookEvents.stripeEventId, event.id));
23 if (dup) return res.json({ received: true, duplicate: true });
24
25 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });
26
27 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 }
50
51 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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.