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.13
  • The 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 Account
  • The 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 balances

Critical 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 months

Let 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 (full or express), 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' requires requirement_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 first

This 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:

  1. Never transfer money before value is delivered. Choose a charge model that gives you control over settlement timing.

  2. 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.

  3. Use the database to enforce invariants. Atomic claims eliminate TOCTOU races. Sentinel values enforce uniqueness. Application code checks are not enough.

  4. Layer your defenses. Atomic claims at the DB level. Idempotency keys at the Stripe level. Each layer covers a different failure mode.

  5. Classify errors by recoverability. Transient failures heal themselves. Permanent failures surface to humans with actionable context.

  6. Snapshot financial parameters at commitment time. Commission rates, fees, and tax rates are immutable per transaction. Use integers for money.

  7. Let things fail naturally. Proactive blocking adds state machine complexity. Natural failure at execution boundaries gives you uniform error handling and simple recovery.

  8. 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.