50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
paymentsMay 26, 2026·9 min read

Stripe Connect for Marketplaces: Splitting Payments Between Sellers

Stripe Connect sounds simple until you're debugging payout timing at midnight. Here's the real implementation guide for marketplace payment splitting.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Stripe Connect for Marketplaces: Splitting Payments Between Sellers

We've helped build a few marketplace-style apps over the years, and the question that always comes up around week three is some variation of: 'okay, but how does the money actually get to the sellers?' That's Stripe Connect territory, and while Stripe's docs are genuinely good, they don't always make clear which of the three Connect account types you should pick, or why your transfer is failing because of a compliance hold you didn't know existed.

This post is about building a real payment splitting flow with Stripe Connect — specifically the pattern where a buyer pays, your platform takes a cut, and the rest goes to the seller. We're using Express accounts because that's the right choice for 90% of marketplaces. We'll show you actual working code, not the kind that looks good in a blog post but explodes on the first edge case.

Connect Account Types: Pick Express, Ignore the Rest (For Now)

Stripe gives you three Connect account types: Standard, Express, and Custom. Standard means sellers log in with their existing Stripe account — low friction, but you give up a lot of control over the checkout experience. Custom means you build everything yourself, including KYC flows, which is a great way to spend three months building a compliance nightmare instead of a product. Express is the sweet spot: Stripe handles onboarding and identity verification, you handle the payment flow and branding.

  • Standard: Sellers connect existing Stripe accounts. Great if your sellers are already businesses. You get almost no UX control.
  • Express: Stripe hosts onboarding, you control the payment flow. The right call for most marketplaces.
  • Custom: You build everything. Do this only if you have a compliance team and a very specific reason.

We're going with Express. It gets sellers onboarded in minutes, handles the KYC/AML requirements Stripe needs to legally pay them out, and lets you focus on the actual marketplace logic.

Onboarding Sellers: The Account Link Flow

Before a seller can receive money, they need a connected account and they need to complete Stripe's onboarding. You create the account on your end, then redirect them to a Stripe-hosted onboarding page. Here's what that looks like in a Next.js Route Handler:

// app/api/stripe/connect/onboard/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'

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

export async function POST(req: NextRequest) {
  const session = await auth()
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const user = await db.query.users.findFirst({
    where: (u, { eq }) => eq(u.id, session.user.id),
  })

  // Create a connected account if one doesn't exist
  let stripeAccountId = user?.stripeAccountId

  if (!stripeAccountId) {
    const account = await stripe.accounts.create({
      type: 'express',
      email: session.user.email,
      capabilities: {
        card_payments: { requested: true },
        transfers: { requested: true },
      },
      business_type: 'individual', // or 'company' — you can let them choose
    })

    stripeAccountId = account.id

    await db.update(users)
      .set({ stripeAccountId })
      .where(eq(users.id, session.user.id))
  }

  // Generate the onboarding link
  const accountLink = await stripe.accountLinks.create({
    account: stripeAccountId,
    refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/onboarding?refresh=true`,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/onboarding/complete`,
    type: 'account_onboarding',
  })

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

The `refresh_url` matters more than people think. If the onboarding link expires (they take too long, close the tab, whatever), Stripe calls that URL and you need to generate a fresh link. Don't just redirect to your homepage — that's confusing. Send them back to a page that re-triggers the link generation automatically.

Store the `stripeAccountId` the moment you create it. If you regenerate it on every onboarding attempt, you'll end up with duplicate accounts for the same seller, and Stripe support tickets are not how you want to spend your Tuesday.

Checking Onboarding Status

When a seller comes back from onboarding, you can't just trust the return URL — they might have bailed halfway through. You need to check their account status via the API:

// lib/stripe.ts
export async function getSellerOnboardingStatus(stripeAccountId: string) {
  const account = await stripe.accounts.retrieve(stripeAccountId)

  return {
    chargesEnabled: account.charges_enabled,
    payoutsEnabled: account.payouts_enabled,
    detailsSubmitted: account.details_submitted,
    // This tells you what's still blocking them
    requirements: account.requirements?.currently_due ?? [],
    pendingRequirements: account.requirements?.pending_verification ?? [],
  }
}

// Usage in a Server Component or API route
const status = await getSellerOnboardingStatus(seller.stripeAccountId)

if (!status.chargesEnabled) {
  // Don't let them list items yet
  // Or show a banner: 'Complete your payout setup to start selling'
}

You want `charges_enabled` to be true before letting a seller accept payments. `details_submitted` just means they submitted the form — Stripe might still be reviewing it. Don't conflate the two. We made that mistake once and had sellers wondering why they weren't getting paid after 'completing' onboarding.

The Payment Split: Destination Charges vs. Separate Charges

This is where it gets interesting. Stripe Connect gives you two main ways to split payments: destination charges and separate charges with transfers. Destination charges are simpler and right for most use cases — the payment goes to your platform account and Stripe automatically routes the seller's portion to their connected account.

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

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

export async function POST(req: NextRequest) {
  const { listingId, sellerId } = await req.json()

  // Fetch listing and seller from your DB
  const listing = await db.query.listings.findFirst({
    where: (l, { eq }) => eq(l.id, listingId),
    with: { seller: true },
  })

  if (!listing || !listing.seller.stripeAccountId) {
    return NextResponse.json({ error: 'Seller not ready' }, { status: 400 })
  }

  const priceInCents = listing.priceInCents // e.g. 5000 = $50.00
  const platformFeePercent = 0.10 // 10% platform cut
  const platformFeeAmount = Math.round(priceInCents * platformFeePercent)
  const sellerAmount = priceInCents - platformFeeAmount

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [
      {
        price_data: {
          currency: 'usd',
          product_data: {
            name: listing.title,
            images: listing.imageUrl ? [listing.imageUrl] : [],
          },
          unit_amount: priceInCents,
        },
        quantity: 1,
      },
    ],
    payment_intent_data: {
      // This is the destination charge magic
      application_fee_amount: platformFeeAmount,
      transfer_data: {
        destination: listing.seller.stripeAccountId,
      },
    },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/orders/{CHECKOUT_SESSION_ID}/success`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/listings/${listingId}`,
    metadata: {
      listingId,
      sellerId: listing.seller.id,
    },
  })

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

The key fields here are `application_fee_amount` (your platform's cut, in cents) and `transfer_data.destination` (the seller's connected account ID). Stripe handles moving the money — you just declare the fee. The platform fee gets deposited to your Stripe balance and the remainder goes to the seller's connected account automatically when the payment clears.

Do all your fee math in cents and use `Math.round()`. Never do floating point arithmetic with money. Yes, we learned this the hard way. No, it wasn't fun to reconcile.

Handling Webhooks: The Part Everyone Underestimates

The checkout session completing is not when you should fulfill orders. The webhook is. Network blips happen, users close tabs, browsers crash. The webhook is your source of truth. Here's a minimal but complete webhook handler for marketplace payments:

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

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

export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = req.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session

      // Only fulfill if payment actually succeeded
      if (session.payment_status === 'paid') {
        await fulfillOrder(session)
      }
      break
    }

    case 'checkout.session.async_payment_succeeded': {
      // For payment methods like bank transfers that aren't instant
      const session = event.data.object as Stripe.Checkout.Session
      await fulfillOrder(session)
      break
    }

    case 'account.updated': {
      // A connected seller account updated their details
      const account = event.data.object as Stripe.Account
      await syncSellerAccountStatus(account)
      break
    }
  }

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

async function fulfillOrder(session: Stripe.Checkout.Session) {
  const { listingId, sellerId } = session.metadata ?? {}
  if (!listingId || !sellerId) return

  await db.insert(orders).values({
    listingId,
    sellerId,
    buyerEmail: session.customer_details?.email ?? '',
    stripeSessionId: session.id,
    amountTotal: session.amount_total ?? 0,
    status: 'paid',
  })

  // Send confirmation emails, notify seller, etc.
}

async function syncSellerAccountStatus(account: Stripe.Account) {
  await db.update(users)
    .set({
      stripeChargesEnabled: account.charges_enabled,
      stripePayoutsEnabled: account.payouts_enabled,
    })
    .where(eq(users.stripeAccountId, account.id))
}

The `account.updated` webhook is something a lot of people miss. When a seller completes onboarding or Stripe verifies their identity, they fire this event. If you're caching their account status in your database (which you should be, for performance), you need to listen for this and sync it. Otherwise you have sellers who've completed onboarding but still see a 'finish setup' banner in your UI.

Refunds on Marketplace Payments

Refunds on destination charges work differently than regular refunds. When you refund, Stripe pulls the money back from the seller's connected account and your platform fee is also reversed. If the seller has already been paid out, Stripe will debit their connected account balance, which can go negative. You need to think about your refund policy and who eats the cost.

// Refunding a marketplace payment
async function refundOrder(paymentIntentId: string, refundReason?: string) {
  // This automatically reverses the transfer and fee
  const refund = await stripe.refunds.create({
    payment_intent: paymentIntentId,
    reason: (refundReason as Stripe.RefundCreateParams.Reason) ?? 'requested_by_customer',
    // Set this to false if you want to keep your platform fee
    // and only refund the seller's portion
    refund_application_fee: true,
    // Set to true to pull money back from the seller's balance
    reverse_transfer: true,
  })

  return refund
}

The `reverse_transfer: true` flag is what actually pulls the money back from the seller's connected account. If you set it to false, the refund comes entirely out of your platform's balance. Think carefully about which behavior you want — and make sure your terms of service are clear about it.

Things That Will Bite You

  • Connected accounts in restricted countries can't receive transfers. Check Stripe's country availability before telling sellers in X country that they can sign up.
  • Your platform fee can't exceed the payment amount. Sounds obvious, but if you're doing percentage-based fees on very small transactions, double-check your math.
  • Stripe's Express dashboard is separate from your platform. Sellers will have their own Stripe Express dashboard at express.stripe.com — link to it so they know where to go for payout history.
  • Test mode and live mode have separate webhook endpoints and separate connected accounts. Keep these straight in your environment variables or you'll be very confused about why your test sellers aren't appearing.
  • Instant payouts are a separate Stripe feature and cost extra. Standard payouts take 2-7 business days. Set expectations with your sellers.

One more thing: when testing Connect locally with the Stripe CLI, you need to listen for both platform webhooks and connected account webhooks. The `account.updated` events come in as connected account events, not platform events. Use `stripe listen --forward-to localhost:3000/api/webhooks/stripe` and make sure you're verifying with the right webhook secret.

The Dashboard: Give Sellers Visibility

One thing that kills seller trust fast is not being able to see their earnings. Stripe makes it easy to give them a link to their Express dashboard — you don't have to build this yourself:

// Generate a link to the seller's Stripe Express dashboard
export async function getSellerDashboardLink(stripeAccountId: string) {
  const loginLink = await stripe.accounts.createLoginLink(stripeAccountId)
  return loginLink.url
}

// Use in a Server Action or Route Handler
// The URL is single-use and expires after a few minutes
// Generate it fresh on each request, don't cache it

This gives sellers access to their payout history, bank account settings, and tax documents — all hosted by Stripe. It's one of the actually good things about the Express account type: you get a proper seller dashboard for free.

If you're building a marketplace and want a solid foundation to work from, our templates at peal.dev include Stripe Connect wiring with the webhook handlers, seller onboarding flow, and platform fee logic already set up — saves you the part where you spend a weekend debugging why transfers aren't showing up in the connected account.

The Short Version

Stripe Connect for marketplaces isn't as complex as it looks once you understand the model: sellers get Express accounts, buyers pay through your platform, you declare your fee in `application_fee_amount`, and Stripe moves the money. The hard parts are edge cases — sellers who haven't finished onboarding, refund policies, webhook reliability, and keeping your database in sync with Stripe's account status events.

The most important thing: don't fulfill orders from the success URL redirect. Only fulfill from webhooks. Build your payment flow assuming users will close the browser at every possible moment.

Get the onboarding flow right, handle the `account.updated` webhook, do your math in cents, and you'll have a solid marketplace payment split working in a day or two. The compliance and edge case handling is what takes the rest of the week — but at least you know what you're getting into.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders