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
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
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.
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 preparing14// 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.
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.
1// server/routes/subscribe.js2const express = require('express');3const stripe = require('../stripe'); // server/stripe.js singleton4const { getBaseUrl } = require('../lib/baseUrl');5const { db } = require('../db');6const { boxPlans, subscribers } = require('../schema');7const { eq } = require('drizzle-orm');89const router = express.Router();1011router.post('/api/subscribe', express.json(), async (req, res) => {12 const { planId, preferences, shippingAddress } = req.body;13 const user = req.user; // from Replit Auth14 const baseUrl = getBaseUrl();1516 const [plan] = await db.select().from(boxPlans)17 .where(eq(boxPlans.id, planId)).limit(1);1819 if (!plan || !plan.isActive) {20 return res.status(400).json({ error: 'Plan not available' });21 }2223 // Create or retrieve Stripe customer24 let customer = await stripe.customers.create({25 email: user.email,26 name: user.name,27 metadata: { user_id: user.id },28 });2930 // Store subscriber record with preferences before checkout31 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();3940 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 });5253 res.json({ url: session.url });54});5556module.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.
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.
1// server/routes/webhook.js2const express = require('express');3const stripe = require('../stripe');4const { db } = require('../db');5const { eq, and } = require('drizzle-orm');6const { subscriptions, subscribers, boxShipments, webhookEvents } = require('../schema');78const router = express.Router();910// 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;1415 try {16 // constructEvent is SYNCHRONOUS in Node.js — do NOT use constructEventAsync17 event = stripe.webhooks.constructEvent(18 req.body, sig, process.env.STRIPE_WEBHOOK_SECRET19 );20 } catch (err) {21 console.error('[webhook] signature failed:', err.message);22 return res.status(400).send(`Webhook Error: ${err.message}`);23 }2425 // Idempotency check26 const inserted = await db.insert(webhookEvents)27 .values({ id: event.id, eventType: event.type })28 .onConflictDoNothing()29 .returning({ id: webhookEvents.id });3031 if (inserted.length === 0) {32 return res.json({ received: true, duplicate: true });33 }3435 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});6162async 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}7475async function handleInvoicePaid(invoice) {76 const [sub] = await db.select().from(subscriptions)77 .where(eq(subscriptions.stripeSubscriptionId, invoice.subscription)).limit(1);7879 if (!sub) return;8081 await db.update(subscriptions)82 .set({ status: 'active' })83 .where(eq(subscriptions.id, sub.id));8485 if (!sub.skipNext) {86 await db.insert(boxShipments).values({87 subscriptionId: sub.id,88 items: [], // populated by admin shipment generation job89 status: 'preparing',90 });91 } else {92 // Reset skip flag for next cycle93 await db.update(subscriptions)94 .set({ skipNext: false })95 .where(eq(subscriptions.id, sub.id));96 }97}9899module.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).
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.
1// server/routes/subscription-controls.js2const express = require('express');3const stripe = require('../stripe');4const { db } = require('../db');5const { eq } = require('drizzle-orm');6const { subscriptions } = require('../schema');78const router = express.Router();910// POST /api/subscriptions/:id/skip — skip next billing cycle box11router.post('/api/subscriptions/:id/skip', express.json(), async (req, res) => {12 const subId = parseInt(req.params.id);13 const userId = req.user.id;1415 const [sub] = await db.select().from(subscriptions)16 .where(eq(subscriptions.id, subId)).limit(1);1718 if (!sub) return res.status(404).json({ error: 'Subscription not found' });1920 await db.update(subscriptions)21 .set({ skipNext: !sub.skipNext }) // toggle22 .where(eq(subscriptions.id, subId));2324 res.json({ skipNext: !sub.skipNext });25});2627// POST /api/subscriptions/:id/pause — pause via Stripe pause_collection28router.post('/api/subscriptions/:id/pause', express.json(), async (req, res) => {29 const subId = parseInt(req.params.id);3031 const [sub] = await db.select().from(subscriptions)32 .where(eq(subscriptions.id, subId)).limit(1);3334 if (!sub) return res.status(404).json({ error: 'Not found' });3536 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 pause45 });46 await db.update(subscriptions).set({ status: 'active' })47 .where(eq(subscriptions.id, subId));48 }4950 res.json({ status: sub.status === 'active' ? 'paused' : 'active' });51});5253module.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.
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.
1// server/index.js — correct middleware ordering for webhooks2const express = require('express');3const app = express();45// 1. Webhook route FIRST with raw body parser6const webhookRouter = require('./routes/webhook');7app.use('/api/webhooks/stripe',8 express.raw({ type: 'application/json' }),9 webhookRouter10);1112// 2. JSON parser for all other routes13app.use(express.json());1415// 3. API routes16app.use(require('./routes/subscribe'));17app.use(require('./routes/subscription-controls'));1819const 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
1const express = require('express');2const stripe = require('../stripe');3const { db } = require('../db');4const { eq } = require('drizzle-orm');5const { subscriptions, boxShipments, webhookEvents } = require('../schema');67const router = express.Router();89// 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;1314 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_SECRET20 );21 } catch (err) {22 console.error('[webhook] signature error:', err.message);23 return res.status(400).send(`Webhook Error: ${err.message}`);24 }2526 // Idempotency — skip duplicate Stripe events27 const inserted = await db28 .insert(webhookEvents)29 .values({ id: event.id, eventType: event.type })30 .onConflictDoNothing()31 .returning({ id: webhookEvents.id });3233 if (inserted.length === 0) {34 return res.json({ received: true, duplicate: true });35 }3637 try {38 switch (event.type) {39 case 'customer.subscription.created':40 case 'customer.subscription.updated': {41 const sub = event.data.object;42 await db43 .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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation