# How to Build a Billing System 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 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.

## 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)
- 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

## Step-by-step guide

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

```
// Step 1: Prompt Replit Agent:
// Build a Node.js Express billing system with Replit Auth and built-in PostgreSQL using Drizzle ORM.
// Schema in shared/schema.ts:
// * customers: id serial pk, user_id text not null unique, stripe_customer_id text unique,
//   email text not null, company_name text, billing_address jsonb, created_at timestamp default now()
// * plans: id serial pk, name text not null, description text, stripe_price_id text not null unique,
//   amount integer not null, interval text not null, features jsonb, is_active boolean default true
// * subscriptions: id serial pk, customer_id integer references customers not null,
//   plan_id integer references plans not null, stripe_subscription_id text unique,
//   status text not null default 'active', current_period_start timestamp,
//   current_period_end timestamp, cancel_at_period_end boolean default false,
//   trial_end timestamp, created_at timestamp default now()
// * invoices: id serial pk, customer_id integer references customers not null,
//   subscription_id integer references subscriptions, stripe_invoice_id text unique,
//   amount_due integer not null, amount_paid integer default 0, status text default 'draft',
//   hosted_invoice_url text, pdf_url text, due_date timestamp, paid_at timestamp,
//   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: POST /api/checkout/subscribe, POST /api/billing-portal,
// GET /api/billing/invoices, GET /api/billing/subscription, POST /api/webhooks/stripe

// Step 2: After Agent finishes, type /stripe in the Agent chat
// 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.

```
// In Stripe Dashboard (test mode):
// Products → Add product → Name: 'Pro Plan' → Pricing: $29/month recurring
// Copy the Price ID (starts with price_test_...)
// Repeat for each plan tier

// Then seed the plans table using Drizzle Studio or this route:
// POST /api/admin/plans (admin-only)
import { db } from '../db.js';
import { plans } from '../../shared/schema.js';

export async function seedPlans(req, res) {
  const starterPlan = await db.insert(plans).values({
    name: 'Starter',
    description: 'For small teams getting started',
    stripePriceId: 'price_test_REPLACE_WITH_YOURS',
    amount: 2900, // $29.00 in cents
    interval: 'monthly',
    features: JSON.stringify([
      { key: 'users', label: 'Up to 5 users', included: true },
      { key: 'storage', label: '10GB storage', included: true },
      { key: 'api', label: 'API access', included: false },
    ]),
    isActive: true,
  }).returning();

  res.json({ created: starterPlan });
}
```

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

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

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

export async function createCheckoutSession(req, res) {
  const userId = req.get('X-Replit-User-Id');
  const { planId } = req.body;

  if (!userId) return res.status(401).json({ error: 'Not authenticated' });

  const [plan] = await db.select().from(plans).where(eq(plans.id, parseInt(planId)));
  if (!plan || !plan.isActive) return res.status(404).json({ error: 'Plan not found' });

  // Find or create Stripe customer
  let [customer] = await db.select().from(customers).where(eq(customers.userId, userId));

  if (!customer) {
    const stripeCustomer = await stripe.customers.create({
      metadata: { replitUserId: userId },
    });
    [customer] = await db.insert(customers).values({
      userId,
      stripeCustomerId: stripeCustomer.id,
      email: req.get('X-Replit-User-Email') || '',
    }).returning();
  }

  const session = await stripe.checkout.sessions.create({
    customer: customer.stripeCustomerId,
    payment_method_types: ['card'],
    mode: 'subscription',
    line_items: [{ price: plan.stripePriceId, quantity: 1 }],
    subscription_data: {
      trial_period_days: 14, // 14-day free trial
      metadata: { planId: String(plan.id), userId },
    },
    success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
  });

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

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

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

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

// IMPORTANT: This route MUST use express.raw({ type: 'application/json' })
// Register it BEFORE app.use(express.json()) in server/index.js
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).json({ error: `Webhook verification failed: ${err.message}` });
  }

  // Idempotency check: skip if already processed
  const [existing] = await db.select().from(webhookEvents).where(eq(webhookEvents.stripeEventId, event.id));
  if (existing) return res.json({ received: true, skipped: true });

  await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });

  const obj = event.data.object;

  switch (event.type) {
    case 'customer.subscription.updated':
    case 'customer.subscription.created':
      await db.update(subscriptions)
        .set({
          status: obj.status,
          currentPeriodStart: new Date(obj.current_period_start * 1000),
          currentPeriodEnd: new Date(obj.current_period_end * 1000),
          cancelAtPeriodEnd: obj.cancel_at_period_end,
          trialEnd: obj.trial_end ? new Date(obj.trial_end * 1000) : null,
        })
        .where(eq(subscriptions.stripeSubscriptionId, obj.id));
      break;

    case 'invoice.paid':
      await db.update(invoices)
        .set({ status: 'paid', amountPaid: obj.amount_paid, paidAt: new Date() })
        .where(eq(invoices.stripeInvoiceId, obj.id));
      break;

    case 'invoice.payment_failed':
      // Mark subscription as past_due and update invoice status
      await db.update(subscriptions)
        .set({ status: 'past_due' })
        .where(eq(subscriptions.stripeSubscriptionId, obj.subscription));
      await db.update(invoices)
        .set({ status: 'open' })
        .where(eq(invoices.stripeInvoiceId, obj.id));
      break;

    case 'customer.subscription.deleted':
      await db.update(subscriptions)
        .set({ status: 'canceled' })
        .where(eq(subscriptions.stripeSubscriptionId, obj.id));
      break;
  }

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

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

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

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

// POST /api/billing-portal — create a Customer Portal session
export async function createBillingPortalSession(req, res) {
  const userId = req.get('X-Replit-User-Id');
  if (!userId) return res.status(401).json({ error: 'Not authenticated' });

  const [customer] = await db.select().from(customers).where(eq(customers.userId, userId));
  if (!customer?.stripeCustomerId) {
    return res.status(404).json({ error: 'No billing account found' });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: customer.stripeCustomerId,
    return_url: `${process.env.APP_URL}/billing`,
  });

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

// Deployment notes (add to Deployment Secrets, not workspace Secrets):
// STRIPE_SECRET_KEY=sk_test_...
// STRIPE_WEBHOOK_SECRET=whsec_... (from Stripe Dashboard after registering webhook)
// APP_URL=https://your-deployed-url.replit.app
//
// After deploying, register webhook in Stripe Dashboard:
// Developers → Webhooks → Add endpoint
// URL: https://your-deployed-url.replit.app/api/webhooks/stripe
// Events: customer.subscription.created, customer.subscription.updated,
//         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 example

File: `server/routes/webhook.js`

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

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

// Register with express.raw BEFORE 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 {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.error('Webhook verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  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 });

  const obj = event.data.object;
  try {
    if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.created') {
      await db.update(subscriptions).set({
        status: obj.status,
        currentPeriodStart: new Date(obj.current_period_start * 1000),
        currentPeriodEnd: new Date(obj.current_period_end * 1000),
        cancelAtPeriodEnd: obj.cancel_at_period_end,
      }).where(eq(subscriptions.stripeSubscriptionId, obj.id));
    } else if (event.type === 'invoice.paid') {
      await db.update(invoices).set({ status: 'paid', amountPaid: obj.amount_paid, paidAt: new Date() })
        .where(eq(invoices.stripeInvoiceId, obj.id));
    } else if (event.type === 'invoice.payment_failed') {
      await db.update(subscriptions).set({ status: 'past_due' })
        .where(eq(subscriptions.stripeSubscriptionId, obj.subscription));
    } else if (event.type === 'customer.subscription.deleted') {
      await db.update(subscriptions).set({ status: 'canceled' })
        .where(eq(subscriptions.stripeSubscriptionId, obj.id));
    }
  } catch (err) {
    console.error('Webhook processing error:', err);
    return res.status(500).json({ error: 'Processing failed' });
  }

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

## Common mistakes

- **Registering the Stripe webhook route after express.json()** — express.json() parses the raw body into a JavaScript object. Stripe's constructEvent() needs the original raw Buffer to verify the HMAC signature. After parsing, the bytes are gone and verification always fails with 'Webhook Error: No signatures found'. 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()).
- **Not re-adding Stripe secrets in Deployment Secrets** — Workspace Secrets (the lock icon) are only available during development. The deployed app runs in a separate environment. STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET missing in deployment causes every Stripe API call to fail. Fix: 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.
- **Not implementing idempotency in the webhook handler** — Stripe retries webhook delivery up to 3 times if it doesn't receive a 200 response within 30 seconds. Without the webhook_events deduplication check, retried events trigger duplicate database updates (e.g., marking a subscription as past_due twice). Fix: 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.

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

---

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