Build a multi-vendor marketplace in Replit in 2-4 hours. Use Replit Agent to generate an Express + PostgreSQL app with Stripe Connect for split payments, seller onboarding, buyer/seller roles, and a webhook handler. Deploy on Reserved VM for always-on payment event reception.
What you're building
A marketplace is one of the most complex but highest-value apps you can build. Etsy, Fiverr, and Gumroad are all marketplaces — a platform that takes a percentage of every transaction between independent sellers and buyers. The core technical challenge isn't the product listings or the search — it's the money. Stripe Connect handles split payments: when a buyer pays $100, Stripe automatically sends $90 to the seller and $10 to your platform, without you ever touching the funds.
Replit Agent generates the full Express backend in one prompt. The seller onboarding flow uses Stripe Connect Express, which means sellers complete Stripe's hosted identity verification — you never see their banking details. Once a seller's account has charges_enabled and payouts_enabled, their listings go live. A webhook event on account.updated fires when onboarding completes.
The most critical architectural decision: this is a manual Stripe integration. The Replit /stripe command does not support Connect flows. Install the Stripe SDK manually, store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in Replit Secrets, and bind the webhook endpoint to your deployed URL. Deploy on Reserved VM — the marketplace must always be able to receive Stripe payment events.
Final result
A fully functional multi-vendor marketplace with Stripe Connect split payments, seller onboarding, product listings, buyer checkout flow, order management, and a verified review system — deployed on Replit Reserved VM.
Tech stack
Prerequisites
- A Replit Core account or higher (Reserved VM deployment required for webhooks)
- A Stripe account with Connect enabled (Settings → Connect → Enable Stripe Connect)
- STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET (generated after deployment) in Replit Secrets
- Platform commission rate decision (e.g., 10% platform fee, 90% to seller)
Build steps
Scaffold the project with Replit Agent
Create a new Repl and use the Agent prompt below to generate the full marketplace structure. This is a manual Stripe setup — do NOT use the /stripe command, as it doesn't support Connect flows.
1// Type this into Replit Agent:2// Build a multi-vendor marketplace with Express and PostgreSQL using Drizzle ORM.3// DO NOT use the /stripe command — install stripe SDK manually: npm install stripe.4// Tables:5// - sellers: id serial pk, user_id text unique not null, store_name text not null,6// store_description text, logo_url text, stripe_account_id text, payout_status text default 'pending'7// (enum: pending/active/disabled), commission_rate numeric default 0.10, created_at timestamp8// - categories: id serial, name text unique not null, slug text unique not null, position integer default 09// - listings: id serial, seller_id integer FK sellers, category_id integer FK categories,10// title text not null, description text not null, price integer not null (cents),11// images jsonb default '[]', status text default 'draft'12// (enum: draft/pending_review/active/sold_out/suspended), stock integer default 1,13// tags text[], created_at timestamp, updated_at timestamp14// - orders: id serial, buyer_id text not null, listing_id integer FK listings,15// seller_id integer FK sellers, quantity integer default 1, total_amount integer not null,16// platform_fee integer not null, seller_payout integer not null, status text default 'pending'17// (enum: pending/paid/shipped/delivered/disputed/refunded),18// stripe_payment_intent_id text, shipping_address jsonb, created_at timestamp19// - reviews: id serial, order_id integer FK orders unique, buyer_id text not null,20// seller_id integer FK sellers, rating integer not null, comment text, created_at timestamp21// - webhook_events: id serial, stripe_event_id text unique not null, event_type text,22// processed_at timestamp default now()23// Routes: POST /api/sellers/register, POST /api/sellers/onboard (Stripe Connect Express),24// GET /api/sellers/dashboard, POST /api/listings, GET /api/listings (public browse),25// GET /api/listings/:id, POST /api/orders (create Stripe Checkout Session with Connect),26// POST /api/orders/:id/ship, POST /api/reviews, POST /api/webhooks/stripe (raw body).27// Use Replit Auth. Bind server to 0.0.0.0.Pro tip: Install Stripe manually in the Replit packages panel or by typing npm install stripe in the Shell tab. The /stripe command provisions a simple checkout but does NOT set up Connect — you need the SDK directly.
Expected result: A running Express app with all six tables. The packages panel shows stripe installed. The console shows 'Marketplace running on port 5000'.
Build the Stripe Connect seller onboarding flow
Sellers register with your platform and get redirected to Stripe's hosted onboarding to provide identity and banking details. After completion, Stripe sends an account.updated webhook to activate their listings.
1const Stripe = require('stripe');2const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);3const { sellers } = require('../../shared/schema');4const { eq } = require('drizzle-orm');56// POST /api/sellers/register — create seller profile7router.post('/sellers/register', async (req, res) => {8 const userId = req.user?.id;9 if (!userId) return res.status(401).json({ error: 'Login required' });1011 const { storeName, storeDescription } = req.body;12 try {13 const [seller] = await db.insert(sellers).values({14 userId,15 storeName,16 storeDescription,17 payoutStatus: 'pending',18 }).returning();19 res.status(201).json(seller);20 } catch (err) {21 if (err.code === '23505') return res.status(409).json({ error: 'Already registered as seller' });22 res.status(500).json({ error: err.message });23 }24});2526// POST /api/sellers/onboard — initiate Stripe Connect Express onboarding27router.post('/sellers/onboard', async (req, res) => {28 const userId = req.user?.id;29 const [seller] = await db.select().from(sellers).where(eq(sellers.userId, userId));30 if (!seller) return res.status(404).json({ error: 'Register as seller first' });3132 let accountId = seller.stripeAccountId;3334 // Create Stripe Express account if none exists35 if (!accountId) {36 const account = await stripe.accounts.create({37 type: 'express',38 capabilities: { card_payments: { requested: true }, transfers: { requested: true } },39 });40 accountId = account.id;41 await db.update(sellers).set({ stripeAccountId: accountId }).where(eq(sellers.id, seller.id));42 }4344 // Create an Account Link for onboarding redirect45 const accountLink = await stripe.accountLinks.create({46 account: accountId,47 refresh_url: `${process.env.REPLIT_DEPLOYMENT_URL}/seller/onboard`,48 return_url: `${process.env.REPLIT_DEPLOYMENT_URL}/seller/dashboard`,49 type: 'account_onboarding',50 });5152 res.json({ url: accountLink.url });53});Pro tip: Store REPLIT_DEPLOYMENT_URL in Replit Secrets before deploying. This URL appears in the refresh_url and return_url for Stripe's onboarding flow. Using localhost here will break the redirect after onboarding.
Expected result: POST /api/sellers/onboard returns a Stripe-hosted onboarding URL. Redirecting the seller to that URL takes them through identity verification and bank account setup.
Build the Stripe Checkout with destination charges
When a buyer purchases a listing, create a Stripe Checkout Session with a destination charge. Stripe automatically splits the payment: the platform_fee goes to your platform account, the seller_payout transfers to the seller's connected account.
1// POST /api/orders — create order and Stripe Checkout Session2router.post('/orders', async (req, res) => {3 const buyerId = req.user?.id;4 if (!buyerId) return res.status(401).json({ error: 'Login required' });56 const { listingId, quantity = 1, shippingAddress } = req.body;78 const [listing] = await db.select().from(listings)9 .innerJoin(sellers, eq(listings.sellerId, sellers.id))10 .where(eq(listings.id, listingId));1112 if (!listing) return res.status(404).json({ error: 'Listing not found' });13 if (listing.listings.status !== 'active') return res.status(400).json({ error: 'Listing not available' });14 if (listing.sellers.payoutStatus !== 'active') return res.status(400).json({ error: 'Seller not yet verified' });1516 const totalAmount = listing.listings.price * quantity;17 const commissionRate = parseFloat(listing.sellers.commissionRate);18 const platformFee = Math.round(totalAmount * commissionRate);19 const sellerPayout = totalAmount - platformFee;2021 // Create order record first (pending payment)22 const [order] = await db.insert(orders).values({23 buyerId,24 listingId,25 sellerId: listing.sellers.id,26 quantity,27 totalAmount,28 platformFee,29 sellerPayout,30 shippingAddress: shippingAddress || null,31 status: 'pending',32 }).returning();3334 // Create Stripe Checkout Session with Connect destination charge35 const session = await stripe.checkout.sessions.create({36 payment_method_types: ['card'],37 line_items: [{38 price_data: {39 currency: 'usd',40 product_data: { name: listing.listings.title },41 unit_amount: listing.listings.price,42 },43 quantity,44 }],45 mode: 'payment',46 success_url: `${process.env.REPLIT_DEPLOYMENT_URL}/orders/${order.id}/success`,47 cancel_url: `${process.env.REPLIT_DEPLOYMENT_URL}/listings/${listingId}`,48 payment_intent_data: {49 application_fee_amount: platformFee,50 transfer_data: { destination: listing.sellers.stripeAccountId },51 },52 metadata: { orderId: order.id.toString() },53 });5455 res.json({ checkoutUrl: session.url, orderId: order.id });56});Expected result: POST /api/orders returns a Stripe-hosted checkout URL. After payment, the platform fee is retained in your Stripe account and the seller payout transfers automatically to the seller's connected account.
Build the Stripe webhook handler
The webhook endpoint listens for payment completion and seller onboarding events. It uses constructEvent (sync, not async) to verify the Stripe signature. A deduplication table prevents double-processing.
1const express = require('express');2const Stripe = require('stripe');3const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);4const { webhookEvents, orders, listings, sellers } = require('../../shared/schema');5const { eq } = require('drizzle-orm');67const router = express.Router();89// POST /api/webhooks/stripe — raw body required for signature verification10router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {11 const sig = req.headers['stripe-signature'];12 let event;1314 try {15 // constructEvent is SYNC — not async16 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);17 } catch (err) {18 return res.status(400).json({ error: `Webhook signature verification failed: ${err.message}` });19 }2021 // Deduplication: skip already-processed events22 const existing = await db.select().from(webhookEvents)23 .where(eq(webhookEvents.stripeEventId, event.id));24 if (existing.length > 0) {25 return res.json({ received: true, duplicate: true });26 }2728 // Record event before processing29 await db.insert(webhookEvents).values({30 stripeEventId: event.id,31 eventType: event.type,32 });3334 // Process events35 if (event.type === 'checkout.session.completed') {36 const session = event.data.object;37 const orderId = parseInt(session.metadata.orderId);3839 await db.update(orders)40 .set({41 status: 'paid',42 stripePaymentIntentId: session.payment_intent,43 })44 .where(eq(orders.id, orderId));4546 // Decrement listing stock47 const [order] = await db.select().from(orders).where(eq(orders.id, orderId));48 if (order) {49 await db.update(listings)50 .set({ stock: sql`stock - ${order.quantity}` })51 .where(eq(listings.id, order.listingId));52 }53 }5455 if (event.type === 'account.updated') {56 const account = event.data.object;57 if (account.charges_enabled && account.payouts_enabled) {58 await db.update(sellers)59 .set({ payoutStatus: 'active' })60 .where(eq(sellers.stripeAccountId, account.id));61 }62 }6364 res.json({ received: true });65});6667module.exports = router;Pro tip: The webhook route must be registered BEFORE express.json() middleware — Stripe requires the raw request body for signature verification. Register it as: app.use('/api/webhooks', webhooksRouter) before app.use(express.json()).
Get the webhook secret and deploy on Reserved VM
After deploying to Reserved VM, register your webhook endpoint with Stripe to get the STRIPE_WEBHOOK_SECRET. A marketplace must be on Reserved VM — Autoscale can miss payment events when instances are scaled down.
1// server/index.js — critical middleware order for webhook signature verification2const express = require('express');3const path = require('path');45const webhooksRouter = require('./routes/webhooks');6const apiRouter = require('./routes/api');78const app = express();910// IMPORTANT: Register webhook route BEFORE express.json()11// Stripe requires raw body for signature verification12app.use('/api/webhooks', webhooksRouter);1314// Then add JSON parsing for all other routes15app.use(express.json());16app.use('/api', apiRouter);1718// Serve React frontend19app.use(express.static(path.join(__dirname, '../client/dist')));20app.get('*', (req, res) => {21 res.sendFile(path.join(__dirname, '../client/dist/index.html'));22});2324// Bind to 0.0.0.0 — required for Replit25app.listen(5000, '0.0.0.0', () => console.log('Marketplace running on port 5000'));2627// After deploying to Reserved VM:28// 1. Go to Stripe Dashboard → Developers → Webhooks → Add endpoint29// 2. Enter your Replit URL: https://your-repl.replit.app/api/webhooks/stripe30// 3. Select events: checkout.session.completed, account.updated31// 4. Copy the Signing Secret32// 5. Add it to Replit Secrets as STRIPE_WEBHOOK_SECRETPro tip: Deploy on Reserved VM ($10-20/month). Marketplaces cannot use Autoscale for webhook endpoints — if an instance is scaled to zero when a payment event arrives, that event is lost and the order never gets marked as paid.
Expected result: The app runs on Reserved VM with a public URL. The Stripe webhook endpoint is registered and the STRIPE_WEBHOOK_SECRET is in Replit Secrets. Test by completing a purchase in Stripe test mode.
Complete code
1const express = require('express');2const Stripe = require('stripe');3const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);4const { db } = require('../db');5const { webhookEvents, orders, listings, sellers } = require('../../shared/schema');6const { eq, sql } = require('drizzle-orm');78const router = express.Router();910router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {11 const sig = req.headers['stripe-signature'];12 let event;1314 try {15 event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);16 } catch (err) {17 console.error('Webhook signature error:', err.message);18 return res.status(400).send(`Webhook Error: ${err.message}`);19 }2021 // Deduplication check22 const existing = await db.select().from(webhookEvents)23 .where(eq(webhookEvents.stripeEventId, event.id));24 if (existing.length > 0) return res.json({ received: true, duplicate: true });2526 await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });2728 try {29 if (event.type === 'checkout.session.completed') {30 const session = event.data.object;31 const orderId = parseInt(session.metadata?.orderId);32 if (orderId) {33 await db.update(orders)34 .set({ status: 'paid', stripePaymentIntentId: session.payment_intent })35 .where(eq(orders.id, orderId));36 const [order] = await db.select().from(orders).where(eq(orders.id, orderId));37 if (order) {38 await db.update(listings)39 .set({ stock: sql`stock - ${order.quantity}` })40 .where(eq(listings.id, order.listingId));41 }42 }43 }4445 if (event.type === 'account.updated') {46 const account = event.data.object;47 if (account.charges_enabled && account.payouts_enabled) {48 await db.update(sellers)49 .set({ payoutStatus: 'active' })50 .where(eq(sellers.stripeAccountId, account.id));51 } else if (account.requirements?.disabled_reason) {52 await db.update(sellers)53 .set({ payoutStatus: 'disabled' })54 .where(eq(sellers.stripeAccountId, account.id));55 }56 }57 } catch (processingErr) {58 console.error('Webhook processing error:', processingErr.message);59 }6061 res.json({ received: true });62});6364module.exports = router;Customization ideas
Dispute resolution workflow
Add a disputes table and a POST /api/orders/:id/dispute route. When a buyer opens a dispute, set the order status to 'disputed' and send email notifications to both buyer and seller. Add an admin resolution form that sets status to 'refunded' or 'delivered'.
Seller analytics dashboard
Add a GET /api/sellers/analytics route returning 30-day revenue (SUM of seller_payout WHERE paid), top-selling listings, and average review rating. Render as a chart in the seller dashboard using recharts.
Search and filter listings
Add PostgreSQL full-text search on listing title + description with a GIN index. The GET /api/listings route accepts keyword, category, min_price, max_price, and tags query params, enabling faceted search similar to Etsy.
Subscription seller plans
Create seller tiers (free: 10 active listings, pro: unlimited listings). Use Stripe Subscriptions for the Pro plan. Gate the POST /api/listings route based on the seller's active listing count versus their plan limit.
Common pitfalls
Pitfall: Using the /stripe Replit command instead of installing the SDK manually
How to avoid: Install stripe via npm install stripe in the Shell tab. Store STRIPE_SECRET_KEY in Replit Secrets and import with const stripe = new Stripe(process.env.STRIPE_SECRET_KEY).
Pitfall: Registering the webhook route after express.json() middleware
How to avoid: Register app.use('/api/webhooks', webhooksRouter) before app.use(express.json()). Use express.raw({ type: 'application/json' }) only on the webhook route.
Pitfall: Deploying on Autoscale instead of Reserved VM
How to avoid: Deploy marketplaces on Reserved VM ($10-20/month). This ensures the webhook endpoint is always running and can receive Stripe events at any time.
Pitfall: Activating seller listings before their Stripe onboarding is complete
How to avoid: Only set listing status to 'active' after the account.updated webhook confirms charges_enabled AND payouts_enabled are both true. The order creation route checks seller.payoutStatus === 'active' before proceeding.
Best practices
- Store STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and REPLIT_DEPLOYMENT_URL in Replit Secrets — never hardcode them.
- Register the webhook route BEFORE express.json() middleware — Stripe signature verification requires the raw request body.
- Use the webhook_events deduplication table to prevent double-processing. Stripe can deliver the same event more than once.
- Only activate seller listings after receiving the account.updated webhook confirming both charges_enabled and payouts_enabled are true.
- Deploy on Reserved VM for marketplace apps — payment webhooks must be received 24/7 and Autoscale's scale-to-zero will cause missed events.
- Calculate platform_fee and seller_payout at order creation time and store them — don't recalculate at payout time, as commission rates may change.
- Use Stripe test mode during development with test cards like 4242 4242 4242 4242. Switch to live mode only when ready for real transactions.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a multi-vendor marketplace with Express and Stripe Connect. I need to create a Stripe Checkout Session where the platform takes a 10% fee and the rest transfers to the seller's connected Stripe account. Help me write the code for: (1) creating a Stripe Express account for a new seller, (2) generating an Account Link for onboarding, (3) creating a Checkout Session with payment_intent_data.application_fee_amount and transfer_data.destination set to the seller's Stripe account ID. Use the Stripe Node.js SDK.
Add a Stripe Customer Portal integration to the marketplace so sellers can view their payouts, update their bank account, and manage their Stripe Express account from within the app. Add a GET /api/sellers/stripe-portal route that calls stripe.accounts.createLoginLink(seller.stripeAccountId) and returns the URL. Add a 'Manage Payouts' button in the seller dashboard that redirects to this URL in a new tab.
Frequently asked questions
How does Stripe Connect split payments work?
When a buyer pays, Stripe processes the full amount. The application_fee_amount you set (e.g., 10% = $10 on a $100 order) stays in your platform Stripe account. The remaining $90 transfers automatically to the seller's connected Stripe account. You never manually move money between accounts.
What does the seller see during onboarding?
Stripe's hosted onboarding flow. Your app creates an Account Link and redirects the seller to a Stripe-hosted page where they enter their name, date of birth, SSN (last 4), business type, and bank account details. You never see this information — Stripe handles all KYC/AML compliance.
Can I use test mode to build and test without real money?
Yes. Stripe test mode uses separate API keys (sk_test_... and pk_test_...). Use test card 4242 4242 4242 4242 with any future expiry. For Connect testing, use Stripe's test account routing numbers. Switch to live keys only when you're ready for real transactions.
Why does the webhook route need to be before express.json()?
Stripe's constructEvent() requires the raw request body (a Buffer) to verify the signature. express.json() parses the body into a JavaScript object, destroying the original Buffer. If you register the webhook route after json(), every signature check fails with a 400 error.
What Replit plan do I need?
A paid plan (Core or higher) is required for Reserved VM deployment. The marketplace must use Reserved VM — Autoscale scales to zero during quiet periods and will miss Stripe webhook events, causing orders to never be marked as paid.
How do I handle a seller whose Stripe account gets restricted?
Stripe sends an account.updated event with requirements.disabled_reason set. In the webhook handler, update seller.payout_status to 'disabled' when this happens. Add a check in your listing query that hides listings from disabled sellers so buyers don't encounter a broken checkout.
Can RapidDev help build a custom marketplace?
Yes. RapidDev has built 600+ apps including multi-vendor platforms with custom commission structures, escrow systems, and dispute resolution workflows. They can help you configure Stripe Connect and build the full seller/buyer experience. Book a free consultation at rapidevelopers.com.
How do I prevent buyers from reviewing without purchasing?
The reviews table links review to a specific order_id. The POST /api/reviews route checks that the order exists, the buyer_id matches the requesting user, and the order status is 'delivered' before allowing the review. This ensures only verified purchasers can leave reviews.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation