Stripe Connect & Subscription Billing
Two money flows on one Stripe account. A marketplace charges the buyer once and fans the proceeds out to many merchants (Connect, separate charges and transfers). A platform subscription charges those merchants a recurring fee off-session (Billing). They share a client, a webhook pair and a money type.
The insight that shapes everything: the buyer's charge and the merchants' payouts fail independently. By the time settlement runs the money has already moved, so a rejected transfer must never fail the charge or the webhook, and must stay re-drivable later without paying anyone twice.
When to use
- One payment splits across several sellers, with the platform taking a cut.
- Sellers onboard to Connect and are paid on a delay (escrow, reserves, KYC).
- The platform bills its own sellers a recurring fee on a saved card.
- A connected account looks healthy but has never received any money.
When NOT to use
| Not this | Use instead |
|---|---|
| Single-seller checkout, no split | Stripe Checkout / Payment Element directly |
| PayPal Marketplace onboarding + payouts | A PayPal skill; only the seams are shared |
| Card UI, Payment Element styling | Your design system; this skill is server-side |
Architecture
buyer ──charge (platform account, transfer_group = orderId)──▶ platform balance
│ settlement fan-out (webhook OR read-path sync; leased claim)
┌────────────┴──┬────────────────────────┬────────────────┐
▼ ▼ ▼ ▼
transfer:merchant transfer:partner escrow hold rolling reserve
(source_transaction = the charge) (release_at) (% withheld)
│ │ release-escrow cron
▼ ▼
connected balance ◀── retry-transfers payable balance ──▶ payout (KYC/risk gated)
(unfunded legs)
platform ──off_session PaymentIntent (customer + saved PM)──▶ subscription invoice
dunning: 3 attempts, 3 days apart, then suspend
Critical facts
- Separate charges and transfers, never destination charges. A charge takes
one
destination; a multi-vendor cart needs N transfers under onetransfer_group, soapplication_fee_amountis unusable. - A transfer without
source_transactiondraws on the available balance and is rejected while the charge is still settling; on a young platform account, that is every transfer. This one flag is what lets a split fund while the charge is still settling. - Stripe transfers cross-border only inside US/CA/UK/EEA/CH. A platform elsewhere pays connected accounts in its own country only. Stripe enforces this at transfer time, not onboarding time, so the templates apply the region rule at onboarding.
- Connect events carry a different signing secret than platform events. Two endpoints, two secrets, one URL — verify against both.
- A connected account's country is immutable — wrong country means re-onboarding, not a patch.
- Money is never a float —
numeric(19,4)strings end to end,bigintmath, minor units only at the SDK edge.
Hard rules
Never let a failed transfer fail the settlement. Record the leg unfunded and continue. The charge has already succeeded, so the webhook must succeed too; the retry cron funds the leg later.
Never treat a duplicate webhook insert as "already handled". The insert is a claim; the retry after a delivery crashed mid-handling re-runs the handlers, so every event runs to completion exactly once.
Never re-send a transfer without asking Stripe whether it already exists. A leg recorded unfunded because the response was lost pays twice once Stripe's 24h idempotency window passes.
Never derive settlement exclusivity from a status flag written at the end. Two callers reach settlement routinely; an atomic leased claim is what makes inventory and transfers happen once.
Never reverse more than a transfer's remaining headroom. Stacked reversals (gateway fee, then a refund) are rejected past the original amount.
Quick start
- Model the money — money.md, data-model.md, store.md.
- Client, dual webhook secrets, env — stripe-adapter.md.
- Onboard accounts; check the region rule first — connect-accounts.md.
- Receive events idempotently — webhooks.md.
- Settle: claim, fan out, escrow, reserve — settlement.md.
- Converge: retry legs, reconcile fees — reconciliation.md.
- Bill tenants — subscriptions.md; run it with operations.md.
Fit it to your app with adaptation.md; the record of the audit is in provenance.md.
Adaptation Contract
| Seam | The skill ships | The host supplies |
|---|---|---|
| Domain entities | Tenant (seller org), Order, VendorOrder, Partner | Its own vocabulary |
| Tenant scope | One server-derived tenantId | org / workspace / seller |
| Auth guard | An adapter signature per route | Clerk, NextAuth, Supabase, custom |
| Data access | A PaymentsStore contract + SQL | Its ORM or SDK |
| Audit + notifications | Call sites, event names, payload shapes | Its audit table / outbox |
| Background work | Four crons and their cadence | Its scheduler |
| Validation | A schema shape per route body | zod / valibot / yup |
| UI | Nothing — this skill is server-side | All of it |
Reference directory
| Scenario | Trigger keywords | Reference |
|---|---|---|
| Why this money model | separate charges and transfers, destination charge, application_fee_amount, transfer_group, ledger | architecture.md |
| Tables, columns, RLS | schema, migration, payment_intents, transfers, escrow_holds, webhook_events | data-model.md |
| The data-access contract | store, repository, atomic claim, conditional update, ORM | store.md |
| Amounts and rounding | numeric, bigint, minor units, distribute, largest remainder, cents | money.md |
| Client and adapter code | stripeClient, apiVersion, constructEvent, whsec_, PaymentIntent, refund, payout | stripe-adapter.md |
| Onboarding, account state | Express, Accounts v2, account.updated, charges_enabled, acct_, country, region | connect-accounts.md |
| Receiving events safely | idempotency, duplicate, replay, raw body, signature, invalid_signature | webhooks.md |
| Splitting a paid order | settlement, fan-out, source_transaction, escrow, reserve, single-flight | settlement.md |
| Fixing what didn't land | retry, unfunded, backoff, adopt, gateway fee, clawback, reversal, headroom | reconciliation.md |
| Recurring platform fees | SetupIntent, off_session, saved card, dunning, invoice, past_due, suspend | subscriptions.md |
| Setup, env, crons, debugging | STRIPE_SECRET_KEY, webhook endpoint, stripe listen, cron, "merchant never paid" | operations.md |
| Fitting it to this app | adapt, host probe, rename, ORM, auth guard, port | adaptation.md |
| The audit record | provenance, deviation, kept, added, unverified | provenance.md |