# How to Build a Subscription System with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build a SaaS subscription system with Replit in 1-2 hours. You'll create an Express API with Stripe Checkout (subscription mode), a feature-gating middleware, a Stripe Customer Portal for self-service billing, and a PostgreSQL database (Drizzle ORM) for plan and subscription data. Use the Replit /stripe command for instant Stripe provisioning. Deploy on Autoscale.

## Before you start

- A Replit account (Free tier is sufficient)
- Basic understanding of what subscriptions and API routes do (no coding experience needed)
- Stripe account for going live (the /stripe command provisions a sandbox for testing)
- Defined feature list for each tier before you start (e.g. free: 3 projects, pro: unlimited)

## Step-by-step guide

### 1. Provision Stripe and scaffold the project with Agent

From the Replit home screen, type `/stripe` to launch the Stripe integration. This auto-provisions a sandbox Stripe account and injects `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` into Replit Secrets. Then use the Agent prompt below to generate the full app structure including the Drizzle schema.

```
// After running /stripe, paste this into Replit Agent:
// Build a SaaS subscription system with Express, PostgreSQL (Drizzle ORM), and Stripe.
// Schema: plans (id serial PK, name text, description text, stripe_price_id text unique,
// amount integer cents, interval text monthly/yearly, features jsonb array of {key, label, included bool},
// display_order integer default 0, is_active bool default true),
// subscriptions (id serial PK, user_id text unique, plan_id int references plans,
// stripe_subscription_id text unique, stripe_customer_id text,
// status text active/trialing/past_due/canceled/incomplete,
// current_period_start timestamp, current_period_end timestamp,
// cancel_at_period_end bool default false, trial_end timestamp, created_at),
// subscription_events (id serial PK, subscription_id int references subscriptions,
// event_type text, stripe_event_id text unique, metadata jsonb, created_at),
// webhook_events (id serial PK, stripe_event_id text unique, event_type text, processed_at timestamp).
// Routes: GET /api/plans, POST /api/subscribe (Stripe Checkout mode=subscription, 14-day trial),
// POST /api/billing-portal (Stripe Customer Portal), GET /api/subscription,
// GET /api/subscription/features, POST /api/webhooks/stripe.
// Mount /api/webhooks/stripe BEFORE express.json() using express.raw().
// Core middleware: requireFeature(key) — loads user subscription, checks plan features jsonb,
// returns 403 if key not included: true.
// React: pricing page with plan cards, feature comparison, current plan badge, upgrade button;
// subscription status banner; billing portal link.
// Bind server to 0.0.0.0.
```

> Pro tip: Define your plans and features BEFORE running Agent. Write out: plan names, monthly prices, and which features each tier includes. This makes the seed data much easier to write.

**Expected result:** Agent creates the full project. The preview shows the pricing page with plan cards.

### 2. Create Stripe Price IDs and seed the plans table

In Stripe Dashboard (test mode), create a Product for each plan (e.g. 'Pro Plan'), then create a recurring monthly Price. Copy the price_XXXX IDs, then seed your plans table via Drizzle Studio. This is the link between your database and Stripe's billing engine.

```
// Seed script — run once to populate plans table
// server/seed.js
const { db } = require('./db');
const { plans } = require('./schema');

async function seed() {
  await db.insert(plans).values([
    {
      name: 'Free',
      description: 'Get started with the basics',
      stripePriceId: 'price_free',  // no Stripe price for free tier
      amount: 0,
      interval: 'monthly',
      features: [
        { key: 'projects', label: 'Up to 3 projects', included: true },
        { key: 'api_access', label: 'API access', included: false },
        { key: 'team_members', label: 'Team members', included: false },
      ],
      displayOrder: 0,
      isActive: true,
    },
    {
      name: 'Pro',
      description: 'For growing teams',
      stripePriceId: 'price_XXXX',  // replace with your Stripe price ID
      amount: 2900,  // $29.00 in cents
      interval: 'monthly',
      features: [
        { key: 'projects', label: 'Unlimited projects', included: true },
        { key: 'api_access', label: 'API access', included: true },
        { key: 'team_members', label: 'Up to 10 team members', included: true },
      ],
      displayOrder: 1,
      isActive: true,
    },
  ]).onConflictDoNothing();

  console.log('Plans seeded');
  process.exit(0);
}

seed().catch(console.error);
```

> Pro tip: Open Drizzle Studio from the Database tool to verify the seeded rows. You can also edit plan data directly in the studio without redeploying.

### 3. Build the requireFeature middleware

This is the core of the subscription system. The middleware loads the user's active subscription, reads the `features` array from their plan, and either allows the request or returns a 402 with an upgrade prompt. Every protected route simply wraps with `requireFeature('api_access')`.

```
// server/middleware/requireFeature.js
const { eq, and, inArray } = require('drizzle-orm');
const { db } = require('../db');
const { subscriptions, plans } = require('../schema');

const ACTIVE_STATUSES = ['active', 'trialing'];

function requireFeature(featureKey) {
  return async (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Login required' });
    }

    // Load subscription with plan data
    const rows = await db
      .select({
        status: subscriptions.status,
        features: plans.features,
        planName: plans.name,
      })
      .from(subscriptions)
      .innerJoin(plans, eq(subscriptions.planId, plans.id))
      .where(
        and(
          eq(subscriptions.userId, req.user.id),
          inArray(subscriptions.status, ACTIVE_STATUSES)
        )
      )
      .limit(1);

    if (rows.length === 0) {
      return res.status(402).json({
        error: 'Subscription required',
        upgradeUrl: '/pricing',
      });
    }

    const { features, planName } = rows[0];
    const feature = features.find(f => f.key === featureKey);

    if (!feature || !feature.included) {
      return res.status(403).json({
        error: `Feature '${featureKey}' requires a higher plan`,
        currentPlan: planName,
        upgradeUrl: '/pricing',
      });
    }

    req.subscription = rows[0];
    next();
  };
}

module.exports = { requireFeature };

// Usage example:
// const { requireFeature } = require('../middleware/requireFeature');
// app.get('/api/pro/data', requireFeature('api_access'), (req, res) => {
//   res.json({ data: 'premium content' });
// });
```

**Expected result:** Calling a feature-gated route without an active subscription returns HTTP 402 with an upgradeUrl. With an active subscription and the feature included, the route proceeds normally.

### 4. Build the Stripe webhook handler

Webhooks keep your database in sync with Stripe's subscription state. The handler processes subscription created/updated/deleted events and payment failures. It MUST use synchronous `constructEvent` (not constructEventAsync) and be mounted with `express.raw()` BEFORE `express.json()`.

```
// server/routes/webhook.js
const express = require('express');
const stripe = require('../stripe');
const { db } = require('../db');
const { eq } = require('drizzle-orm');
const { subscriptions, plans, subscriptionEvents, webhookEvents } = require('../schema');

const router = express.Router();

router.post('/', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    // SYNC — do NOT use constructEventAsync on Node.js/Replit
    event = stripe.webhooks.constructEvent(
      req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Idempotency
  const inserted = await db.insert(webhookEvents)
    .values({ id: event.id, eventType: event.type })
    .onConflictDoNothing()
    .returning({ id: webhookEvents.id });

  if (inserted.length === 0) return res.json({ received: true });

  try {
    const sub = event.data.object;
    switch (event.type) {
      case 'customer.subscription.created': {
        const item = sub.items.data[0];
        const [plan] = await db.select().from(plans)
          .where(eq(plans.stripePriceId, item.price.id)).limit(1);

        await db.insert(subscriptions).values({
          userId: sub.metadata.user_id,
          planId: plan.id,
          stripeSubscriptionId: sub.id,
          stripeCustomerId: sub.customer,
          status: sub.status,
          currentPeriodStart: new Date(sub.current_period_start * 1000),
          currentPeriodEnd: new Date(sub.current_period_end * 1000),
          cancelAtPeriodEnd: sub.cancel_at_period_end,
          trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
        }).onConflictDoNothing();
        break;
      }
      case 'customer.subscription.updated': {
        const item = sub.items.data[0];
        const [plan] = await db.select().from(plans)
          .where(eq(plans.stripePriceId, item.price.id)).limit(1);

        await db.update(subscriptions)
          .set({
            planId: plan?.id,
            status: sub.status,
            currentPeriodEnd: new Date(sub.current_period_end * 1000),
            cancelAtPeriodEnd: sub.cancel_at_period_end,
          })
          .where(eq(subscriptions.stripeSubscriptionId, sub.id));
        break;
      }
      case 'customer.subscription.deleted':
        await db.update(subscriptions)
          .set({ status: 'canceled' })
          .where(eq(subscriptions.stripeSubscriptionId, sub.id));
        break;
      case 'invoice.payment_failed':
        await db.update(subscriptions)
          .set({ status: 'past_due' })
          .where(eq(subscriptions.stripeSubscriptionId, sub.subscription));
        break;
    }
    return res.json({ received: true });
  } catch (err) {
    console.error('[webhook] error:', err);
    return res.status(500).send('handler error');
  }
});

module.exports = router;
```

> Pro tip: After deploying, add your *.replit.app/api/webhooks/stripe URL in the Stripe Dashboard under Developers > Webhooks. Copy the signing secret and add it as STRIPE_WEBHOOK_SECRET in your Deployment Secrets (not Workspace Secrets).

### 5. Add the Stripe Customer Portal route and deploy

The Customer Portal lets subscribers update their payment method, switch plans, or cancel — without you building a billing settings UI. After deploying on Autoscale, you must configure the Customer Portal in Stripe Dashboard first (save the settings once), or you'll get a test-mode error on first use.

```
// server/routes/portal.js
const express = require('express');
const stripe = require('../stripe');
const { getBaseUrl } = require('../lib/baseUrl');
const { db } = require('../db');
const { eq } = require('drizzle-orm');
const { subscriptions } = require('../schema');

const router = express.Router();

router.post('/api/billing-portal', express.json(), async (req, res) => {
  const userId = req.user.id;
  const baseUrl = getBaseUrl();

  const [sub] = await db.select()
    .from(subscriptions)
    .where(eq(subscriptions.userId, userId))
    .limit(1);

  if (!sub) {
    return res.status(404).json({ error: 'No subscription found' });
  }

  // NOTE: First use in test mode requires saving portal settings at
  // https://dashboard.stripe.com/test/settings/billing/portal
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: sub.stripeCustomerId,
    return_url: `${baseUrl}/account`,
  });

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

module.exports = router;
```

**Expected result:** Clicking 'Manage Billing' in the React frontend redirects to a Stripe-hosted portal where subscribers can update payment info, switch plans, or cancel.

## Complete code example

File: `server/middleware/requireFeature.js`

```javascript
const { eq, and, inArray } = require('drizzle-orm');
const { db } = require('../db');
const { subscriptions, plans } = require('../schema');

const ACTIVE_STATUSES = ['active', 'trialing'];

// Middleware factory — gates any route behind a specific feature key
// Usage: app.get('/api/feature', requireFeature('api_access'), handler)
function requireFeature(featureKey) {
  return async (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Login required' });
    }

    try {
      const rows = await db
        .select({
          subId: subscriptions.id,
          status: subscriptions.status,
          planId: subscriptions.planId,
          currentPeriodEnd: subscriptions.currentPeriodEnd,
          features: plans.features,
          planName: plans.name,
          displayOrder: plans.displayOrder,
        })
        .from(subscriptions)
        .innerJoin(plans, eq(subscriptions.planId, plans.id))
        .where(
          and(
            eq(subscriptions.userId, req.user.id),
            inArray(subscriptions.status, ACTIVE_STATUSES)
          )
        )
        .limit(1);

      // No active subscription — prompt to subscribe
      if (rows.length === 0) {
        return res.status(402).json({
          error: 'Subscription required to access this feature',
          upgradeUrl: '/pricing',
        });
      }

      const row = rows[0];
      const feature = row.features.find(f => f.key === featureKey);

      // Feature not in plan
      if (!feature || !feature.included) {
        return res.status(403).json({
          error: `'${featureKey}' is not available on the ${row.planName} plan`,
          currentPlan: row.planName,
          upgradeUrl: '/pricing',
        });
      }

      // Attach subscription context for use in the route handler
      req.subscription = row;
      next();
    } catch (err) {
      console.error('[requireFeature] DB error:', err.message);
```

## Common mistakes

- **Webhook signature verification fails with 'No signatures found'** — express.json() parses the body globally before the webhook route sees it. Stripe verifies the HMAC signature against the raw request bytes — once parsed to a JS object, they no longer match. Fix: Mount the webhook route BEFORE app.use(express.json()) in server/index.js and use express.raw({ type: 'application/json' }) only on that route.
- **Plan upgrade doesn't take effect immediately** — The plan change is only applied when the next billing cycle starts, because the developer is updating the plan in the subscription handler on `invoice.paid` instead of `customer.subscription.updated`. Fix: Update the local plan_id when the `customer.subscription.updated` webhook fires — this happens immediately when Stripe processes the plan change, not at the next billing date.
- **Free tier users can't access any routes after adding the middleware** — The requireFeature middleware requires an active subscription row in the database, but free tier users don't go through Stripe Checkout and have no subscription row. Fix: On first login, auto-insert a subscription row pointing to the free plan with status='active'. This gives free tier users a subscription record that the middleware can check.

## Best practices

- Store STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Replit Secrets (lock icon) — add them again separately in Deployment Secrets
- Mount the webhook route BEFORE app.use(express.json()) using express.raw({ type: 'application/json' }) — this is the most common Stripe integration mistake
- Use the webhook_events table for idempotency — insert with ON CONFLICT DO NOTHING and skip processing if no rows were inserted
- Seed default plan rows on first deployment with the Stripe price IDs — use Drizzle Studio to verify the data looks correct
- Handle plan changes immediately in customer.subscription.updated, not invoice.paid — customers expect instant access after upgrading
- Use the /stripe command in Replit for initial setup — it provisions the sandbox, creates routes, and injects Secrets automatically
- Test with Stripe card 4242 4242 4242 4242 before going live, and use the Stripe Events tab to verify all webhook events are being received

## Frequently asked questions

### Do I need a paid Stripe account to build this?

No. The Replit /stripe command provisions a free test mode Stripe sandbox. Use test card 4242 4242 4242 4242 to simulate payments. You only need a live Stripe account with KYC verification when you're ready to charge real customers.

### How do I handle users on the free plan with no Stripe subscription?

On first login, auto-insert a subscriptions row pointing to your free plan with status='active' and no Stripe IDs. This gives free tier users a subscription record that the requireFeature middleware can check. Free features pass through; paid features return 403 with an upgradeUrl.

### What's the difference between Workspace Secrets and Deployment Secrets?

Workspace Secrets (lock icon in sidebar) are used when running your app in the Replit editor. Deployment Secrets are configured in the Publish pane and used by your live deployed app. They are completely separate — you must add Stripe keys in both places.

### Should I use Autoscale or Reserved VM for this app?

Autoscale works for most SaaS subscription systems. Traffic is moderate and predictable. Stripe retries failed webhooks for up to 3 days, so occasional cold starts don't cause lost events. Reserve VM only if you're processing high volumes (hundreds of payments per day).

### How do plan upgrades work mid-billing cycle?

When a subscriber upgrades via the Stripe Customer Portal, Stripe prorates the charge and fires a `customer.subscription.updated` webhook. Your webhook handler updates the local plan_id and status immediately, giving the subscriber instant access to the higher tier's features.

### How do I prevent someone from calling protected routes after their subscription expires?

The requireFeature middleware checks the subscription status against an allowed list ['active', 'trialing']. If the status is 'past_due', 'canceled', or 'incomplete', the middleware returns 402. The status is updated in real time by webhook handlers.

### Can I add a free trial without requiring a credit card?

Yes, but with a trade-off. Set `payment_method_collection: 'if_required'` on the Checkout Session and add `trial_period_days`. Users can trial without a card, but when the trial ends they're moved to 'paused' status (not 'active') until they add a payment method. Handle the `customer.subscription.paused` webhook.

### Can RapidDev help me build a custom subscription system?

Yes. RapidDev has built 600+ apps including SaaS platforms with complex subscription logic, usage-based billing, and multi-tenant architectures. Book a free consultation at rapidevelopers.com.

---

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