Stripe Connect Tutorial for Indie SaaS | Coding Capybaras
Stripe Connect tutorial for indie SaaS: pick a charge type, onboard sellers with Express accounts, collect application fees, and survive refunds and disputes.
· Justin Boggs

Photo by Annie Spratt on Unsplash
To add Stripe Connect to an indie SaaS, you do four things: pick a charge type (destination charges, for almost every indie marketplace), onboard your sellers as Express accounts through Stripe-hosted onboarding, attach an application_fee_amount to each payment so your cut lands in your platform balance, and wire up the webhooks that tell you when accounts and payments change state. None of it requires payments expertise — but two of the defaults (refund behavior and dispute liability) will surprise you if nobody warns you. This tutorial walks the whole path, with the exact AI prompt to wire it into a Next.js app at the end.
TL;DR
- Stripe Connect routes one payment between multiple parties — your customer pays, your seller gets their share, your platform keeps a fee.
- Use destination charges + Express accounts as an indie founder. Direct charges and separate charges/transfers exist for cases you probably don't have yet.
- Your fee is the
application_fee_amounton the PaymentIntent. Stripe's processing fee comes out of your side, so price your take rate with that in mind.- Refunds and disputes on destination charges debit your platform balance, not the seller's. Plan for it.
What is Stripe Connect (and do you actually need it)?
Stripe Connect is Stripe's infrastructure for multi-party payments: it lets your platform accept a payment from a customer and split it between your seller and yourself, with Stripe handling the seller's identity verification, bank account, and payouts. If your SaaS only charges customers for your own product, you don't need Connect — plain Stripe payments do that, and I covered it end to end in the Next.js Stripe tutorial.
You need Connect the moment money passes through you to someone else. The classic indie cases:
- A marketplace where creators sell templates, courses, or services and you take a percentage
- A booking product where providers get paid and you keep a fee per transaction
- A SaaS that lets your users charge their customers (invoicing tools, storefront builders)
The reason you can't just "collect the money and PayPal them their share" is regulatory, not technical. Moving other people's money makes you a money transmitter in most jurisdictions, with licensing requirements you do not want. Connect exists so Stripe carries that burden: sellers onboard as connected accounts under your platform, Stripe runs Know Your Customer (KYC) checks on them, and the money routes through Stripe's rails rather than your bank account.
One honest caveat before you commit: Connect is Stripe-only territory. If you picked Lemon Squeezy or Paddle after reading my payments comparison, those are merchant-of-record products for selling your own stuff — neither offers a real marketplace equivalent. A marketplace business model is effectively a decision to build on Stripe.
Which Connect charge type should you use?
Connect offers three ways to structure a payment, and Stripe's charge-type documentation is the canonical reference. The short version:
| | Direct charges | Destination charges | Separate charges & transfers | | --- | --- | --- | --- | | Payment lands in | Seller's account | Your platform account | Your platform account | | Best for | SaaS where sellers have their own storefronts (Shopify-style) | Marketplaces — customer sees your brand | Splitting one payment across multiple sellers (DoorDash-style) | | Refunds/disputes hit | The seller's balance | Your platform balance | Your platform balance | | Statement descriptor | Seller's name | Your platform's name | Your platform's name | | Complexity | Medium | Low | High |
For an indie marketplace, destination charges are the right default, and it's the type this tutorial uses. The customer transacts with your brand, you create the charge on your platform account, and Stripe immediately transfers the seller's share to their connected account. Per Stripe's destination charge docs, this is the model built for the Airbnb/Lyft shape of business — one customer, one seller, platform in the middle.
Skip direct charges unless your sellers are the brand and you're invisible. Skip separate charges and transfers unless you genuinely need one payment split across multiple sellers, or you need to hold funds before deciding who gets them — it's the closest thing Connect has to an escrow-style flow, and Stripe itself recommends using it only when your use case requires it, because you become responsible for tracking balances that the simpler types track for you.
Here's the funds flow you're building:
sequenceDiagram
participant C as Customer
participant P as Your platform (Stripe)
participant S as Seller (connected account)
C->>P: Pays $50 via Checkout
P->>S: $45 transferred automatically
Note over P: $5 application fee stays<br/>in platform balance
Note over P: Stripe processing fee<br/>debited from platform
S->>S: Payout to seller's bank<br/>(Stripe handles schedule)
Onboarding sellers with Express accounts
Connected accounts come in flavors (Stripe now frames these as controller properties, but the classic names — Standard, Express, Custom — still map to the API). Express is the indie sweet spot: Stripe hosts the onboarding form, runs KYC, gives sellers a lightweight dashboard for payouts, and you write almost no identity-handling code.
Creating a seller takes two API calls. First the account:
// /product/lib/connect/accounts.ts
const account = await stripe.accounts.create({
type: "express",
email: seller.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
});
// Persist account.id on your seller record — you'll need it for every charge
Then an Account Link, which is a single-use URL that sends the seller into Stripe-hosted onboarding:
const link = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${baseUrl}/sellers/onboarding/refresh`,
return_url: `${baseUrl}/sellers/onboarding/complete`,
type: "account_onboarding",
});
// redirect the seller to link.url
Three gotchas from the docs that are easy to miss and painful to learn live:
Account Links are single-use and expire. Never email one; generate it on demand for the authenticated seller and redirect immediately. The refresh_url exists because links die — your refresh route should just mint a new link and redirect again.
The return_url does not mean onboarding succeeded. Stripe redirects there when the seller exits the flow properly, which includes exiting with requirements still outstanding. The actual source of truth is the account object: check details_submitted and charges_enabled, and subscribe to the account.updated webhook to track when a seller becomes chargeable. Gate their "start selling" button on that, not on the redirect.
Prefill what you already know. Anything you pass at account creation (email, business type, URL) is one less field the seller types into Stripe's form. Onboarding drop-off is real; every prefilled field helps.
Collecting your marketplace fee
This is the part you're here for. With destination charges, your cut is the application_fee_amount — an amount in cents that stays in your platform balance while the rest transfers to the seller. Using Stripe Checkout, the whole thing is a few lines on top of a normal session:
// /product/lib/connect/checkout.ts
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "usd",
product_data: { name: listing.title },
unit_amount: 5000, // $50.00
},
quantity: 1,
},
],
payment_intent_data: {
application_fee_amount: 500, // your $5.00 (10%)
transfer_data: {
destination: seller.stripeAccountId,
},
},
success_url: `${baseUrl}/purchases/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/listings/${listing.id}`,
});
When the payment settles, $45 lands in the seller's connected account and $5 stays with you. Simple — until you remember that with destination charges, Stripe's fees come out of your side, not the seller's.
Run the real math on that $50 sale with a 10% take rate, using Stripe's standard US card rate (2.9% + 30¢ at the time of writing) and Connect's published pricing for Express accounts ($2 per monthly active account, plus 0.25% + 25¢ per payout):
| Line item | Amount | | --- | --- | | Customer pays | $50.00 | | Transferred to seller | $45.00 | | Your application fee | +$5.00 | | Card processing (2.9% + 30¢ on $50) | −$1.75 | | Payout fee (0.25% + 25¢ on $45) | −$0.36 | | Your margin on this sale | ≈ $2.89 | | Monthly active-account fee (per seller, amortized) | −$2.00/mo |
Two pricing lessons fall straight out of that table. First, a 5% take rate on small transactions can leave you underwater — processing fees are mostly fixed-plus-percentage, so low prices and low take rates don't mix. Second, the $2/month active-account fee means a long tail of sellers who each make one $10 sale a month costs you money. Most indie marketplaces land at a 10–20% take rate for exactly these reasons; if that feels high, remember you're covering processing, payout fees, refund risk, and dispute liability. The unit-economics thinking here is the same MRR-and-margins math from subscription billing math, just applied per transaction.
You can also set transfer_data[amount] instead of application_fee_amount — same outcome, inverted expression (you specify what the seller gets rather than what you keep). Pick one and stay consistent; mixing them across a codebase is a great way to write a fee bug.
Refunds, disputes, and the gotchas nobody mentions
Here's the section I wish someone had made me read before I touched Connect.
Refunds debit your platform balance. On a destination charge, when you refund the customer their $50, Stripe takes it from you — the seller's $45 doesn't automatically come back. To recover it you must also reverse the transfer (reversals.create on the transfer, or pass refund_application_fee and reverse_transfer when creating the refund). If you refund without reversing, you just personally funded your seller's payday. Decide your policy now — most marketplaces reverse the transfer and refund the application fee together — and encode it in one helper function so every refund path behaves the same way.
Disputes debit you too, plus the dispute fee. When a customer charges back a destination-charge payment, Stripe pulls the disputed amount and the dispute fee from your platform balance automatically. You can reverse the seller's transfer to recover funds — but if they've already paid out and their balance is empty, that reversal can fail or go negative. This is the actual risk business you're in as a marketplace: you are the underwriter of your sellers' behavior. Hold that in mind when you decide payout timing (Stripe lets you delay payout schedules on connected accounts — a few days of buffer is cheap insurance).
Webhooks are load-bearing. At minimum, handle account.updated (seller onboarding state), checkout.session.completed / payment_intent.succeeded (fulfillment), charge.refunded, and charge.dispute.created. Every one of them must verify Stripe's signature before processing — the same non-negotiable I hammered on in Stripe webhook hell, which catalogs the idempotency and replay gotchas that apply doubly when money is being split three ways.
Test mode has a full Connect story. Stripe provides test connected accounts and magic onboarding values, so you can rehearse the entire flow — onboarding, charge, refund, transfer reversal, dispute — without a real bank account. Do the dispute rehearsal especially; the first time your balance goes negative should be in test mode.
The AI prompt to wire it in
If you're building on a Next.js + Supabase + Stripe stack, here's the prompt I'd paste into Claude Code or Cursor. It assumes the Coding Capybaras convention that the Stripe SDK lives behind a single payments module — adjust paths to your project:
Add Stripe Connect marketplace payments to this app using destination
charges and Express connected accounts.
1. Seller onboarding:
- A server action that creates an Express account
(capabilities: card_payments + transfers), stores the account id
on the seller's row, then creates an account_onboarding Account
Link and redirects to it.
- A /sellers/onboarding/refresh route that mints a fresh Account
Link (they're single-use) and redirects.
- Handle the account.updated webhook: persist details_submitted
and charges_enabled; only sellers with charges_enabled can list.
2. Checkout:
- A server action that creates a Checkout Session in payment mode
with payment_intent_data.application_fee_amount = 10% of the
price and transfer_data.destination = the seller's account id.
- Validate the listing and price server-side with Zod before
creating the session. Never trust a price from the client.
3. Refunds:
- One helper that refunds the charge, reverses the transfer, and
refunds the application fee together, so no refund path can
forget the reversal.
4. Webhooks:
- Extend the existing Stripe webhook handler (signature
verification stays mandatory) with account.updated,
charge.refunded, and charge.dispute.created.
Use the existing payments module for all Stripe calls — do not import
the Stripe SDK anywhere new.
That last line matters more than it looks. Keeping the SDK behind one module means when Stripe's API version bumps or you add a second payment flow, there's exactly one file to update — a pattern worth enforcing whether or not you use my boilerplate.
Frequently asked questions
How long does Stripe Connect take to implement?
With Express accounts, hosted onboarding, and Checkout, a working destination-charge flow is a weekend project for an AI-assisted founder — the code above is most of it. Budget the second weekend for the unglamorous parts: webhook handling, refund policy, and testing the dispute path.
Can I hold funds in escrow with Stripe Connect?
Sort of. Stripe isn't a true escrow service, but separate charges and transfers let you accept payment now and transfer to the seller later (within limits), and delayed payout schedules hold settled funds on the connected account before they hit the seller's bank. For most "release funds when the work is done" marketplaces, that combination is enough.
What does Stripe Connect cost?
Connect pricing sits on top of normal processing fees. For Express accounts, Stripe's pricing page lists $2 per monthly active connected account plus 0.25% + 25¢ per payout — an account is "active" in months it receives a payout. Standard connected accounts with direct charges avoid the per-account fee but change who eats processing costs and disputes.
Do my sellers need their own Stripe accounts?
They need a connected account, which is not the same as signing up at stripe.com. With Express, they click your onboarding link, fill in Stripe's hosted form once, and get a lightweight dashboard for payouts. From their perspective it feels like a feature of your product.
Who is liable when a seller scams a buyer?
On destination charges: you. Disputes debit your platform balance, and recovering from the seller is your problem. This is why marketplaces vet sellers, delay payouts, and keep a reserve — the take rate isn't free money; part of it is an insurance premium.
Should I charge a percentage, a flat fee, or both?
Run the math from the fee table above against your real price points. Percentage-only take rates get eaten by Stripe's fixed 30¢ on small transactions; flat-only fees punish cheap listings. A blend (e.g., 8% + 50¢) tracks your actual cost curve best, which is exactly why Stripe's own pricing is shaped that way.
Conclusion
A Stripe Connect integration for an indie marketplace comes down to four decisions made in the right order: destination charges as your charge type, Express accounts for onboarding, an application fee that survives the real fee math, and a refund-and-dispute policy you encoded before the first angry email. The APIs are the easy part; the durable work is treating your platform balance as the risk pool it actually is.
The Coding Capybaras marketplace has the copy-paste prompts for the rest of this stack — Stripe payments, webhooks, and lifecycle email — tuned for the same Next.js + Supabase architecture this tutorial assumes, so pasting the Connect prompt above into Claude Code drops it into a codebase already wired the same way.