# How to Build a Marketplace with Replit

- Tool: How to Build with Replit
- Difficulty: Advanced
- Compatibility: Replit Core or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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)

## Step-by-step guide

### 1. 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.

```
// Type this into Replit Agent:
// Build a multi-vendor marketplace with Express and PostgreSQL using Drizzle ORM.
// DO NOT use the /stripe command — install stripe SDK manually: npm install stripe.
// Tables:
// - sellers: id serial pk, user_id text unique not null, store_name text not null,
//   store_description text, logo_url text, stripe_account_id text, payout_status text default 'pending'
//   (enum: pending/active/disabled), commission_rate numeric default 0.10, created_at timestamp
// - categories: id serial, name text unique not null, slug text unique not null, position integer default 0
// - listings: id serial, seller_id integer FK sellers, category_id integer FK categories,
//   title text not null, description text not null, price integer not null (cents),
//   images jsonb default '[]', status text default 'draft'
//   (enum: draft/pending_review/active/sold_out/suspended), stock integer default 1,
//   tags text[], created_at timestamp, updated_at timestamp
// - orders: id serial, buyer_id text not null, listing_id integer FK listings,
//   seller_id integer FK sellers, quantity integer default 1, total_amount integer not null,
//   platform_fee integer not null, seller_payout integer not null, status text default 'pending'
//   (enum: pending/paid/shipped/delivered/disputed/refunded),
//   stripe_payment_intent_id text, shipping_address jsonb, created_at timestamp
// - reviews: id serial, order_id integer FK orders unique, buyer_id text not null,
//   seller_id integer FK sellers, rating integer not null, comment text, created_at timestamp
// - webhook_events: id serial, stripe_event_id text unique not null, event_type text,
//   processed_at timestamp default now()
// Routes: POST /api/sellers/register, POST /api/sellers/onboard (Stripe Connect Express),
// GET /api/sellers/dashboard, POST /api/listings, GET /api/listings (public browse),
// GET /api/listings/:id, POST /api/orders (create Stripe Checkout Session with Connect),
// POST /api/orders/:id/ship, POST /api/reviews, POST /api/webhooks/stripe (raw body).
// 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'.

### 2. 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.

```
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const { sellers } = require('../../shared/schema');
const { eq } = require('drizzle-orm');

// POST /api/sellers/register — create seller profile
router.post('/sellers/register', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const { storeName, storeDescription } = req.body;
  try {
    const [seller] = await db.insert(sellers).values({
      userId,
      storeName,
      storeDescription,
      payoutStatus: 'pending',
    }).returning();
    res.status(201).json(seller);
  } catch (err) {
    if (err.code === '23505') return res.status(409).json({ error: 'Already registered as seller' });
    res.status(500).json({ error: err.message });
  }
});

// POST /api/sellers/onboard — initiate Stripe Connect Express onboarding
router.post('/sellers/onboard', async (req, res) => {
  const userId = req.user?.id;
  const [seller] = await db.select().from(sellers).where(eq(sellers.userId, userId));
  if (!seller) return res.status(404).json({ error: 'Register as seller first' });

  let accountId = seller.stripeAccountId;

  // Create Stripe Express account if none exists
  if (!accountId) {
    const account = await stripe.accounts.create({
      type: 'express',
      capabilities: { card_payments: { requested: true }, transfers: { requested: true } },
    });
    accountId = account.id;
    await db.update(sellers).set({ stripeAccountId: accountId }).where(eq(sellers.id, seller.id));
  }

  // Create an Account Link for onboarding redirect
  const accountLink = await stripe.accountLinks.create({
    account: accountId,
    refresh_url: `${process.env.REPLIT_DEPLOYMENT_URL}/seller/onboard`,
    return_url: `${process.env.REPLIT_DEPLOYMENT_URL}/seller/dashboard`,
    type: 'account_onboarding',
  });

  res.json({ url: accountLink.url });
});
```

> 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.

### 3. 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.

```
// POST /api/orders — create order and Stripe Checkout Session
router.post('/orders', async (req, res) => {
  const buyerId = req.user?.id;
  if (!buyerId) return res.status(401).json({ error: 'Login required' });

  const { listingId, quantity = 1, shippingAddress } = req.body;

  const [listing] = await db.select().from(listings)
    .innerJoin(sellers, eq(listings.sellerId, sellers.id))
    .where(eq(listings.id, listingId));

  if (!listing) return res.status(404).json({ error: 'Listing not found' });
  if (listing.listings.status !== 'active') return res.status(400).json({ error: 'Listing not available' });
  if (listing.sellers.payoutStatus !== 'active') return res.status(400).json({ error: 'Seller not yet verified' });

  const totalAmount = listing.listings.price * quantity;
  const commissionRate = parseFloat(listing.sellers.commissionRate);
  const platformFee = Math.round(totalAmount * commissionRate);
  const sellerPayout = totalAmount - platformFee;

  // Create order record first (pending payment)
  const [order] = await db.insert(orders).values({
    buyerId,
    listingId,
    sellerId: listing.sellers.id,
    quantity,
    totalAmount,
    platformFee,
    sellerPayout,
    shippingAddress: shippingAddress || null,
    status: 'pending',
  }).returning();

  // Create Stripe Checkout Session with Connect destination charge
  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: { name: listing.listings.title },
        unit_amount: listing.listings.price,
      },
      quantity,
    }],
    mode: 'payment',
    success_url: `${process.env.REPLIT_DEPLOYMENT_URL}/orders/${order.id}/success`,
    cancel_url: `${process.env.REPLIT_DEPLOYMENT_URL}/listings/${listingId}`,
    payment_intent_data: {
      application_fee_amount: platformFee,
      transfer_data: { destination: listing.sellers.stripeAccountId },
    },
    metadata: { orderId: order.id.toString() },
  });

  res.json({ checkoutUrl: session.url, orderId: order.id });
});
```

**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.

### 4. 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.

```
const express = require('express');
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const { webhookEvents, orders, listings, sellers } = require('../../shared/schema');
const { eq } = require('drizzle-orm');

const router = express.Router();

// POST /api/webhooks/stripe — raw body required for signature verification
router.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    // constructEvent is SYNC — not async
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).json({ error: `Webhook signature verification failed: ${err.message}` });
  }

  // Deduplication: skip already-processed events
  const existing = await db.select().from(webhookEvents)
    .where(eq(webhookEvents.stripeEventId, event.id));
  if (existing.length > 0) {
    return res.json({ received: true, duplicate: true });
  }

  // Record event before processing
  await db.insert(webhookEvents).values({
    stripeEventId: event.id,
    eventType: event.type,
  });

  // Process events
  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    const orderId = parseInt(session.metadata.orderId);

    await db.update(orders)
      .set({
        status: 'paid',
        stripePaymentIntentId: session.payment_intent,
      })
      .where(eq(orders.id, orderId));

    // Decrement listing stock
    const [order] = await db.select().from(orders).where(eq(orders.id, orderId));
    if (order) {
      await db.update(listings)
        .set({ stock: sql`stock - ${order.quantity}` })
        .where(eq(listings.id, order.listingId));
    }
  }

  if (event.type === 'account.updated') {
    const account = event.data.object;
    if (account.charges_enabled && account.payouts_enabled) {
      await db.update(sellers)
        .set({ payoutStatus: 'active' })
        .where(eq(sellers.stripeAccountId, account.id));
    }
  }

  res.json({ received: true });
});

module.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()).

### 5. 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.

```
// server/index.js — critical middleware order for webhook signature verification
const express = require('express');
const path = require('path');

const webhooksRouter = require('./routes/webhooks');
const apiRouter = require('./routes/api');

const app = express();

// IMPORTANT: Register webhook route BEFORE express.json()
// Stripe requires raw body for signature verification
app.use('/api/webhooks', webhooksRouter);

// Then add JSON parsing for all other routes
app.use(express.json());
app.use('/api', apiRouter);

// Serve React frontend
app.use(express.static(path.join(__dirname, '../client/dist')));
app.get('*', (req, res) => {
  res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});

// Bind to 0.0.0.0 — required for Replit
app.listen(5000, '0.0.0.0', () => console.log('Marketplace running on port 5000'));

// After deploying to Reserved VM:
// 1. Go to Stripe Dashboard → Developers → Webhooks → Add endpoint
// 2. Enter your Replit URL: https://your-repl.replit.app/api/webhooks/stripe
// 3. Select events: checkout.session.completed, account.updated
// 4. Copy the Signing Secret
// 5. Add it to Replit Secrets as STRIPE_WEBHOOK_SECRET
```

> Pro 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 example

File: `server/routes/webhooks.js`

```javascript
const express = require('express');
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const { db } = require('../db');
const { webhookEvents, orders, listings, sellers } = require('../../shared/schema');
const { eq, sql } = require('drizzle-orm');

const router = express.Router();

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

  try {
    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}`);
  }

  // Deduplication check
  const existing = await db.select().from(webhookEvents)
    .where(eq(webhookEvents.stripeEventId, event.id));
  if (existing.length > 0) return res.json({ received: true, duplicate: true });

  await db.insert(webhookEvents).values({ stripeEventId: event.id, eventType: event.type });

  try {
    if (event.type === 'checkout.session.completed') {
      const session = event.data.object;
      const orderId = parseInt(session.metadata?.orderId);
      if (orderId) {
        await db.update(orders)
          .set({ status: 'paid', stripePaymentIntentId: session.payment_intent })
          .where(eq(orders.id, orderId));
        const [order] = await db.select().from(orders).where(eq(orders.id, orderId));
        if (order) {
          await db.update(listings)
            .set({ stock: sql`stock - ${order.quantity}` })
            .where(eq(listings.id, order.listingId));
        }
      }
    }

    if (event.type === 'account.updated') {
      const account = event.data.object;
      if (account.charges_enabled && account.payouts_enabled) {
        await db.update(sellers)
          .set({ payoutStatus: 'active' })
          .where(eq(sellers.stripeAccountId, account.id));
      } else if (account.requirements?.disabled_reason) {
        await db.update(sellers)
          .set({ payoutStatus: 'disabled' })
          .where(eq(sellers.stripeAccountId, account.id));
      }
    }
  } catch (processingErr) {
    console.error('Webhook processing error:', processingErr.message);
  }

  res.json({ received: true });
});

module.exports = router;
```

## Common mistakes

- **Using the /stripe Replit command instead of installing the SDK manually** — The /stripe command provisions a basic single-vendor checkout. It does not support Stripe Connect destination charges or account management needed for multi-vendor split payments. Fix: 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).
- **Registering the webhook route after express.json() middleware** — express.json() parses and replaces req.body with a JavaScript object. Stripe's constructEvent needs the raw Buffer, not a parsed object. Registering after json() causes signature verification to always fail. Fix: Register app.use('/api/webhooks', webhooksRouter) before app.use(express.json()). Use express.raw({ type: 'application/json' }) only on the webhook route.
- **Deploying on Autoscale instead of Reserved VM** — Autoscale scales instances to zero during idle periods. A payment event arriving while no instance is running is lost — the order never gets marked as paid. Fix: Deploy marketplaces on Reserved VM ($10-20/month). This ensures the webhook endpoint is always running and can receive Stripe events at any time.
- **Activating seller listings before their Stripe onboarding is complete** — If you allow listings to go live while the seller's payouts_enabled is still false, buyers can check out but the seller never receives funds. Fix: 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.

## 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.

---

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