Stripe Connect from First Principles: Building a Marketplace Payment System That Doesn't Lose Money
Every decision you make in a payment system is hard to reverse once real money is moving. This guide builds your understanding from scratch — layer by layer — so you can reason about these problems instead of blindly following documentation.
Level 0: What Stripe Does
Stripe is a middleman between credit cards and bank accounts. A customer's card is charged. The money lands in the business's Stripe balance — a virtual wallet Stripe holds for you. Periodically, Stripe moves that balance to the business's real bank account. That movement is called a payout.
Customer ────$50────► Stripe ────$48.55────► Business's Bank
keeps $1.45
(2.9% + $0.30)Stripe takes a processing fee on every charge. This is how they make money. The fee is deducted before money reaches the business's balance.
STRIPE
┌─────────────────┐
Customer pays │ │ Payout (automatic, free)
──────────────► │ Stripe Balance │ ────────────────────────► Bank Account
$50 │ $48.55 │ $48.55
└─────────────────┘
(fee already deducted)Standard payouts are free. Stripe already made its money on the processing fee. That's all Stripe does for a single business: charge cards, hold money, pay out to bank.
Level 1: The Platform Problem
Now imagine you're building a platform — an event ticketing marketplace. You don't sell tickets for yourself. You sell tickets on behalf of venues. The money flow has a question at its core:
Customer ──── $50 ticket ────► Your Platform ...but the ticket
is for a venue.
Who does this money
belong to?You need to split the money: your commission goes to you, the rest goes to the venue. This is the entire reason Stripe Connect exists — to let platforms manage payments and move money between multiple parties.
Level 2: Three Ways to Split the Money
Stripe Connect offers three "charge types" — three different plumbing arrangements for how money flows.
Option A: Direct Charges
The charge happens on the venue's Stripe account. The platform's commission is skimmed off automatically.
Customer ────$54────► Venue's Stripe Account
│
Stripe takes fee ($1.87)
Platform takes fee ($4.00)
│
▼
Venue keeps $48.13The venue "owns" the charge
Stripe fee comes out of the venue's balance
The platform's commission is sent to the platform's balance automatically
Option B: Destination Charges
The charge happens on the platform's Stripe account, and the platform specifies a destination. Money routes automatically.
Customer ────$54────► Platform's Stripe Account
│
Stripe takes fee ($1.87)
Platform keeps fee ($4.00)
│
Auto-transfer: $48.13
│
▼
Venue's Stripe AccountThe platform "owns" the charge and controls refunds
Money automatically routes to the venue — you don't control when
Option C: Separate Charges and Transfers
Two independent operations: the platform charges, then transfers whenever it wants.
Step 1: Charge Step 2: Transfer (later)
Customer ────$54────► Platform Platform ────$50────► Venue
Balance Balance Balance
$52.13 keeps $2.13
(Stripe takes $1.87) (Platform decides WHEN to transfer)Maximum control over timing
The platform can hold funds (for refund windows, post-event settlement)
The platform decides when and how much to transfer
The Decision
Ask yourself one question: is there a gap between payment and fulfillment?
No gap (digital download, instant service) — Destination charges work fine. The seller earned the money; move it.
Gap exists (event tickets bought weeks ahead, freelance work delivered later) — You need to hold the funds. Use separate charges and transfers.
For an event ticketing platform, tickets are purchased days or weeks before the event. We need separate charges and transfers.
Principle derived: Never transfer money to a seller before they've delivered value. The charge model must give you control over settlement timing if your business has deferred fulfillment.
Level 3: The Venue's Stripe Account
Every venue needs a Stripe account to receive money. The question is: what kind? Stripe's older API used account "types" (Standard, Express, Custom). The modern API replaces this with four independent controller properties:
Lever 1: Who pays Stripe's Connect fees?
fees.payer = 'account' → Venue pays. No Connect fees to you.
fees.payer = 'application' → Platform pays. Adds $2/mo per venue + payout fees.
BUT you can negotiate bulk processing rates.At low volume, account eliminates Connect fees entirely. At ~$80K+/month in volume, call Stripe to negotiate a bulk rate — the savings from application can outweigh the Connect surcharges.
Lever 2: Who covers losses?
losses.payments = 'stripe' → Stripe absorbs connected account negative balances
losses.payments = 'application' → Platform absorbs connected account negative balancesCritical nuance with separate charges and transfers: the charge lives on your platform account, not the connected account. Chargebacks are always debited from the account where the charge was created. So losses.payments does NOT protect you from chargebacks — those always hit the platform regardless of this setting.
Separate Charges and Transfers:
──────────────────────────────
The charge lives on the PLATFORM's account.
The chargeback hits the PLATFORM's account.
losses.payments only controls CONNECTED ACCOUNT
negative balances — but the chargeback never
touches the connected account.
→ The platform absorbs chargebacks regardless.
→ You mitigate this with deferred settlement (Level 4).Lever 3: Who collects identity verification?
requirement_collection = 'stripe' → Stripe provides KYC forms, auto-updates
requirement_collection = 'application' → You build KYC forms, update every ~6 monthsLet Stripe handle this. Building your own KYC is an enormous ongoing compliance burden with no upside. Stripe's embedded components auto-update when regulations change and can be styled to match your design system.
Lever 4: What dashboard?
┌──────────────────────────────────────────────────────────────────┐
│ │
│ "full" Venue ◄──────► Stripe │
│ Full Stripe user. They see Stripe everywhere. │
│ │
├──────────────────────────────────────────────────────────────────┤
│ │
│ "express" Venue ◄──► Stripe (limited) │
│ Lightweight dashboard. They know Stripe exists. │
│ │
├──────────────────────────────────────────────────────────────────┤
│ │
│ "none" Venue ◄──────► Your Platform │
│ They never see Stripe. Your platform IS their │
│ experience. Stripe is invisible plumbing. │
│ │
└──────────────────────────────────────────────────────────────────┘With dashboard = 'none', Stripe serves onboarding, account management, and balance UIs as cross-origin iframes inside your product. No API keys exposed to the browser. No redirects to Stripe's domain. The venue completes KYC inside your UI without ever leaving your platform.
Constraints Between Levers
Not all combinations are valid. The logic follows from first principles:
If the venue gets a dashboard (
fullorexpress), Stripe must collect KYC itself — it won't grant dashboard access to accounts it hasn't verified.Stripe only absorbs losses for accounts it has verified —
losses.payments = 'stripe'requiresrequirement_collection = 'stripe'.
Recommended Configuration
controller: {
fees: { payer: 'account' }, // No Connect fees at low volume
losses: { payments: 'stripe' }, // Stripe absorbs connected account negatives
requirement_collection: 'stripe', // Stripe handles KYC compliance
stripe_dashboard: { type: 'none' }, // Fully branded — venue never sees Stripe
}Zero Connect-specific fees. Onboarding lives entirely inside your platform. Venues never see Stripe. Stripe handles compliance. The only fee in the entire flow is the standard card processing fee.
Level 4: The 120-Day Chargeback Problem
Now we have money flowing through the platform. The next question: when do we transfer the venue's share? This is where most platforms get burned.
A chargeback is when a customer disputes a charge with their bank, and the bank forcibly reverses the payment. It is not a refund (which the merchant initiates). The bank takes the money back without asking. Under Visa and Mastercard rules, customers can dispute charges for up to 120 days after a transaction.
The Timeline That Hurts
Day 1: Customer buys 2 tickets at $25 each
Total charged: $54.00 ($50 tickets + $4 platform fee)
Stripe takes $1.87 fee
Platform balance: $52.13
Day 2: Platform transfers $50.00 to venue (eager settlement)
Platform balance: $2.13
Day 3: Venue's Stripe balance pays out to their bank
Venue's Stripe balance: $0
Day 45: Customer calls bank: "I didn't authorize this"
Bank REVERSES the $54.00. No questions asked.
+ Bank charges a DISPUTE FEE (~$15)
Platform balance: -$66.87
($54 reversed + $15 dispute fee - $2.13 that was left)
Already transferred $50 to venue: GONE.The $50 is gone. Recovering it from the venue is a legal problem, not a technical one. Your platform just lost $66.87 on a $4 commission.
Deferred Settlement Fixes This
Don't transfer money until the risk window narrows. Hold funds in the platform account until after the event ends and any refund cutoff has passed.
Scenario A: Chargeback BEFORE settlement (best case)
Day 1: Customer charged $54. Platform holds all funds.
Day 1-30: Money held in platform balance (event hasn't happened).
Day 14: Chargeback arrives → $54 + $15 dispute fee debited.
→ Platform still holds the funds → no transfer was made.
→ Platform contests dispute with evidence.
→ If won: $54 + $15 returned. Net cost: $0.
→ If lost: platform absorbs $15 dispute fee. Net cost: $15.
Scenario B: Chargeback AFTER settlement (common case)
Day 1: Customer charged $54.
Day 30: Event ends → transfer $50 to venue.
Day 31: Venue's Stripe balance → payout to bank.
Day 75: Chargeback arrives → platform debited $54 + $15 dispute fee.
→ Platform reverses the $50 transfer.
→ Venue's Stripe balance goes -$50.
→ Platform contests dispute with evidence.
→ If won: $54 + $15 returned to platform,
platform re-transfers $50 to venue.
→ If lost: platform absorbs $15 dispute fee,
venue's -$50 balance recovers from future transfers.Without deferred settlement: platform loses $66.87. With deferred settlement: worst case is $15 (the dispute fee). The ticket price is always recoverable via transfer reversal.
Principle derived: Deferred settlement is not a feature. It's the primary chargeback defense. Architect for it from day one.
Level 5: Making Settlements Crash-Safe
We've established that settlements happen after the event. Now: how do you execute them safely? A background poller finds settlements that are ready. An admin can also click "settle now." A webhook might trigger one. If two of these fire simultaneously, you risk sending the same transfer twice.
The TOCTOU Problem
Time-of-Check to Time-of-Use (TOCTOU) is the classic race condition. The naive approach reads the state, checks it, then acts on it. But between the check and the act, another process can change the state:
Process A Process B
───────── ─────────
Read settlement: status = 'pending'
Read settlement: status = 'pending'
Check: status === 'pending'? Yes!
Check: status === 'pending'? Yes!
Update: status = 'settling'
Call stripe.transfers.create() Update: status = 'settling'
Call stripe.transfers.create()
─────────────────────────────────────────────────────────────────────
Result: TWO transfers sent. Venue gets paid twice.The read and the write are separate operations. Between them, the world can change. This is TOCTOU — the state you checked is not the state you're acting on.
The Atomic Claim Pattern
The fix: combine the check and the state transition into a single atomic database operation. Don't read then write. Write conditionally:
const claimed = await db.settlement.updateMany({
where: { id: settlementId, status: 'pending' },
data: { status: 'settling', startedAt: new Date() },
})
if (claimed.count === 0) return // Another process got here firstThis is not a distributed lock. It's a conditional write that uses the database's own atomicity guarantees. updateMany with a WHERE clause that includes the expected current status means exactly one caller succeeds. Everyone else gets count === 0 and backs off.
Process A Process B
───────── ─────────
UPDATE WHERE status='pending' UPDATE WHERE status='pending'
→ count = 1 (won the claim) → count = 0 (lost the race)
Call stripe.transfers.create() return (back off)
─────────────────────────────────────────────────────────────────
Result: ONE transfer sent. Correct.This pattern applies everywhere a state transition must happen exactly once:
Settlement execution (prevent duplicate transfers)
Webhook processing (prevent double-counting purchases from duplicate deliveries)
Admin retry actions (prevent two admins from both succeeding)
Idempotency Keys: The Second Layer
The atomic claim prevents duplicate attempts. But what about the gap between the Stripe API call and the database update? If Stripe successfully creates the transfer but your process crashes before recording it, the settlement is still at settling. When a recovery process retries, it would attempt the transfer again.
Stripe's idempotency keys solve this:
const transfer = await stripe.transfers.create(
{ amount, destination, ... },
{ idempotencyKey: `settlement_${settlementId}` }
)With the same idempotency key, Stripe returns the original result instead of creating a duplicate transfer. The key is valid for 24 hours. This gives you a safe retry window for crash recovery.
Two layers of protection:
Layer 1: Atomic claim (database)
─────────────────────────────────
Prevents two processes from ATTEMPTING the same transfer.
Only one caller wins the WHERE status='pending' race.
Layer 2: Idempotency key (Stripe)
─────────────────────────────────
Prevents duplicate EXECUTION if the winner crashes after
Stripe succeeds but before DB records it.
Same key → Stripe returns original result, no duplicate.The Two-Tier Reaper
What if a process crashes mid-settlement? The settlement is stuck at settling forever. A reaper job detects and recovers these, but it must respect Stripe's 24-hour idempotency key window:
Stuck duration Action Why
────────────── ────── ───
10 min – 23 hours Reset to 'pending' Idempotency key (24h) still valid.
(auto-retry) Safe to retry — no duplicate risk.
> 23 hours Mark as 'failed' Idempotency key may have expired.
(needs human) Admin must check Stripe dashboard
before retrying.
The 1-hour buffer (23h vs 24h) accounts for poller scheduling drift.Tier 1 recoveries are fully automatic — the reaper resets the settlement, the poller picks it up, the idempotency key guarantees no duplicate, and the transfer completes. No human intervention required.
Tier 2 is the safety boundary. After 23 hours, the idempotency key might expire. If the original transfer actually succeeded at Stripe but the database never recorded it, a retry without the key's protection would create a duplicate. The conservative action is to stop and let a human verify.
Principle derived: Financial state transitions need two layers of protection. Atomic claims at the database level prevent duplicate attempts. Idempotency keys at the payment provider level prevent duplicate execution. Tie your recovery windows to external system guarantees.
Level 6: Database Invariants
Two more problems require the database — not application code — to enforce correctness: tracking active Stripe accounts and tracking commission rates.
The Sentinel Pattern: One Active Account Per Venue
A venue might disconnect and reconnect their Stripe account over time, creating multiple records. You need to enforce "exactly one active account per venue" at the database level.
The naive approach is isActive: boolean with a unique constraint on [venueId, isActive]. But this only allows two records per venue: one where isActive = true and one where isActive = false. The third disconnection violates the constraint.
Instead, use a timestamp field activeUntil with a far-future sentinel value:
Schema: @@unique([venueId, activeUntil])
┌──────────┬──────────────┬──────────────────────────────┐
│ venueId │ stripeAcct │ activeUntil │
├──────────┼──────────────┼──────────────────────────────┤
│ v1 │ acct_AAA │ 2025-03-15T10:30:00Z │ ← deactivated (real timestamp)
│ v1 │ acct_BBB │ 2025-06-01T14:00:00Z │ ← deactivated (real timestamp)
│ v1 │ acct_CCC │ 9999-12-31T23:59:59Z │ ← ACTIVE (sentinel)
└──────────┴──────────────┴──────────────────────────────┘
Only ONE row per venue can have the sentinel → DB enforces "one active account."
Each deactivation produces a unique real timestamp → unlimited history.
Two concurrent reconnections → both try to insert sentinel → DB rejects one.One unique constraint gives you three properties: at most one active account per venue, unlimited historical records, and race condition protection. Querying for the active account is trivial: WHERE activeUntil = "9999-12-31T23:59:59Z".
Commission Snapshotting: Immutability Over Running Totals
Your platform charges 8% plus $1 per ticket. Simple enough. But what happens when you change the rate? If you store the rate on the venue's profile and calculate at settlement time, rate changes retroactively alter the economics of past purchases:
The venue agreed to sell tickets under the old rate.
The platform quoted the customer a price that included the old rate's fees.
Invoices generated after the change won't match actual transaction amounts.
Instead, capture the complete fee structure at the moment of purchase as an immutable record:
// Created at checkout — never modified
type CommissionSnapshot = {
percentageFee: number // 0.08 (8%)
fixedFeeCents: number // 100 ($1.00 per ticket)
vatRate: number // 0.21 (21%)
platformFeeCents: number // Calculated: (subtotal × %) + (fixed × qty)
vatAmountCents: number // Extracted from inclusive fee
venueTransferCents: number // Always 100% of ticket price
totalChargedCents: number // subtotal + platformFee
}Rate changes only affect future purchases. Every past purchase has a permanent, auditable record of the exact rates that applied. Settlement just sums the snapshots.
Notice every monetary value is in cents (integers). Floating-point arithmetic introduces rounding errors that accumulate across thousands of transactions. Financial systems use integers and round once, at the boundary.
Principle derived: Use the database to enforce invariants, not application code. Application code has race conditions — unique constraints don't. Financial parameters are immutable at the point of commitment — snapshot, don't reference.
Level 7: Error Classification and the "Let It Fail" Philosophy
When a settlement transfer fails, the critical question is: will retrying help? The answer determines whether the system can self-heal or needs human intervention.
Two Kinds of Errors
Classify every error into exactly one of two categories using a discriminated union:
type StripeErrorClassification =
| { kind: 'transient'; message: string }
| { kind: 'permanent'; code: FailureCode; message: string }
type FailureCode =
| 'account_disconnected'
| 'account_restricted'
| 'insufficient_balance'
| 'invalid_account' Error type Examples What happens
────────── ──────── ────────────
Transient Rate limit, 5xx, timeout Reset to 'pending' — auto-retry.
Self-heals. No human needed.
Permanent Account disconnected, Mark as 'failed' with code.
account restricted Admin sees what went wrong.
Fix root cause, then retry.Unknown errors default to transient — better to retry once too many than to require a human for a blip.
Why Not Block Proactively?
When a venue disconnects their Stripe account, should you immediately block ticket sales? Lock their dashboard? The instinct is to prevent problems proactively. But consider what "proactive blocking" actually requires:
Proactive approach (complex):
─────────────────────────────
Webhook fires: account.deauthorized
→ Check: does venue have active events?
→ Check: does venue have unsettled purchases?
→ Check: is venue mid-settlement?
→ Block ticket sales for all their events
→ Show "account disconnected" on venue dashboard
→ Show different error to customers trying to buy
→ Handle reconnection: undo all the blocks
→ Handle partial reconnection: undo some blocks
→ Edge case: disconnection during settlement
→ Edge case: disconnection during purchase
→ Edge case: reconnection during block propagation
"Let it fail" approach (simple):
────────────────────────────────
Webhook fires: account.deauthorized
→ Soft-delete the Stripe account record (sentinel timestamp)
→ Done.
Ticket sales continue. Platform holds all funds.
When settlement processor runs, transfer fails.
Error classified as: permanent, 'account_disconnected'.
Admin sees clear message. Venue reconnects. Admin retries.The "let it fail" approach works because the customer's money is always safe — it's in your platform account. The venue's payout is delayed, not lost. Every failure flows through the same classification and recovery path. Fix the root cause, retry. The same code handles initial attempts and retries because idempotency keys and atomic claims make retries safe.
Principle derived: Don't block operations preemptively. Let them fail at their natural execution boundary. Classify the failure. Provide a clear recovery path. Proactive blocking trades one simple failure for many complex state transitions.
The Complete Picture
Let's trace a purchase through the entire system to see how all the levels connect:
1. PURCHASE
Customer buys 2 × $25 tickets. Platform fee: 8% + $1/ticket.
─────────────────────────────────────────────────────────────
Ticket subtotal: $50.00
Platform fee: $6.00 ($50 × 8% + $1 × 2)
Total charged: $56.00
Stripe processing fee: $1.92 (2.9% + $0.30)
─────────────────────────────────────────────────────────────
Platform balance after charge: $54.08
Venue gets (later): $50.00 (100% of ticket price)
Platform keeps: $4.08 ($6.00 - $1.92)
2. CHECKOUT WEBHOOK
Stripe fires checkout.session.completed (possibly twice — duplicates happen).
→ Atomic claim on PurchaseSession: UPDATE WHERE status='pending'
→ Only one webhook delivery succeeds
→ Create immutable CommissionSnapshot with all amounts above
3. WAIT FOR EVENT
Funds sit in platform balance. If chargeback arrives,
platform still holds the money — $15 dispute fee worst case.
4. EVENT ENDS + REFUND WINDOW CLOSES
Poller detects settlement is ready.
→ Atomic claim: UPDATE settlement SET status='settling' WHERE status='pending'
→ One poller instance wins
→ stripe.transfers.create() with idempotencyKey: settlement_{id}
→ On success: mark 'settled', record transfer ID
→ On transient error: reset to 'pending' (auto-retry)
→ On permanent error: mark 'failed' with code (admin fixes + retries)
5. CRASH RECOVERY (if needed)
Process crashed between Stripe success and DB update?
→ Reaper finds settlement stuck at 'settling' for >10 min
→ < 23 hours: reset to 'pending' (idempotency key still valid)
→ > 23 hours: mark 'failed' (admin checks Stripe dashboard)The Principles
Every pattern in this guide was derived from a small set of first principles:
Never transfer money before value is delivered. Choose a charge model that gives you control over settlement timing.
Deferred settlement is chargeback defense. Without it, you lose the ticket price plus the dispute fee. With it, you lose at most the dispute fee.
Use the database to enforce invariants. Atomic claims eliminate TOCTOU races. Sentinel values enforce uniqueness. Application code checks are not enough.
Layer your defenses. Atomic claims at the DB level. Idempotency keys at the Stripe level. Each layer covers a different failure mode.
Classify errors by recoverability. Transient failures heal themselves. Permanent failures surface to humans with actionable context.
Snapshot financial parameters at commitment time. Commission rates, fees, and tax rates are immutable per transaction. Use integers for money.
Let things fail naturally. Proactive blocking adds state machine complexity. Natural failure at execution boundaries gives you uniform error handling and simple recovery.
Tie recovery windows to external guarantees. Your reaper's thresholds match Stripe's idempotency key TTL. Your settlement timing accounts for chargeback windows. Don't invent arbitrary timeouts.
These principles aren't specific to Stripe or even to payments. They apply anywhere you build systems that coordinate state across multiple services where correctness matters more than speed.
If you're building a marketplace and want a head start, LaunchFast gives you a production-ready foundation with authentication, payments, and deployment infrastructure already wired together — so you can focus on the business logic that makes your platform unique.