# How to Build a Subscription Box Service with Replit

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

## TL;DR

Build a Birchbox-style subscription box service with Replit in 2-4 hours. You'll create an Express API with Stripe recurring billing, a PostgreSQL database (Drizzle ORM) for subscribers, box plans, and shipments, plus a subscriber dashboard with skip/pause controls. Use the Replit /stripe command to auto-provision Stripe. Deploy on Autoscale.

## Before you start

- A Replit account (Free tier is sufficient)
- Basic understanding of what subscriptions and webhooks do (no coding experience needed)
- Stripe account for going live (the /stripe command provisions a sandbox for testing)
- A list of your box plans with pricing and billing intervals before you start

## 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 wizard. This auto-provisions a Stripe sandbox, creates your Stripe account link, and injects `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY` into Replit Secrets. Then use the Agent prompt below to generate the full app structure.

```
// After running /stripe, paste this into Replit Agent:
// Build a subscription box service with Express, PostgreSQL (Drizzle ORM), and Stripe.
// Schema: box_plans (id serial PK, name text, description text, price integer in cents,
// interval text enum monthly/quarterly, stripe_price_id text unique, image_url text,
// max_subscribers integer, is_active boolean default true),
// subscribers (id serial PK, user_id text unique, stripe_customer_id text unique,
// name text, email text, shipping_address jsonb, preferences jsonb, created_at),
// subscriptions (id serial PK, subscriber_id int references subscribers,
// box_plan_id int references box_plans, stripe_subscription_id text unique,
// status text default active enum active/paused/cancelled/past_due,
// current_period_end timestamp, skip_next boolean default false, created_at),
// box_shipments (id serial PK, subscription_id int references subscriptions,
// items jsonb, tracking_number text, status text default preparing
// enum preparing/shipped/delivered, ship_date timestamp, delivered_date timestamp, created_at),
// products (id serial PK, name text, category text, cost integer, image_url text, stock int default 0),
// 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),
// POST /api/subscriptions/:id/pause, POST /api/subscriptions/:id/skip,
// PATCH /api/subscribers/preferences, PATCH /api/subscribers/address,
// GET /api/subscribers/shipments, POST /api/admin/shipments/generate,
// POST /api/webhooks/stripe (raw body, constructEvent sync).
// Mount /api/webhooks/stripe BEFORE express.json() using express.raw().
// React frontend: landing page with plan cards, subscriber dashboard with skip/pause toggles,
// preference editor, address form, shipment history. Bind server to 0.0.0.0.
```

> Pro tip: After Agent finishes, open Replit Secrets (lock icon in sidebar) and verify STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY are present. If they're missing, add them manually from the Stripe Dashboard.

**Expected result:** Agent creates the full project. The preview shows the plan landing page. The Stripe keys are in your Workspace Secrets.

### 2. Configure Stripe price IDs for your box plans

Stripe uses Price objects to define recurring billing amounts. You need to create Products and Prices in the Stripe Dashboard (test mode), then store their IDs in your `box_plans` table. The subscription checkout route references these IDs when creating the Checkout Session.

```
// server/routes/subscribe.js
const express = require('express');
const stripe = require('../stripe');  // server/stripe.js singleton
const { getBaseUrl } = require('../lib/baseUrl');
const { db } = require('../db');
const { boxPlans, subscribers } = require('../schema');
const { eq } = require('drizzle-orm');

const router = express.Router();

router.post('/api/subscribe', express.json(), async (req, res) => {
  const { planId, preferences, shippingAddress } = req.body;
  const user = req.user;  // from Replit Auth
  const baseUrl = getBaseUrl();

  const [plan] = await db.select().from(boxPlans)
    .where(eq(boxPlans.id, planId)).limit(1);

  if (!plan || !plan.isActive) {
    return res.status(400).json({ error: 'Plan not available' });
  }

  // Create or retrieve Stripe customer
  let customer = await stripe.customers.create({
    email: user.email,
    name: user.name,
    metadata: { user_id: user.id },
  });

  // Store subscriber record with preferences before checkout
  await db.insert(subscribers).values({
    userId: user.id,
    stripeCustomerId: customer.id,
    name: user.name,
    email: user.email,
    shippingAddress,
    preferences,
  }).onConflictDoNothing();

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer: customer.id,
    line_items: [{ price: plan.stripePriceId, quantity: 1 }],
    success_url: `${baseUrl}/dashboard?subscribed=1&session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${baseUrl}/plans?canceled=1`,
    subscription_data: {
      trial_period_days: 7,
      metadata: { user_id: user.id, plan_id: String(planId) },
    },
    metadata: { user_id: user.id, plan_id: String(planId) },
  });

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

module.exports = router;
```

> Pro tip: In Stripe Dashboard (test mode), create one Product per box plan, then create a recurring Price for each. Copy the price_XXXX IDs into your box_plans table via Drizzle Studio.

### 3. Build the Stripe webhook handler for billing events

Webhooks drive the subscription lifecycle. When `invoice.paid` fires, you generate the shipment (unless skip_next is true). When `invoice.payment_failed`, mark the subscription as past_due. The webhook route MUST be mounted before `express.json()` and use `express.raw()` — otherwise Stripe signature verification fails.

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

const router = express.Router();

// IMPORTANT: mounted with express.raw() in server/index.js BEFORE express.json()
router.post('/', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    // constructEvent is SYNCHRONOUS in Node.js — do NOT use constructEventAsync
    event = stripe.webhooks.constructEvent(
      req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('[webhook] signature failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Idempotency check
  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, duplicate: true });
  }

  try {
    switch (event.type) {
      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await upsertSubscription(event.data.object);
        break;
      case 'customer.subscription.deleted':
        await db.update(subscriptions)
          .set({ status: 'cancelled' })
          .where(eq(subscriptions.stripeSubscriptionId, event.data.object.id));
        break;
      case 'invoice.paid':
        await handleInvoicePaid(event.data.object);
        break;
      case 'invoice.payment_failed':
        await db.update(subscriptions)
          .set({ status: 'past_due' })
          .where(eq(subscriptions.stripeSubscriptionId, event.data.object.subscription));
        break;
    }
    res.json({ received: true });
  } catch (err) {
    console.error('[webhook] handler error:', err);
    res.status(500).send('handler error');
  }
});

async function upsertSubscription(sub) {
  const item = sub.items.data[0];
  await db.insert(subscriptions).values({
    stripeSubscriptionId: sub.id,
    status: sub.status,
    currentPeriodEnd: new Date(sub.current_period_end * 1000),
    skipNext: false,
  }).onConflictDoUpdate({
    target: subscriptions.stripeSubscriptionId,
    set: { status: sub.status, currentPeriodEnd: new Date(sub.current_period_end * 1000) },
  });
}

async function handleInvoicePaid(invoice) {
  const [sub] = await db.select().from(subscriptions)
    .where(eq(subscriptions.stripeSubscriptionId, invoice.subscription)).limit(1);

  if (!sub) return;

  await db.update(subscriptions)
    .set({ status: 'active' })
    .where(eq(subscriptions.id, sub.id));

  if (!sub.skipNext) {
    await db.insert(boxShipments).values({
      subscriptionId: sub.id,
      items: [],  // populated by admin shipment generation job
      status: 'preparing',
    });
  } else {
    // Reset skip flag for next cycle
    await db.update(subscriptions)
      .set({ skipNext: false })
      .where(eq(subscriptions.id, sub.id));
  }
}

module.exports = router;
```

> Pro tip: Webhooks only fire to deployed URLs, not your Replit dev workspace. Deploy first, then add the deployed URL as a webhook endpoint in the Stripe Dashboard. Copy the signing secret into Replit Deployment Secrets (separate from Workspace Secrets).

### 4. Add skip and pause controls for subscribers

Subscribers need control over their box without canceling entirely. Skip-next sets a flag that the webhook handler checks before generating a shipment. Pause uses Stripe's pause_collection to stop invoicing while keeping the subscription active in your system.

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

const router = express.Router();

// POST /api/subscriptions/:id/skip — skip next billing cycle box
router.post('/api/subscriptions/:id/skip', express.json(), async (req, res) => {
  const subId = parseInt(req.params.id);
  const userId = req.user.id;

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

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

  await db.update(subscriptions)
    .set({ skipNext: !sub.skipNext })  // toggle
    .where(eq(subscriptions.id, subId));

  res.json({ skipNext: !sub.skipNext });
});

// POST /api/subscriptions/:id/pause — pause via Stripe pause_collection
router.post('/api/subscriptions/:id/pause', express.json(), async (req, res) => {
  const subId = parseInt(req.params.id);

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

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

  if (sub.status === 'active') {
    await stripe.subscriptions.update(sub.stripeSubscriptionId, {
      pause_collection: { behavior: 'mark_uncollectible' },
    });
    await db.update(subscriptions).set({ status: 'paused' })
      .where(eq(subscriptions.id, subId));
  } else if (sub.status === 'paused') {
    await stripe.subscriptions.update(sub.stripeSubscriptionId, {
      pause_collection: '',  // empty string removes the pause
    });
    await db.update(subscriptions).set({ status: 'active' })
      .where(eq(subscriptions.id, subId));
  }

  res.json({ status: sub.status === 'active' ? 'paused' : 'active' });
});

module.exports = router;
```

**Expected result:** The subscriber dashboard shows a toggle switch for 'Skip next box' and a 'Pause subscription' button that correctly update the UI and Stripe.

### 5. Deploy and add webhook secret to Deployment Secrets

Deploy using Autoscale from the Publish pane. After deployment, copy your *.replit.app URL and add it as a webhook endpoint in the Stripe Dashboard (test mode) pointing to `/api/webhooks/stripe`. Stripe shows you the signing secret — add it as `STRIPE_WEBHOOK_SECRET` in Deployment Secrets (the Publish pane has a separate Secrets section). Workspace Secrets are not used in deployments.

```
// server/index.js — correct middleware ordering for webhooks
const express = require('express');
const app = express();

// 1. Webhook route FIRST with raw body parser
const webhookRouter = require('./routes/webhook');
app.use('/api/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  webhookRouter
);

// 2. JSON parser for all other routes
app.use(express.json());

// 3. API routes
app.use(require('./routes/subscribe'));
app.use(require('./routes/subscription-controls'));

const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Subscription box server running on port ${PORT}`);
});
```

> Pro tip: Use Reserved VM ($6-20/month) instead of Autoscale if you need instant webhook delivery. Autoscale has 10-30 second cold starts, and Stripe retries failed deliveries for up to 3 days — so both work, but Reserved VM is more reliable for production payment volumes.

**Expected result:** The app is live, Stripe can deliver webhooks to your deployed URL, and test payments with card 4242 4242 4242 4242 trigger the full subscription lifecycle.

## Complete code example

File: `server/routes/webhook.js`

```javascript
const express = require('express');
const stripe = require('../stripe');
const { db } = require('../db');
const { eq } = require('drizzle-orm');
const { subscriptions, boxShipments, webhookEvents } = require('../schema');

const router = express.Router();

// Mounted with express.raw({ type: 'application/json' }) in server/index.js BEFORE express.json()
router.post('/', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    // SYNC constructEvent — NOT constructEventAsync (that's for Deno/Workers)
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('[webhook] signature error:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Idempotency — skip duplicate Stripe events
  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, duplicate: true });
  }

  try {
    switch (event.type) {
      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const sub = event.data.object;
        await db
          .insert(subscriptions)
          .values({
            stripeSubscriptionId: sub.id,
            stripeCustomerId: sub.customer,
            status: sub.status,
            currentPeriodEnd: new Date(sub.current_period_end * 1000),
            skipNext: false,
          })
          .onConflictDoUpdate({
            target: subscriptions.stripeSubscriptionId,
            set: {
              status: sub.status,
              currentPeriodEnd: new Date(sub.current_period_end * 1000),
            },
          });
        break;
      }
      case 'customer.subscription.deleted':
module.exports = router;
```

## Common mistakes

- **Stripe signature verification fails with 'No signatures found' error** — The global `express.json()` parses the request body before the webhook route sees the raw bytes. Stripe verifies the signature against the exact raw bytes — once parsed and re-serialized, the bytes change and the signature no longer matches. Fix: Mount the webhook route BEFORE `app.use(express.json())` and use `express.raw({ type: 'application/json' })` only on that route. See the server/index.js in step 5.
- **Workspace Secrets not available in deployed app** — Replit has two separate Secret stores: Workspace Secrets (for development) and Deployment Secrets (for the live app). They don't automatically sync. Fix: After deploying, open the Publish pane, go to the Secrets section, and re-enter STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET for the deployment.
- **Webhook fires but shipment is generated even when skip_next is true** — The invoice.paid handler runs before checking the skip_next flag, or the flag wasn't checked correctly. Fix: Read the subscription row from your database inside the webhook handler and check `sub.skipNext` before inserting the shipment. Reset skip_next to false after skipping so only one cycle is skipped.
- **Using constructEventAsync instead of constructEvent** — Replit Agent sometimes generates Deno-style patterns for Stripe webhook verification. `constructEventAsync` is for Deno/Cloudflare Workers, not Node.js. Fix: Use synchronous `stripe.webhooks.constructEvent(req.body, sig, secret)` — no await needed. Delete any `Stripe.createSubtleCryptoProvider()` calls Agent may have added.

## Best practices

- Store STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY, and STRIPE_WEBHOOK_SECRET in Replit Secrets (lock icon) — never in source files
- Add Deployment Secrets separately in the Publish pane — Workspace Secrets are NOT automatically available to deployed apps
- Always mount the webhook route BEFORE app.use(express.json()) and use express.raw() only on that route
- Use the webhook_events table for idempotency — insert the Stripe event ID on conflict do nothing, then skip processing if no rows were inserted
- Use Drizzle Studio (open from the Database tool) to inspect subscriber preferences and subscription statuses without writing extra queries
- Test the full payment flow with Stripe test card 4242 4242 4242 4242 before going live
- Deploy on Reserved VM for production payment volumes — Autoscale cold starts (10-30s) can cause Stripe to log webhook failures, though it will retry for up to 3 days

## Frequently asked questions

### Do I need a paid Stripe account to test the subscription box flow?

No. The Replit `/stripe` command provisions a free Stripe sandbox with test mode keys. Use test card 4242 4242 4242 4242 with any future expiry to simulate payments. You only need to complete Stripe's KYC verification and add live keys when you're ready to charge real customers.

### How do I set up Stripe Price IDs for my box plans?

In the Stripe Dashboard (test mode), go to Products → Create product for each box plan. Set the price as recurring with your interval (monthly or quarterly). After saving, copy the price_XXXX ID and insert it into your box_plans table via Drizzle Studio or a seed script.

### Why do webhooks work in deployment but not in my Replit workspace?

Replit workspace URLs are tied to your editor session and can be private. Stripe cannot reliably reach them. You must deploy first and register your *.replit.app URL as the webhook endpoint in the Stripe Dashboard. For local development, use the Stripe CLI in the Replit Shell.

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

Autoscale works for most subscription box services — Stripe retries failed webhooks for up to 3 days, so occasional cold start delays don't cause lost events. Upgrade to Reserved VM ($6-20/month) when you're processing more than 50 new subscriptions per day and need instant webhook delivery.

### How do I handle subscribers who want to cancel?

The Stripe Customer Portal (via the `POST /api/billing-portal` route) handles cancellation without you building a cancellation UI. The subscriber clicks 'Manage billing', is redirected to Stripe, and can cancel from there. Your `customer.subscription.deleted` webhook handler then updates the local status to 'cancelled'.

### How does the skip-next-box feature work technically?

The skip_next boolean is stored on the subscriptions table. When `invoice.paid` fires, the webhook handler checks this flag before inserting a box_shipment row. If skip_next is true, no shipment is created and the flag is reset to false so only one box is skipped.

### Can I offer a free trial with this setup?

Yes. Add `trial_period_days: 7` (or any number) to `subscription_data` in the Stripe Checkout Session creation. Subscribers see '$0 due today' and enter their payment method, but won't be charged until the trial ends. The subscription starts in `trialing` status.

### Can RapidDev help me build a custom subscription box platform?

Yes. RapidDev has built 600+ apps including e-commerce and subscription platforms. If you need custom box curation logic, multi-warehouse fulfillment, or white-label storefronts, book a free consultation at rapidevelopers.com.

---

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