Stripe

Marketplace SaaS Boilerplate: 4 Vendors in 60 Days

Built a marketplace SaaS with 4 vendors and Stripe Connect escrow in 60 days on TanStack Ship. Real D1 schema, ledger, payouts, and tradeoffs inside.

Huifer
Huifer
May 21, 202611 min read
Also available in:Deutsch · 中文

Written by Huifer, solo developer and maintainer of TanStack Ship. Between October 2025 and March 2026 I built a B2B resale marketplace for a niche industrial supply vertical — four vendors onboarded, ~$42k GMV run-rate, Stripe Connect Express with held-balance escrow, and a custom multi-vendor ledger on Cloudflare D1. The full build took 60 calendar days working solo. This post is the architecture and the code, not the pitch.

Verified sources: TanStack Start · TanStack Router · Cloudflare D1 · Cloudflare Queues · Stripe Connect · Stripe Express · Makerkit · ShipFast

Last updated: 2026-05-21 · Changelog


TL;DR: A marketplace SaaS is four sub-problems glued together: vendor onboarding (Stripe Connect Express), a multi-vendor data model with tenant scoping, an escrow ledger that reconciles held funds, and payout timing. Each sub-problem has a known shape. TanStack Ship ships the schema and the auth-scoping layer; this post shows the vendor schema, the Connect onboarding flow, and the escrow ledger that reconciles every 24 hours. 60 calendar days, 4 vendors, ~$42k GMV run-rate.


Marketplace SaaS Boilerplate: 4 Vendors in 60 Days

The marketplace pattern: 4 hard sub-problems

A marketplace SaaS is not a SaaS with one extra table. It is four sub-problems glued together, each with its own failure modes and its own library. The four:

  1. Vendor onboarding — KYC, identity verification, payout bank account linking. Stripe Connect Express handles this in days, not months.
  2. Multi-vendor data model — every order, listing, and review carries a vendor_id and the queries are tenant-scoped. The schema looks simple; the indexes and RLS-equivalent helpers are where the engineering hours go.
  3. Escrow ledger — funds are held when the buyer pays and released when the order completes or the dispute window closes. The ledger must be append-only and idempotent; reconciliation runs on every state change.
  4. Payouts — payouts batch, hold for a configurable safety window (usually 3–14 days), and retry on failure. Payout timing is the single most-asked question by buyers and vendors.

Each sub-problem has a known shape. The 60-day window in this post is the time it took to assemble the four shapes into a working marketplace — not to invent the shapes. The TanStack Ship boilerplate provides auth, multi-tenant scoping, Stripe billing, admin, and transactional email; the marketplace-specific glue is the schema, the Connect integration, the ledger, and the payout worker. That glue was about 90 hours of the 60 days.

For the broader boilerplate decision tree, see best SaaS boilerplates in 2026 scorecard. For B2B tenancy depth on Makerkit, see TanStack Ship vs Makerkit.

Hour budget across the 60 days

Sub-problemHoursNotes
Vendor onboarding (Stripe Connect Express)14Account creation link, webhook handler, KYC status sync
Multi-vendor data model + indexes18Schema, Drizzle migrations, tenant scope helpers
Escrow ledger + state machine22Append-only ledger, reconciliation worker
Payout timing + retry worker16Daily cron, idempotent payout records
Vendor admin portal12Listing CRUD, fulfillment queue
Buyer checkout flow8Cart, address, payment
Subtotal (marketplace glue)90
Design, copy, marketing pages, deploy60Mostly TanStack Ship defaults
Total15060 calendar days working solo

The marketplace glue (90 hours) is the delta a custom build would pay in full. TanStack Ship absorbed the other 60 hours with its defaults; a from-scratch build would have spent those 60 hours on auth and billing instead.

Why the marketplace glue is fixed at ~90 hours

Three sub-problems have irreducible engineering costs that no starter kit can fully absorb. The first is the multi-vendor data model with proper indexes — a listings table without a (vendor_id, status) composite index will scan the full table on every vendor dashboard query by month two. The second is the escrow ledger state machine — eight states, three concurrent flows (purchase, refund, dispute), and a reconciliation worker that catches webhook drift. The third is the payout worker — batch, hold, retry, drift detection. The marketplace glue is the diff between a SaaS and a marketplace, and it is roughly 90 hours in any stack.

The 4-vendor build in this post is small enough that 60 days is comfortable and large enough that real production edge cases surfaced (idempotency-key drift on day 17, address-reverification on day 31, dispute-window adjustment on day 44).

Multi-vendor data model on Cloudflare D1

The data model has six core tables. The shape mirrors Drizzle's text() defaults for D1; the same schema works on Cloudflare D1, Turso, or libSQL.

The schema

typescript
// src/db/schema.ts
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

export const vendors = sqliteTable("vendors", {
  id: text("id").primaryKey(),
  ownerUserId: text("owner_user_id").notNull(),
  displayName: text("display_name").notNull(),
  slug: text("slug").notNull().unique(),
  stripeAccountId: text("stripe_account_id"),          // acct_xxx from Stripe Connect
  kycStatus: text("kyc_status").notNull().default("pending"), // pending|verified|rejected
  payoutScheduleDays: integer("payout_schedule_days").notNull().default(7),
  createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});

export const listings = sqliteTable("listings", {
  id: text("id").primaryKey(),
  vendorId: text("vendor_id").notNull().references(() => vendors.id),
  title: text("title").notNull(),
  priceCents: integer("price_cents").notNull(),
  currency: text("currency").notNull().default("USD"),
  inventoryCount: integer("inventory_count").notNull().default(0),
  status: text("status").notNull().default("draft"), // draft|active|paused|archived
  createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});

export const orders = sqliteTable("orders", {
  id: text("id").primaryKey(),
  buyerUserId: text("buyer_user_id").notNull(),
  vendorId: text("vendor_id").notNull().references(() => vendors.id),
  listingId: text("listing_id").notNull().references(() => listings.id),
  amountCents: integer("amount_cents").notNull(),
  platformFeeCents: integer("platform_fee_cents").notNull(),
  vendorAmountCents: integer("vendor_amount_cents").notNull(),
  currency: text("currency").notNull().default("USD"),
  paymentIntentId: text("payment_intent_id"),         // pi_xxx from Stripe
  status: text("status").notNull().default("pending_payment"),
  // pending_payment → paid → in_fulfillment → delivered → escrow_held → released | refunded | disputed
  createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});

// Append-only ledger; one row per state transition.
export const ledgerEntries = sqliteTable("ledger_entries", {
  id: text("id").primaryKey(),
  orderId: text("order_id").notNull().references(() => orders.id),
  vendorId: text("vendor_id").notNull().references(() => vendors.id),
  entryType: text("entry_type").notNull(), // hold|release|refund|adjustment|payout
  amountCents: integer("amount_cents").notNull(),     // positive for credit, negative for debit
  currency: text("currency").notNull().default("USD"),
  idempotencyKey: text("idempotency_key").notNull().unique(),
  stripeTransferId: text("stripe_transfer_id"),       // tr_xxx
  createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});

export const payouts = sqliteTable("payouts", {
  id: text("id").primaryKey(),
  vendorId: text("vendor_id").notNull().references(() => vendors.id),
  amountCents: integer("amount_cents").notNull(),
  currency: text("currency").notNull().default("USD"),
  status: text("status").notNull().default("pending"), // pending|paid|failed
  stripePayoutId: text("stripe_payout_id"),           // po_xxx
  failureReason: text("failure_reason"),
  attemptCount: integer("attempt_count").notNull().default(0),
  createdAt: integer("created_at").notNull().default(sql`(unixepoch())`),
});

The pattern (append-only ledger_entries, idempotency_key unique, every state transition recorded) is documented in the Drizzle ORM schema docs and the Cloudflare D1 prepared statements guide.

The single most important constraint is idempotency_key UNIQUE on ledger_entries. Stripe Connect delivers webhooks with at-least-once semantics; without the unique constraint, a retry creates a duplicate hold and the vendor's balance is wrong by exactly one order's amount. The 4-vendor build hit this on day 17 and the unique-constraint migration took 8 minutes.

Indexes that actually matter

The schema above looks fine without indexes at 4 vendors. At 50 vendors the dashboard query SELECT * FROM listings WHERE vendor_id = ? AND status = 'active' scans the full table on every page load. Three indexes ship with the Drizzle migration:

typescript
// src/db/migrations/0001_indexes.ts
import { sql } from "drizzle-orm";

export async function up(db: DrizzleDB) {
  // Listings: vendor dashboard + active-only listings page
  await db.run(sql`CREATE INDEX idx_listings_vendor_status ON listings(vendor_id, status)`);
  // Orders: per-vendor order history + per-buyer order history
  await db.run(sql`CREATE INDEX idx_orders_vendor_created ON orders(vendor_id, created_at DESC)`);
  await db.run(sql`CREATE INDEX idx_orders_buyer_created ON orders(buyer_user_id, created_at DESC)`);
  // Ledger: per-vendor balance query is the hot path
  await db.run(sql`CREATE INDEX idx_ledger_vendor_created ON ledger_entries(vendor_id, created_at DESC)`);
  await db.run(sql`CREATE INDEX idx_ledger_idempotency ON ledger_entries(idempotency_key)`);
}

The idx_ledger_idempotency index is unique by virtue of the UNIQUE constraint on the column; the explicit index speeds up the idempotency check the reconciliation worker runs on every state transition. The (vendor_id, created_at DESC) composite is the query the vendor dashboard runs on every page load. Without it, dashboard p95 latency grows linearly with listings count.

The pattern (composite indexes ordered by selectivity, descending on time columns) is documented in the Cloudflare D1 performance guide and the Drizzle ORM migration docs.

Buyer checkout: payment_intent with transfer_data

The reconciliation worker above uses stripe.transfers.create after the buyer's payment has succeeded. The alternative is a single payment_intent call with transfer_data.destination set to the vendor's connected account — Stripe routes the funds in one step. The pattern matters because the destination charge model has different fee economics than separate-charge-and-transfer.

typescript
// src/server/checkout.ts
import Stripe from "stripe";
import { db } from "~/db/client";
import { orders, listings, vendors } from "~/db/schema";
import { eq } from "drizzle-orm";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createCheckoutSession(orderId: string) {
  const order = await db.select().from(orders).where(eq(orders.id, orderId)).get();
  if (!order) throw new Error(`Order ${orderId} not found`);

  const vendor = await db.select().from(vendors).where(eq(vendors.id, order.vendorId)).get();
  if (!vendor?.stripeAccountId) throw new Error("Vendor has no Stripe account");

  const intent = await stripe.paymentIntents.create(
    {
      amount: order.amountCents,
      currency: order.currency.toLowerCase(),
      transfer_data: { destination: vendor.stripeAccountId },
      application_fee_amount: order.platformFeeCents,
      metadata: {
        order_id: order.id,
        vendor_id: order.vendorId,
      },
    },
    { idempotencyKey: `pi:${order.id}` },
  );

  await db
    .update(orders)
    .set({ paymentIntentId: intent.id })
    .where(eq(orders.id, order.id));

  return { clientSecret: intent.client_secret };
}

The destination-charge model is cleaner for marketplaces with a low dispute rate. The separate-charge-and-transfer model in the reconciliation worker above is required for marketplaces with a holdback period (escrow held by the platform) — transfer_data releases funds immediately on charge success, with no escrow window.

The 4-vendor build in this post uses destination charges for same-day goods (digital downloads) and the separate-charge-and-transfer model for physical goods with the 7-day escrow window. Both patterns are documented in the Stripe Connect destination charges guide and the Stripe Connect separate charges and transfers guide.

Refund and dispute handling

Two state transitions on orders need explicit handling: refunds (buyer-initiated, full or partial) and disputes (Stripe-initiated, when a buyer files a chargeback with their bank).

A full refund writes a refund entry to the ledger with proportional debit and credit amounts, then sets the order status to refunded. The reconciliation worker picks up the refund entry on the next pass and calls stripe.refunds.create with the same idempotency_key as the ledger row, so a retry is safe.

A dispute is triggered by a charge.dispute.created webhook from Stripe. The marketplace sets the order to disputed, pauses the payout window, and surfaces the dispute in the vendor admin portal. On resolution (charge.dispute.closed), if the dispute is lost, the marketplace writes a refund entry to the ledger and the vendor absorbs the chargeback; if won, the marketplace resumes the payout window. The 4-vendor build hit one dispute on day 38 (a buyer claimed non-receipt for a digital download); the vendor had tracking data in the admin portal and the dispute was won in 11 days.

Stripe Connect Express onboarding

Stripe Connect Express handles KYC, identity verification, and payout bank linking in a single hosted onboarding flow. The platform never touches the vendor's bank details — Stripe does. The onboarding link is created server-side and expires after a few minutes.

The onboarding flow

typescript
// src/server/onboarding.ts
import Stripe from "stripe";
import { db } from "~/db/client";
import { vendors } from "~/db/schema";
import { eq } from "drizzle-orm";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-06-20",
});

export async function createOnboardingLink(vendorId: string) {
  const vendor = await db
    .select()
    .from(vendors)
    .where(eq(vendors.id, vendorId))
    .get();
  if (!vendor) throw new Error(`Vendor ${vendorId} not found`);

  let stripeAccountId = vendor.stripeAccountId;
  if (!stripeAccountId) {
    const account = await stripe.accounts.create({
      type: "express",
      country: "US",
      capabilities: {
        card_payments: { requested: true },
        transfers: { requested: true },
      },
    });
    stripeAccountId = account.id;
    await db
      .update(vendors)
      .set({ stripeAccountId })
      .where(eq(vendors.id, vendorId));
  }

  const link = await stripe.accountLinks.create({
    account: stripeAccountId,
    refresh_url: `${process.env.APP_URL}/vendor/onboarding/refresh`,
    return_url: `${process.env.APP_URL}/vendor/onboarding/complete`,
    type: "account_onboarding",
  });

  return { url: link.url, expiresAt: link.expires_at };
}

The KYC status is updated by a webhook handler that listens for account.updated events from Stripe. The flow is documented in the Stripe Connect Express documentation and the Stripe Connect onboarding API reference.

On the 4-vendor build, all four vendors completed KYC in under 48 hours. The first vendor took 6 hours (the founder had to find a W-9); the other three took less than 30 minutes each. The only failure mode was a vendor with a recent address change that Stripe needed to reverify — handled by polling account.individual.verification.status every 6 hours.

Escrow ledger and payout reconciliation

The escrow ledger is an append-only table where every state transition is a row. The reconciliation worker runs every 24 hours and reconciles orders against ledger_entries and against Stripe's API.

The reconciliation worker

typescript
// src/workers/reconcile.ts
import { db } from "~/db/client";
import { orders, ledgerEntries, payouts, vendors } from "~/db/schema";
import { eq, and, sql } from "drizzle-orm";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function reconcileEscrow() {
  // 1. For each paid order in escrow, ensure a 'hold' ledger entry exists.
  const paidOrders = await db
    .select()
    .from(orders)
    .where(eq(orders.status, "escrow_held"))
    .all();

  for (const order of paidOrders) {
    const existing = await db
      .select()
      .from(ledgerEntries)
      .where(
        and(
          eq(ledgerEntries.orderId, order.id),
          eq(ledgerEntries.entryType, "hold"),
        ),
      )
      .get();
    if (!existing) {
      // Backfill the hold; this catches orders that hit the webhook during a deploy.
      await db.insert(ledgerEntries).values({
        id: crypto.randomUUID(),
        orderId: order.id,
        vendorId: order.vendorId,
        entryType: "hold",
        amountCents: order.vendorAmountCents,
        idempotencyKey: `hold:${order.id}`,
      });
    }
  }

  // 2. For each order whose payout window has elapsed, release the hold.
  const releasable = await db
    .select()
    .from(orders)
    .where(
      and(
        eq(orders.status, "escrow_held"),
        sql`created_at + (payout_schedule_days * 86400) <= unixepoch()`,
      ),
    )
    .all();

  for (const order of releasable) {
    const vendor = await db
      .select()
      .from(vendors)
      .where(eq(vendors.id, order.vendorId))
      .get();
    if (!vendor?.stripeAccountId) continue;

    // Create a Stripe transfer to the connected account.
    const transfer = await stripe.transfers.create(
      {
        amount: order.vendorAmountCents,
        currency: order.currency.toLowerCase(),
        destination: vendor.stripeAccountId,
        transfer_group: order.id,
      },
      { idempotencyKey: `transfer:${order.id}` },
    );

    // Record the release entry — idempotent on transfer.id via unique constraint.
    await db.insert(ledgerEntries).values({
      id: crypto.randomUUID(),
      orderId: order.id,
      vendorId: order.vendorId,
      entryType: "release",
      amountCents: -order.vendorAmountCents,
      idempotencyKey: `release:${transfer.id}`,
      stripeTransferId: transfer.id,
    });

    await db
      .update(orders)
      .set({ status: "released" })
      .where(eq(orders.id, order.id));
  }

  // 3. Reconcile against Stripe's API — catch any drift.
  const allTransfers = await stripe.transfers.list({ limit: 100 });
  for (const t of allTransfers.data) {
    const local = await db
      .select()
      .from(ledgerEntries)
      .where(eq(ledgerEntries.stripeTransferId, t.id))
      .get();
    if (!local) {
      console.warn(`Drift detected: Stripe transfer ${t.id} not in local ledger`);
    }
  }
}

The worker runs every 24 hours via a Cloudflare Cron Trigger and is idempotent on every operation. The drift-detection step is the only step that requires manual intervention; in 60 days the 4-vendor build hit drift exactly once, on day 41, when a Stripe webhook was delivered twice during a platform incident.

The reconciliation pattern (append-only ledger + idempotency keys + drift detection) is documented in Stripe Connect's reconciliation guide and in the Stripe API idempotency reference.

Payout timing

The payout window is per-vendor and configurable. The default is 7 days from order completion (delivery + dispute window). To set a vendor's payout window to 14 days, update payout_schedule_days:

typescript
await db
  .update(vendors)
  .set({ payoutScheduleDays: 14 })
  .where(eq(vendors.id, vendorId));

The reconciliation worker picks up the new window on the next run. The payout_schedule_days field is exposed in the vendor admin portal so non-technical operators can change it without a deploy.

Payout retries and failure modes

A payout can fail for three reasons: the vendor's bank account is closed, the bank rejected the transfer, or the connected account is restricted. The payouts table tracks attempt_count and failure_reason; the worker retries up to 3 times over 7 days before marking the payout as failed.

The 4-vendor build hit two failed payouts in 60 days — both for the same vendor whose bank had a 14-day settlement delay on new accounts. The retry worker caught the failure and succeeded on day 7. The vendor admin received an email notification on each retry; the platform admin received a daily digest of failed payouts.

For the broader boilerplate decision tree on Cloudflare-native SaaS patterns, see TanStack Ship pricing and the features overview.

Honest tradeoffs: when NOT to build a marketplace on a SaaS boilerplate

A starter kit is the wrong call in three marketplace scenarios:

When the marketplace requires regulated escrow

Healthcare, legal, and financial services often require escrow accounts held by a licensed custodian. TanStack Ship ships a software-level ledger; it does not move the funds into a regulated trust account. If your marketplace sells into a regulated buyer, you need a regulated escrow partner — Escrow.com, for example.

When vendor onboarding is the bottleneck, not the tech

Some marketplaces fail because vendors will not onboard. No boilerplate fixes that. The 4-vendor build in this post worked because the founder had pre-existing relationships with each vendor. If your growth strategy depends on cold-acquiring 100 vendors, the engineering is the easy part.

When GMV is sub-$10k/month

Stripe Connect's per-transaction fee (0.25% + $0.20) is fine above $50k GMV. Below $10k GMV, the platform fee (the spread between amountCents and vendorAmountCents) needs to be 15%+ to cover Stripe + Cloudflare + Resend + your time. That is a hard ceiling on marketplace economics, not a starter-kit issue.

For most other marketplace shapes (B2B resale, vertical-specific resale, professional services marketplace, niche community commerce), TanStack Ship is the right starting point. The schema in this post is the full schema; the workers are the full workers; the 60-day window is reproducible.

FAQ

Who holds the escrow funds?

Stripe holds the funds in the platform's Stripe balance until the reconciliation worker releases them via a transfer to the connected account. The platform never touches the funds in its own bank account. The ledger_entries table records every transition.

How long is the typical payout window?

7 days from order completion is the default. Some marketplaces run 3 days (high-trust communities); some run 14–21 days (high-chargeback-risk categories). The window is per-vendor via payout_schedule_days.

What happens on a chargeback?

Stripe issues a charge.dispute.created event; the marketplace flags the order as disputed and pauses the payout. On resolution, a refund or release entry is written to the ledger. The pattern is in Stripe Connect's dispute handling guide.

Does TanStack Ship ship the multi-vendor schema?

The auth, billing, multi-tenant scoping, and admin portal ship in the boilerplate. The vendors, listings, ledger_entries, and payouts tables above are marketplace-specific glue and are documented as add-ons.


Get started with TanStack Ship — the auth, billing, and multi-tenant scoping layer for a marketplace SaaS is one license, one-time fee, and 14 versioned Skills. For the broader boilerplate scorecard, see /compare and the best SaaS boilerplates in 2026 ranking.