Skip to main content
RapidDev - Software Development Agency

How to Build a Subscription Box Service with Replit

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.

What you'll build

  • Monthly and quarterly subscription box plans with Stripe recurring billing
  • Subscriber signup flow using Stripe Checkout (mode: subscription) with preference collection
  • Subscriber dashboard with skip-next-box and pause/resume subscription controls
  • Shipping address and preference management (size, dietary, interests)
  • Admin shipment generation route that curates box contents based on subscriber preferences
  • Full Stripe webhook handler for invoice.paid, invoice.payment_failed, and subscription events
  • Past shipment history page with tracking numbers for subscribers
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced14 min read2-4 hoursReplit FreeApril 2026RapidDev Engineering Team
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.

What you're building

A subscription box service is a recurring revenue business where customers pay monthly or quarterly for curated physical products. Building this from scratch requires recurring billing, subscriber preference tracking, shipment logistics, and the ability for subscribers to pause or skip boxes — features that would take weeks to wire together manually.

Replit's `/stripe` command auto-provisions a Stripe sandbox, creates the checkout and webhook routes, and injects the API keys into your Replit Secrets. Agent generates the full Express + PostgreSQL backend from a single prompt, including the Drizzle schema for box plans, subscribers, subscriptions, and shipments. What used to require a payment specialist can now be scaffolded in minutes.

The architecture uses Stripe Checkout in subscription mode for recurring billing. Webhooks from Stripe drive the fulfillment lifecycle: when `invoice.paid` fires, the system checks if the subscriber hasn't skipped this cycle, then generates a shipment record. The subscriber dashboard lets users update preferences, skip the next box, pause their subscription, or update their shipping address — all through the Stripe Customer Portal or your own API routes.

Final result

A complete subscription box platform with plan selection, Stripe recurring billing, subscriber preference management, skip and pause controls, admin box curation, shipment tracking, and a Stripe webhook handler — all deployed on Replit.

Tech stack

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

Prerequisites

  • 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

Build steps

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.

prompt.txt
1// After running /stripe, paste this into Replit Agent:
2// Build a subscription box service with Express, PostgreSQL (Drizzle ORM), and Stripe.
3// Schema: box_plans (id serial PK, name text, description text, price integer in cents,
4// interval text enum monthly/quarterly, stripe_price_id text unique, image_url text,
5// max_subscribers integer, is_active boolean default true),
6// subscribers (id serial PK, user_id text unique, stripe_customer_id text unique,
7// name text, email text, shipping_address jsonb, preferences jsonb, created_at),
8// subscriptions (id serial PK, subscriber_id int references subscribers,
9// box_plan_id int references box_plans, stripe_subscription_id text unique,
10// status text default active enum active/paused/cancelled/past_due,
11// current_period_end timestamp, skip_next boolean default false, created_at),
12// box_shipments (id serial PK, subscription_id int references subscriptions,
13// items jsonb, tracking_number text, status text default preparing
14// enum preparing/shipped/delivered, ship_date timestamp, delivered_date timestamp, created_at),
15// products (id serial PK, name text, category text, cost integer, image_url text, stock int default 0),
16// webhook_events (id serial PK, stripe_event_id text unique, event_type text, processed_at timestamp).
17// Routes: GET /api/plans, POST /api/subscribe (Stripe Checkout mode=subscription),
18// POST /api/subscriptions/:id/pause, POST /api/subscriptions/:id/skip,
19// PATCH /api/subscribers/preferences, PATCH /api/subscribers/address,
20// GET /api/subscribers/shipments, POST /api/admin/shipments/generate,
21// POST /api/webhooks/stripe (raw body, constructEvent sync).
22// Mount /api/webhooks/stripe BEFORE express.json() using express.raw().
23// React frontend: landing page with plan cards, subscriber dashboard with skip/pause toggles,
24// 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
1// server/routes/subscribe.js
2const express = require('express');
3const stripe = require('../stripe'); // server/stripe.js singleton
4const { getBaseUrl } = require('../lib/baseUrl');
5const { db } = require('../db');
6const { boxPlans, subscribers } = require('../schema');
7const { eq } = require('drizzle-orm');
8
9const router = express.Router();
10
11router.post('/api/subscribe', express.json(), async (req, res) => {
12 const { planId, preferences, shippingAddress } = req.body;
13 const user = req.user; // from Replit Auth
14 const baseUrl = getBaseUrl();
15
16 const [plan] = await db.select().from(boxPlans)
17 .where(eq(boxPlans.id, planId)).limit(1);
18
19 if (!plan || !plan.isActive) {
20 return res.status(400).json({ error: 'Plan not available' });
21 }
22
23 // Create or retrieve Stripe customer
24 let customer = await stripe.customers.create({
25 email: user.email,
26 name: user.name,
27 metadata: { user_id: user.id },
28 });
29
30 // Store subscriber record with preferences before checkout
31 await db.insert(subscribers).values({
32 userId: user.id,
33 stripeCustomerId: customer.id,
34 name: user.name,
35 email: user.email,
36 shippingAddress,
37 preferences,
38 }).onConflictDoNothing();
39
40 const session = await stripe.checkout.sessions.create({
41 mode: 'subscription',
42 customer: customer.id,
43 line_items: [{ price: plan.stripePriceId, quantity: 1 }],
44 success_url: `${baseUrl}/dashboard?subscribed=1&session_id={CHECKOUT_SESSION_ID}`,
45 cancel_url: `${baseUrl}/plans?canceled=1`,
46 subscription_data: {
47 trial_period_days: 7,
48 metadata: { user_id: user.id, plan_id: String(planId) },
49 },
50 metadata: { user_id: user.id, plan_id: String(planId) },
51 });
52
53 res.json({ url: session.url });
54});
55
56module.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
1// server/routes/webhook.js
2const express = require('express');
3const stripe = require('../stripe');
4const { db } = require('../db');
5const { eq, and } = require('drizzle-orm');
6const { subscriptions, subscribers, boxShipments, webhookEvents } = require('../schema');
7
8const router = express.Router();
9
10// IMPORTANT: mounted with express.raw() in server/index.js BEFORE express.json()
11router.post('/', async (req, res) => {
12 const sig = req.headers['stripe-signature'];
13 let event;
14
15 try {
16 // constructEvent is SYNCHRONOUS in Node.js — do NOT use constructEventAsync
17 event = stripe.webhooks.constructEvent(
18 req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
19 );
20 } catch (err) {
21 console.error('[webhook] signature failed:', err.message);
22 return res.status(400).send(`Webhook Error: ${err.message}`);
23 }
24
25 // Idempotency check
26 const inserted = await db.insert(webhookEvents)
27 .values({ id: event.id, eventType: event.type })
28 .onConflictDoNothing()
29 .returning({ id: webhookEvents.id });
30
31 if (inserted.length === 0) {
32 return res.json({ received: true, duplicate: true });
33 }
34
35 try {
36 switch (event.type) {
37 case 'customer.subscription.created':
38 case 'customer.subscription.updated':
39 await upsertSubscription(event.data.object);
40 break;
41 case 'customer.subscription.deleted':
42 await db.update(subscriptions)
43 .set({ status: 'cancelled' })
44 .where(eq(subscriptions.stripeSubscriptionId, event.data.object.id));
45 break;
46 case 'invoice.paid':
47 await handleInvoicePaid(event.data.object);
48 break;
49 case 'invoice.payment_failed':
50 await db.update(subscriptions)
51 .set({ status: 'past_due' })
52 .where(eq(subscriptions.stripeSubscriptionId, event.data.object.subscription));
53 break;
54 }
55 res.json({ received: true });
56 } catch (err) {
57 console.error('[webhook] handler error:', err);
58 res.status(500).send('handler error');
59 }
60});
61
62async function upsertSubscription(sub) {
63 const item = sub.items.data[0];
64 await db.insert(subscriptions).values({
65 stripeSubscriptionId: sub.id,
66 status: sub.status,
67 currentPeriodEnd: new Date(sub.current_period_end * 1000),
68 skipNext: false,
69 }).onConflictDoUpdate({
70 target: subscriptions.stripeSubscriptionId,
71 set: { status: sub.status, currentPeriodEnd: new Date(sub.current_period_end * 1000) },
72 });
73}
74
75async function handleInvoicePaid(invoice) {
76 const [sub] = await db.select().from(subscriptions)
77 .where(eq(subscriptions.stripeSubscriptionId, invoice.subscription)).limit(1);
78
79 if (!sub) return;
80
81 await db.update(subscriptions)
82 .set({ status: 'active' })
83 .where(eq(subscriptions.id, sub.id));
84
85 if (!sub.skipNext) {
86 await db.insert(boxShipments).values({
87 subscriptionId: sub.id,
88 items: [], // populated by admin shipment generation job
89 status: 'preparing',
90 });
91 } else {
92 // Reset skip flag for next cycle
93 await db.update(subscriptions)
94 .set({ skipNext: false })
95 .where(eq(subscriptions.id, sub.id));
96 }
97}
98
99module.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
1// server/routes/subscription-controls.js
2const express = require('express');
3const stripe = require('../stripe');
4const { db } = require('../db');
5const { eq } = require('drizzle-orm');
6const { subscriptions } = require('../schema');
7
8const router = express.Router();
9
10// POST /api/subscriptions/:id/skip — skip next billing cycle box
11router.post('/api/subscriptions/:id/skip', express.json(), async (req, res) => {
12 const subId = parseInt(req.params.id);
13 const userId = req.user.id;
14
15 const [sub] = await db.select().from(subscriptions)
16 .where(eq(subscriptions.id, subId)).limit(1);
17
18 if (!sub) return res.status(404).json({ error: 'Subscription not found' });
19
20 await db.update(subscriptions)
21 .set({ skipNext: !sub.skipNext }) // toggle
22 .where(eq(subscriptions.id, subId));
23
24 res.json({ skipNext: !sub.skipNext });
25});
26
27// POST /api/subscriptions/:id/pause — pause via Stripe pause_collection
28router.post('/api/subscriptions/:id/pause', express.json(), async (req, res) => {
29 const subId = parseInt(req.params.id);
30
31 const [sub] = await db.select().from(subscriptions)
32 .where(eq(subscriptions.id, subId)).limit(1);
33
34 if (!sub) return res.status(404).json({ error: 'Not found' });
35
36 if (sub.status === 'active') {
37 await stripe.subscriptions.update(sub.stripeSubscriptionId, {
38 pause_collection: { behavior: 'mark_uncollectible' },
39 });
40 await db.update(subscriptions).set({ status: 'paused' })
41 .where(eq(subscriptions.id, subId));
42 } else if (sub.status === 'paused') {
43 await stripe.subscriptions.update(sub.stripeSubscriptionId, {
44 pause_collection: '', // empty string removes the pause
45 });
46 await db.update(subscriptions).set({ status: 'active' })
47 .where(eq(subscriptions.id, subId));
48 }
49
50 res.json({ status: sub.status === 'active' ? 'paused' : 'active' });
51});
52
53module.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
1// server/index.js — correct middleware ordering for webhooks
2const express = require('express');
3const app = express();
4
5// 1. Webhook route FIRST with raw body parser
6const webhookRouter = require('./routes/webhook');
7app.use('/api/webhooks/stripe',
8 express.raw({ type: 'application/json' }),
9 webhookRouter
10);
11
12// 2. JSON parser for all other routes
13app.use(express.json());
14
15// 3. API routes
16app.use(require('./routes/subscribe'));
17app.use(require('./routes/subscription-controls'));
18
19const PORT = process.env.PORT || 3000;
20app.listen(PORT, '0.0.0.0', () => {
21 console.log(`Subscription box server running on port ${PORT}`);
22});

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

server/routes/webhook.js
1const express = require('express');
2const stripe = require('../stripe');
3const { db } = require('../db');
4const { eq } = require('drizzle-orm');
5const { subscriptions, boxShipments, webhookEvents } = require('../schema');
6
7const router = express.Router();
8
9// Mounted with express.raw({ type: 'application/json' }) in server/index.js BEFORE express.json()
10router.post('/', async (req, res) => {
11 const sig = req.headers['stripe-signature'];
12 let event;
13
14 try {
15 // SYNC constructEvent — NOT constructEventAsync (that's for Deno/Workers)
16 event = stripe.webhooks.constructEvent(
17 req.body,
18 sig,
19 process.env.STRIPE_WEBHOOK_SECRET
20 );
21 } catch (err) {
22 console.error('[webhook] signature error:', err.message);
23 return res.status(400).send(`Webhook Error: ${err.message}`);
24 }
25
26 // Idempotency — skip duplicate Stripe events
27 const inserted = await db
28 .insert(webhookEvents)
29 .values({ id: event.id, eventType: event.type })
30 .onConflictDoNothing()
31 .returning({ id: webhookEvents.id });
32
33 if (inserted.length === 0) {
34 return res.json({ received: true, duplicate: true });
35 }
36
37 try {
38 switch (event.type) {
39 case 'customer.subscription.created':
40 case 'customer.subscription.updated': {
41 const sub = event.data.object;
42 await db
43 .insert(subscriptions)
44 .values({
45 stripeSubscriptionId: sub.id,
46 stripeCustomerId: sub.customer,
47 status: sub.status,
48 currentPeriodEnd: new Date(sub.current_period_end * 1000),
49 skipNext: false,
50 })
51 .onConflictDoUpdate({
52 target: subscriptions.stripeSubscriptionId,
53 set: {
54 status: sub.status,
55 currentPeriodEnd: new Date(sub.current_period_end * 1000),
56 },
57 });
58 break;
59 }
60 case 'customer.subscription.deleted':
61module.exports = router;

Customization ideas

Box curation algorithm

Build an admin route `POST /api/admin/shipments/generate` that queries active subscriptions, loads subscriber preferences from the `preferences` jsonb field, matches products by category, and excludes items shipped in the last 3 boxes — then inserts box_shipment rows with the curated items array.

Subscriber referral program

Add a `referral_code` column to subscribers and a `referrals` table. Give each subscriber a unique code; when a new subscriber signs up with a referral code, award the referrer a credit (either a free month via Stripe coupons, or points in your own system).

Multiple box tiers with upgrade/downgrade

Allow subscribers to switch plans mid-cycle using `stripe.subscriptions.update({ items: [{ id: item.id, price: newPriceId }] })`. Stripe prorates the charge automatically. Handle the `customer.subscription.updated` webhook to update the local `box_plan_id`.

Shipment tracking notifications

Add a tracking_number field to box_shipments. When an admin enters a tracking number, trigger an email to the subscriber via SendGrid with the tracking link. Store the SendGrid API key in Replit Secrets.

Common pitfalls

Pitfall: Stripe signature verification fails with 'No signatures found' error

How to avoid: 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.

Pitfall: Workspace Secrets not available in deployed app

How to avoid: 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.

Pitfall: Webhook fires but shipment is generated even when skip_next is true

How to avoid: 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.

Pitfall: Using constructEventAsync instead of constructEvent

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a subscription box service using Express.js, PostgreSQL with Drizzle ORM, and Stripe on Replit. Help me design the box curation algorithm. I have a `subscribers` table with a `preferences` jsonb column (e.g. {size: 'M', dietary: 'vegan', interests: ['tech', 'books']}) and a `products` table with a `category` column. I also have a `box_shipments` table that records past shipments as a jsonb array. Write an Express route `POST /api/admin/shipments/generate` that for each active subscription: queries preferences, selects matching products (by category matching interests, respecting dietary restrictions), excludes products shipped in the last 3 boxes, and creates a box_shipment row with the selected items.

Build Prompt

Add a Stripe Customer Portal integration to my subscription box service. When a subscriber clicks 'Manage billing', call POST /api/billing-portal which creates a Stripe billingPortal.sessions.create({ customer: customerId, return_url: baseUrl + '/dashboard' }) and returns the portal URL. The subscriber is redirected to Stripe's hosted portal where they can update payment method, switch plans, view invoices, and cancel. Show a 'Manage Billing' button on the subscriber dashboard that triggers this flow.

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.

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.