Stripe Connect is the standard for marketplace and platform payouts — if your SaaS lets one user pay another (think: a service marketplace, a creator platform, a multi-vendor store), Connect is what lets you take a cut, route the rest to the seller, and stay on the right side of money-transmission law. This tutorial walks the eight steps that get you from zero to a working marketplace flow, drawing on stripe.com/docs/connect and stripe.com/connect for the canonical patterns, plus the official Stripe Connect API reference at stripe.com/docs/api/accounts. The code below is what works in production for a Next.js + Supabase Postgres stack.

1 Decide on Standard vs Express vs Custom

Connect ships three account types. Picking the wrong one is the most expensive mistake on this tutorial — you can’t cleanly migrate accounts between types after onboarding.

Account typeOnboarding UXCompliance burdenBest for
StandardSellers create their own Stripe account, log in to dashboard.stripe.comStripe handles everythingMarketplaces where sellers are sophisticated and you want zero compliance work
ExpressWhite-labeled Stripe-hosted onboarding flow + light Express dashboardStripe handles KYC, you handle UI shellMost marketplace SaaS — the right default
CustomYou build the entire onboarding UIYou collect KYC fields, route to Stripe APIEmbedded financial products, payouts disguised as part of your app

The honest recommendation: start with Express unless you have a specific reason not to. Standard pushes sellers into Stripe’s full dashboard, which feels disconnected from your brand. Custom is a six-month project to build properly — KYC field collection, document upload, ongoing capability checks. Express gives you the right balance: Stripe handles the legal-grade onboarding flow, you keep the user inside your app.

2 Set up your Connect platform

In the Stripe dashboard, go to Settings → Connect settings. You’ll need to:

  • Accept the Connect platform terms. One-time legal acknowledgment that you’re running a platform, not just receiving payments yourself.
  • Set platform branding. Logo, primary color, business name. These appear on Express onboarding screens and the Express dashboard.
  • Configure your platform’s public name and support contact. Required — sellers see this when they receive emails about disputes or payouts.
  • Pick the countries you’ll support. Each enabled country adds compliance scope. Start with your home market.

Save your Connect platform-level keys. They’re different from your regular Stripe API keys — same secret key namespace, but Connect-specific calls require the Stripe-Account header for some operations. Your env vars look like this:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_CONNECT_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_PLATFORM_FEE_BPS=500   # 5.00% application fee, in basis points

3 Database schema for connected accounts

Mirror Stripe’s connected-account state in your own database so app reads don’t hit the Stripe API. Minimum viable schema:

create table public.connected_accounts (
  user_id uuid primary key references auth.users(id) on delete cascade,
  stripe_account_id text unique not null,
  status text not null default 'pending',   -- pending, active, restricted, rejected
  capabilities jsonb default '{}'::jsonb,    -- {card_payments: 'active', transfers: 'active'}
  charges_enabled boolean default false,
  payouts_enabled boolean default false,
  details_submitted boolean default false,
  country text,
  default_currency text,
  requirements jsonb,                        -- pending requirements from Stripe
  disabled_reason text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

create index on public.connected_accounts (status);
create index on public.connected_accounts (stripe_account_id);

alter table public.connected_accounts enable row level security;

create policy "select_own_account"
on public.connected_accounts for select to authenticated
using (user_id = auth.uid());

The three booleans — charges_enabled, payouts_enabled, details_submitted — are what your app reads to decide whether a seller is “ready to sell.” Don’t roll your own status field; mirror Stripe’s.

4 Onboarding flow with Account Links

The pattern: when a user clicks “Become a seller,” you create a Stripe Express account on their behalf, then generate a one-time Account Link URL and redirect them to it. After they finish onboarding, Stripe redirects them back to your app.

// src/app/api/connect/onboard/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@/lib/supabase/server';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
  typescript: true
});

export async function POST(req: Request) {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }

  // Look up an existing connected account, or create one
  let { data: existing } = await supabase
    .from('connected_accounts')
    .select('stripe_account_id')
    .eq('user_id', user.id)
    .maybeSingle();

  let stripeAccountId = existing?.stripe_account_id;

  if (!stripeAccountId) {
    const account = await stripe.accounts.create({
      type: 'express',
      email: user.email!,
      capabilities: {
        card_payments: { requested: true },
        transfers: { requested: true }
      },
      business_type: 'individual',
      metadata: { supabase_user_id: user.id }
    });
    stripeAccountId = account.id;

    await supabase.from('connected_accounts').insert({
      user_id: user.id,
      stripe_account_id: stripeAccountId,
      status: 'pending'
    });
  }

  // Account Links are single-use, expire in ~5 minutes
  const accountLink = await stripe.accountLinks.create({
    account: stripeAccountId,
    refresh_url: `${process.env.NEXT_PUBLIC_SITE_URL}/seller/onboard?refresh=true`,
    return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/seller/onboard?return=true`,
    type: 'account_onboarding'
  });

  return NextResponse.json({ url: accountLink.url });
}

Two URLs to know about: refresh_url is hit when an Account Link expires before the user finishes; return_url is hit when they complete (or quit). Neither return means the account is “done” — that signal comes from the webhook in step 5. Don’t flip your seller to “active” based on the return URL alone.

KYC requirements for sellers

Stripe Express handles all KYC field collection — you don’t prompt for SSNs, business EINs, or document uploads yourself. What Stripe collects depends on the seller’s country and business type, but typically includes: legal name, date of birth, address, last 4 of SSN (or full SSN above transaction thresholds), bank account, and ID document scans. For business-type accounts, add EIN, articles of incorporation, and beneficial owner info. Sellers under certain thresholds can transact before submitting the full document set; Stripe surfaces this via requirements.currently_due and requirements.eventually_due — mirror both into your requirements jsonb column.

5 Handle onboarding webhook events

The webhook is how you learn whether an account is actually ready to sell. The two events you must handle:

  • account.updated — fired whenever the account’s capabilities, requirements, or charges/payouts state changes. Hit this on every onboarding step.
  • account.application.deauthorized — fired when a seller revokes your platform’s access. You must mark them inactive and stop routing payments.
// src/app/api/connect/webhook/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia'
});

const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } }
);

export const runtime = 'nodejs';   // signature lib needs Node, not Edge

export async function POST(req: Request) {
  const sig = headers().get('stripe-signature');
  const body = await req.text();   // RAW body, not json

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig!,
      process.env.STRIPE_CONNECT_WEBHOOK_SECRET!
    );
  } catch (err: any) {
    return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
  }

  switch (event.type) {
    case 'account.updated': {
      const account = event.data.object as Stripe.Account;
      const status =
        account.charges_enabled && account.payouts_enabled
          ? 'active'
          : account.requirements?.disabled_reason
          ? 'restricted'
          : 'pending';

      await admin.from('connected_accounts').update({
        status,
        capabilities: account.capabilities ?? {},
        charges_enabled: account.charges_enabled ?? false,
        payouts_enabled: account.payouts_enabled ?? false,
        details_submitted: account.details_submitted ?? false,
        country: account.country,
        default_currency: account.default_currency,
        requirements: account.requirements,
        disabled_reason: account.requirements?.disabled_reason ?? null,
        updated_at: new Date().toISOString()
      }).eq('stripe_account_id', account.id);
      break;
    }
    case 'account.application.deauthorized': {
      const account = event.data.object as Stripe.Account;
      await admin.from('connected_accounts').update({
        status: 'rejected',
        charges_enabled: false,
        payouts_enabled: false,
        updated_at: new Date().toISOString()
      }).eq('stripe_account_id', account.id);
      break;
    }
    default:
      // ignore other events
  }

  return NextResponse.json({ received: true });
}

For deeper background on signature verification and idempotency, see what is a webhook and webhook security best practices.

6 Create a checkout that splits payment with an application fee

Now the actual money flow. When a buyer pays, you want the funds to land in the seller’s connected account, with your platform’s cut routed to your platform balance. Stripe calls this a destination charge with an application_fee_amount.

// src/app/api/checkout/marketplace/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@/lib/supabase/server';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia'
});

export async function POST(req: Request) {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { listingId } = await req.json();

  // Look up the listing and its seller
  const { data: listing } = await supabase
    .from('listings')
    .select('id, price_cents, seller_id, title')
    .eq('id', listingId)
    .single();

  if (!listing) {
    return NextResponse.json({ error: 'not found' }, { status: 404 });
  }

  // Look up the seller's connected account; must be active
  const { data: seller } = await supabase
    .from('connected_accounts')
    .select('stripe_account_id, charges_enabled')
    .eq('user_id', listing.seller_id)
    .single();

  if (!seller || !seller.charges_enabled) {
    return NextResponse.json(
      { error: 'seller cannot accept payments yet' },
      { status: 400 }
    );
  }

  // Compute the platform fee from basis points (e.g. 500 bps = 5.00%)
  const feeBps = Number(process.env.NEXT_PUBLIC_PLATFORM_FEE_BPS ?? 500);
  const applicationFeeAmount = Math.floor(
    (listing.price_cents * feeBps) / 10_000
  );

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: { name: listing.title },
        unit_amount: listing.price_cents
      },
      quantity: 1
    }],
    payment_intent_data: {
      application_fee_amount: applicationFeeAmount,
      transfer_data: {
        destination: seller.stripe_account_id
      },
      metadata: {
        listing_id: listing.id,
        buyer_id: user.id,
        seller_id: listing.seller_id
      }
    },
    success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/orders/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/listings/${listing.id}`
  });

  return NextResponse.json({ url: session.url });
}

The two key fields are application_fee_amount (your cut, in cents) and transfer_data.destination (the connected account that gets the rest). Stripe handles the rest: charges land on the buyer’s card via your platform, the seller’s account is credited with price - application_fee - stripe_fee, and your platform is credited with the application fee. For a primer on the underlying flow see what is Stripe Checkout; if you also need recurring billing on top of marketplace charges, the subscriptions tutorial covers that side.

Tax handling for marketplaces

Tax is the boring trap of marketplace SaaS. Two options:

  • Use Stripe Tax. Set automatic_tax: { enabled: true } on the checkout session. Stripe calculates and collects the right sales tax / VAT based on the buyer’s address, and remits it under the seller’s tax registration. Cleanest path; small per-transaction fee.
  • Sellers handle their own tax. Each connected account is responsible for tax compliance in their jurisdiction. You expose tax fields in your seller dashboard and add the calculated tax to the line_items total. More work for you and the seller.

For US-only marketplaces under the marketplace facilitator threshold, the second option is usually fine. For multi-country marketplaces, Stripe Tax saves a year of compliance work.

Payout timing and platform float

By default, Express accounts receive payouts on a 2-day rolling schedule (US). The buyer’s card is charged immediately, but the seller doesn’t see funds in their bank until ~T+2. During those two days, the funds sit in Stripe’s system — not in your bank account, not in the seller’s. This is normal and good (it’s how Stripe handles disputes), but it does mean your platform isn’t holding seller money — which keeps you out of money-transmitter territory.

You can adjust payout schedules per connected account (stripe.accounts.update(id, { settings: { payouts: { schedule: { interval: 'weekly' } } } })) or pause payouts entirely while a dispute resolves.

7 Refunds and disputes — who pays what

The two most-asked questions in any Connect support thread.

Refunds

By default, when you refund a destination charge, the buyer gets their money back from the seller’s connected account. Your application fee stays on your platform balance — the seller absorbs the loss. To refund the application fee too (so the seller is made whole), pass refund_application_fee: true:

// Refund a payment, returning your platform fee to the seller
await stripe.refunds.create({
  payment_intent: paymentIntentId,
  refund_application_fee: true,         // platform absorbs the fee loss
  reverse_transfer: true                // pull funds back from connected account first
});

Most marketplaces refund the application fee on legitimate cancellations (the seller didn’t do anything wrong) and keep it on policy violations (the seller misbehaved). Document the policy clearly — sellers will ask.

Disputes

Disputes are the more painful case. With destination charges, dispute liability falls on your platform by default, not the seller — the buyer’s card issuer charged your platform’s merchant account, so your platform is on the hook for the chargeback amount plus a $15–$25 dispute fee. To shift liability to the seller, you have two options:

  • Use direct charges instead of destination charges. Direct charges are made on behalf of the connected account using Stripe-Account headers. Liability sits with the seller. Worse buyer UX (charge appears under seller’s name on the statement) but cleaner liability.
  • Stay on destination charges and contractually require sellers to indemnify you. You eat the dispute fee up front, then deduct it from the seller’s next payout. Standard for service marketplaces.

Stripe’s dispute documentation at stripe.com/docs/disputes covers the evidence-submission flow. Most disputes are won or lost on whether you can produce delivery confirmation, signed terms of service, and a clean audit trail of the transaction.

8 Test end-to-end with Stripe CLI

The Stripe CLI forwards Connect webhook events to your local dev server — the only sane way to debug onboarding state changes.

# install once
brew install stripe/stripe-cli/stripe

# log in to your platform's test mode
stripe login

# forward Connect events to your local webhook handler
stripe listen \
  --forward-connect-to localhost:3000/api/connect/webhook

The first run prints a Connect-specific webhook signing secret like whsec_abc.... Set that as STRIPE_CONNECT_WEBHOOK_SECRET in your .env.local. Then trigger the lifecycle from another terminal:

stripe trigger account.updated
stripe trigger account.application.deauthorized
stripe trigger payment_intent.succeeded

For a full end-to-end test: create a fake seller account through your real onboarding flow using Stripe’s test KYC values (SSN 000-00-0000, DOB 1901-01-01, US bank routing 110000000, account 000123456789). After completing the Express flow, the seller’s row should flip to status = 'active' with charges_enabled: true. Then create a buyer, call your marketplace checkout route with test card 4242 4242 4242 4242, and verify three things: the buyer pays, the seller’s connected balance increases by price - app_fee - stripe_fee, and your platform balance increases by app_fee.

For production cutover, swap to live mode keys, register the production webhook URL under Developers → Webhooks → Connect endpoint, and verify the Connect platform terms are accepted on your live account separately from your test account. Then go ship. If you’re weighing Stripe against alternatives before committing to the full Connect build, see Lemon Squeezy vs Stripe.

Summary
Account type → platform setup → schema → Account Links → webhooks → destination charges → refund/dispute policy → CLI test

Stripe Connect for marketplace SaaS is mostly about choosing Express, mirroring connected-account state in your DB via webhooks, and using destination charges with application_fee_amount to take your cut. The legal-grade KYC and tax pieces are handled by Stripe; you handle policy decisions on refunds and disputes.

Related guides

Get one SaaS build breakdown every week

The stack, prompts, pricing, and mistakes to avoid — for solo founders building with AI.