Pro LessonLesson 04 of 12

Stripe Connect for
Platforms & Marketplaces
Split Payments in 15 Minutes

The complete guide to accepting payments on behalf of sellers, creators, and service providers — without reading 40 pages of Stripe docs.

~15 min readNext.js + Node.jsIntermediate

When you need Connect vs. regular Stripe

Regular Stripe is perfect when you are the seller. But the moment your app facilitates payments between other people, you need Stripe Connect.

If any of these sound like you, Connect is the answer:

Marketplace

Buyers pay sellers through your platform. You take a cut. Think Etsy, Fiverr, or Gumroad.

Platform

You provide tools and take a fee. Think Substack, Teachable, or Calendly.

Multi-vendor

Multiple sellers on one storefront. Think a food delivery app or booking platform.

The simple test: If money flows from Customer → Your App → Someone Else, you need Stripe Connect. If it's just Customer → You, regular Stripe is fine.

Account types: Standard, Express, Custom

Stripe Connect has three account types. Choosing the right one saves you weeks of work. Here's the honest breakdown:

Standard

Least work

Sellers create their own Stripe account and connect it to your platform. You have minimal control over the UX. Sellers see the Stripe dashboard directly. Best for B2B platforms where sellers already have Stripe.

Express

Recommended

Stripe hosts the onboarding and a lightweight dashboard for your sellers. You control the payment flow. Sellers get a simple payout experience. This is what 90% of indie hackers should use.

Custom

Most work

You build 100% of the onboarding UI and dashboard yourself. Full control, but massive engineering effort. Only worth it if you're building the next Shopify. Seriously, avoid this unless you have to.

We're using Express accounts for the rest of this lesson. If you picked Standard or Custom, the payment flow code is nearly identical — only the onboarding differs.

1

Install Stripe & enable Connect

First, install the Stripe SDK and set up your environment variables. Then enable Connect in your Stripe Dashboard.

terminalbash
npm install stripe
.env.localenv
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
lib/stripe.tstypescript
import Stripe from "stripe";

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

Enable Connect in your Dashboard

Go to Stripe Dashboard → Connect → Get started. Choose a platform name (your app name) and select “Express” as the default account type. This takes 30 seconds.

2

Onboard connected accounts (sellers/creators)

When a seller signs up on your platform, you create a connected account and redirect them to Stripe's hosted onboarding. Stripe handles identity verification, bank details, and compliance.

app/api/connect/create-account/route.tstypescript
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function POST(req: Request) {
  const { email } = await req.json();

  // 1. Create the Express connected account
  const account = await stripe.accounts.create({
    type: "express",
    email,
    capabilities: {
      card_payments: { requested: true },
      transfers: { requested: true },
    },
  });

  // 2. Create an onboarding link
  const accountLink = await stripe.accountLinks.create({
    account: account.id,
    refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/onboarding?refresh=true`,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/dashboard`,
    type: "account_onboarding",
  });

  // Save account.id to your database, linked to this seller
  // e.g., await db.seller.update({ where: { email }, data: { stripeAccountId: account.id } });

  return NextResponse.json({
    accountId: account.id,
    onboardingUrl: accountLink.url,
  });
}

On the frontend, call this endpoint and redirect the seller:

app/seller/onboarding/page.tsxtypescript
"use client";

import { useState } from "react";

export default function SellerOnboarding() {
  const [loading, setLoading] = useState(false);

  async function handleOnboard() {
    setLoading(true);
    const res = await fetch("/api/connect/create-account", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email: "seller@example.com" }), // from your auth
    });
    const { onboardingUrl } = await res.json();
    window.location.href = onboardingUrl;
  }

  return (
    <button onClick={handleOnboard} disabled={loading}>
      {loading ? "Setting up..." : "Set up payouts"}
    </button>
  );
}

The onboarding link expires after a few minutes. If the seller doesn't finish, they'll hit your refresh_url. When that happens, create a new account link (not a new account) using the same account.id and redirect again.

Handling the refresh URL

When a seller lands on your refresh URL, generate a new link and redirect:

app/api/connect/refresh-link/route.tstypescript
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function POST(req: Request) {
  const { accountId } = await req.json();

  const accountLink = await stripe.accountLinks.create({
    account: accountId,
    refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/onboarding?refresh=true`,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/dashboard`,
    type: "account_onboarding",
  });

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

Check if a seller is fully onboarded

Before you can route payments to a seller, their account needs to have charges enabled. Here's how to check:

app/api/connect/account-status/route.tstypescript
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const accountId = searchParams.get("accountId");

  if (!accountId) {
    return NextResponse.json({ error: "Missing accountId" }, { status: 400 });
  }

  const account = await stripe.accounts.retrieve(accountId);

  return NextResponse.json({
    chargesEnabled: account.charges_enabled,
    payoutsEnabled: account.payouts_enabled,
    detailsSubmitted: account.details_submitted,
    // If charges_enabled is true, this seller can receive payments
  });
}

Pro tip: Listen for the account.updated webhook event instead of polling. Stripe will notify you whenever a seller's account status changes. Store charges_enabled in your database and update it from the webhook.

4

Create payments with automatic splits

This is the core of Connect. When a buyer pays, you specify how much goes to the seller and how much you keep as a platform fee. Stripe calls your cut the application_fee_amount.

How the money flows: Customer pays $100 → Stripe takes ~$3.20 (2.9% + 30¢) → You keep $10 (your 10% platform fee) → Seller gets $86.80. All automatic. One API call.

app/api/connect/checkout/route.tstypescript
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function POST(req: Request) {
  const { productName, priceInCents, sellerStripeAccountId } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [
      {
        price_data: {
          currency: "usd",
          product_data: { name: productName },
          unit_amount: priceInCents,
        },
        quantity: 1,
      },
    ],
    payment_intent_data: {
      // This is the magic line — your platform fee
      application_fee_amount: Math.round(priceInCents * 0.1), // 10% platform fee
      transfer_data: {
        destination: sellerStripeAccountId, // acct_xxxxx
      },
    },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/purchase/cancel`,
  });

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

On the frontend, call this endpoint when a buyer clicks “Buy”:

components/BuyButton.tsxtypescript
"use client";

export function BuyButton({
  productName,
  priceInCents,
  sellerStripeAccountId,
}: {
  productName: string;
  priceInCents: number;
  sellerStripeAccountId: string;
}) {
  async function handleBuy() {
    const res = await fetch("/api/connect/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ productName, priceInCents, sellerStripeAccountId }),
    });
    const { checkoutUrl } = await res.json();
    window.location.href = checkoutUrl;
  }

  return (
    <button onClick={handleBuy}>
      Buy for ${(priceInCents / 100).toFixed(2)}
    </button>
  );
}

The application_fee_amount is in cents and must be less than the total charge amount. If you set it to 0, the seller gets everything (minus Stripe's processing fee). Stripe's fee always comes off the top — your platform fee comes from what's left.

5

Handle payouts to sellers

Good news: payouts happen automatically. When you use transfer_data.destination, Stripe automatically transfers the seller's share to their connected account. Stripe then pays out to their bank on the standard schedule (usually 2 business days).

You don't need to do anything. But here's how to check payout status and give sellers a dashboard link:

app/api/connect/seller-dashboard/route.tstypescript
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function POST(req: Request) {
  const { sellerStripeAccountId } = await req.json();

  // Create a login link to the Express dashboard
  // Sellers can view their balance, payouts, and tax info here
  const loginLink = await stripe.accounts.createLoginLink(
    sellerStripeAccountId
  );

  return NextResponse.json({ dashboardUrl: loginLink.url });
}

You can also check a seller's balance programmatically:

app/api/connect/seller-balance/route.tstypescript
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const accountId = searchParams.get("accountId")!;

  const balance = await stripe.balance.retrieve({
    stripeAccount: accountId,
  });

  return NextResponse.json({
    available: balance.available,  // funds ready to pay out
    pending: balance.pending,      // funds not yet available
  });
}

Custom payout schedules

By default, Stripe pays out on a rolling 2-day schedule. You can change this per-account when you create it by passing settings: { payouts: { schedule: { interval: "weekly", weekly_anchor: "monday" } } } to stripe.accounts.create(). Most marketplaces leave the default and let sellers see the Express dashboard.

6

Listen for Connect webhook events

Connect adds a few important events on top of regular Stripe webhooks. You need to handle these to keep your database in sync:

app/api/webhooks/stripe-connect/route.tstypescript
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";

const endpointSecret = process.env.STRIPE_CONNECT_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature")!;

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

  switch (event.type) {
    case "account.updated": {
      // A connected account's status changed
      const account = event.data.object;
      console.log(`Account ${account.id}: charges_enabled=${account.charges_enabled}`);
      // Update your database:
      // await db.seller.update({
      //   where: { stripeAccountId: account.id },
      //   data: { chargesEnabled: account.charges_enabled },
      // });
      break;
    }

    case "checkout.session.completed": {
      // A payment was completed through your platform
      const session = event.data.object;
      console.log(`Payment completed: ${session.id}`);
      // Fulfill the order, send confirmation email, etc.
      break;
    }

    case "transfer.created": {
      // Money was transferred to a connected account
      const transfer = event.data.object;
      console.log(`Transfer ${transfer.id}: ${transfer.amount} to ${transfer.destination}`);
      break;
    }

    case "payout.paid": {
      // A payout to a seller's bank account succeeded
      const payout = event.data.object;
      console.log(`Payout ${payout.id} paid: ${payout.amount}`);
      break;
    }

    case "payout.failed": {
      // A payout to a seller's bank failed
      const payout = event.data.object;
      console.log(`Payout ${payout.id} failed: ${payout.failure_message}`);
      // Notify the seller to update their bank details
      break;
    }

    default:
      console.log(`Unhandled event: ${event.type}`);
  }

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

Connect webhooks need their own endpoint and secret. In your Stripe Dashboard, go to Developers → Webhooks → Add endpoint. Choose “Listen to events on Connected accounts” and select the events above. You'll get a separate webhook signing secret — store it as STRIPE_CONNECT_WEBHOOK_SECRET.

7

Test the full flow

Stripe Connect is fully testable in test mode. Here's how to test the entire flow end-to-end:

1. Create a test connected account

Call your /api/connect/create-account endpoint. Click the onboarding link. Stripe's test mode uses pre-filled data — just click through the steps. Use phone number 000 000 0000 and SMS code 000000.

2. Make a test payment

Call your checkout endpoint with the test account ID. Use card 4242 4242 4242 4242 with any future expiry and any CVC. The payment will split automatically.

3. Verify the split in your Dashboard

Go to Stripe Dashboard → Payments. Click the payment. You'll see the total charge, the application fee (your cut), and the transfer to the connected account.

4. Test webhooks with the CLI

Forward Connect events to your local server:

terminalbash
# Install the Stripe CLI, then:
stripe listen --forward-connect-to localhost:3000/api/webhooks/stripe-connect

# In another terminal, trigger a test event:
stripe trigger checkout.session.completed --stripe-account=acct_xxxxx

5. Test failure scenarios

Use card 4000 0000 0000 0002 to simulate a declined payment. Use 4000 0000 0000 3220 to trigger 3D Secure authentication. Test with an account that hasn't completed onboarding to verify your error handling.

Common gotchas

“Your destination account needs to have at least one of the following capabilities enabled: transfers”

The connected account hasn't finished onboarding. Check account.charges_enabled before creating payments. Show a “Complete setup” prompt to sellers who haven't finished.

Application fee exceeds the amount

Your application_fee_amount is higher than the payment amount. Double-check your math — both values are in cents.

Onboarding link expired

Account links expire quickly (a few minutes). Never store them. Always generate a fresh one when the seller needs to onboard. That's what the refresh_url is for.

Webhook events not arriving for connected accounts

You need a separate webhook endpoint configured to listen on connected accounts, not just your own account. In the Stripe Dashboard, make sure you selected “Listen to events on Connected accounts” when creating the endpoint.

Can I use Connect with subscriptions?

Yes! Replace mode: "payment" with mode: "subscription" and add subscription_data: { application_fee_percent: 10 } instead of application_fee_amount. This takes a percentage of every recurring invoice.

Ship-it checklist

Before you go live with Connect:

What you just built

In about 15 minutes you set up a complete marketplace payment system:

  • Seller onboarding with Stripe Express (hosted KYC, bank setup, compliance)
  • Automatic payment splitting with platform fees
  • Automatic payouts to seller bank accounts
  • Seller dashboard access via Express login links
  • Connect webhooks for account updates and payout failures

This is the same infrastructure that powers marketplaces processing millions. The Stripe Connect docs are 40+ pages — you just got the 90% you need in 15 minutes. Ship it.

Next up: Handle Failed Payments & Dunning

Stop losing MRR to failed cards. Learn retry logic, grace periods, and automated recovery flows.

Unlock all lessons — $14.50/mo

Cancel anytime · Instant access to all 12 lessons