Splitting money in a marketplace: Stripe Connect, destination charges, and getting hosts paid
4 min read
The first time I built payments for a marketplace, I thought it was the same as payments for a normal app plus a division. Charge the guest, keep a cut, send the rest to the host. It is that — but “send the rest to the host” hides two problems that a single-sided app never has: the money has to split at charge time, and the host has to be set up to receive payouts at all, which means identity verification, a bank account, and tax details you absolutely do not want flowing through your own servers.
Stripe Connect exists for exactly this. Here’s how the two halves actually worked in a lodging marketplace — hosts listed places, guests booked, the platform took a fee.
Half one: onboard the host for payouts
Before a host can be paid, Stripe needs to know who they are. You don’t build that — you’d be signing up to handle KYC, bank verification, and compliance in every country you operate in. Instead you create an Express account for the host and hand them Stripe’s hosted onboarding:
Stripe.api_key = ENV.fetch("STRIPE_SECRET_KEY")
account = Stripe::Account.create({
type: 'express',
country: 'FR',
email: host.email,
capabilities: { transfers: { requested: true } }
})
host.update!(stripe_account_id: account.id)
Then you generate a one-time link to Stripe’s onboarding flow and redirect the host to it:
link = Stripe::AccountLink.create({
account: host.stripe_account_id,
refresh_url: refresh_url, # if the link expires, come back here to mint a new one
return_url: return_url, # where Stripe sends them when they're done
type: 'account_onboarding',
})
redirect_to link.url
The host lands on a Stripe-hosted page, enters their bank and ID details on Stripe, and comes back.
Your database only ever stores the stripe_account_id — a reference. The sensitive stuff never touches
your box, which is the entire point: the compliance surface is Stripe’s, not yours. The refresh_url /
return_url split matters too — those onboarding links are short-lived, so you need a place to bounce a
host who clicks a stale one and mint a fresh link.
Half two: charge the guest and split the money in one shot
Now the charge. A destination charge lets you charge the guest, automatically route the bulk to the host’s connected account, and keep a platform fee — all in a single Checkout Session:
platform_fee = commission_fee + service_fee + occupancy_tax # all in integer cents
Stripe::Checkout::Session.create({
payment_method_types: ['card'],
line_items: [{ name: listing.name, amount: total, currency: 'eur', quantity: 1, description: summary }],
payment_intent_data: {
application_fee_amount: platform_fee, # what the platform keeps
transfer_data: { destination: host.stripe_account_id }, # where the rest goes
},
success_url: success_url,
cancel_url: cancel_url,
})
The two lines that carry the whole marketplace are application_fee_amount and
transfer_data.destination. The guest pays total; Stripe moves total − platform_fee to the host’s
connected account and leaves the fee with the platform. No second transfer call, no holding funds and
paying out later, no reconciliation job — the split happens atomically with the charge. That simplicity
is worth a lot: the failure mode of “charged the guest but never paid the host” mostly can’t happen,
because it’s one operation.
One real subtlety from the actual code: there were two branches. For host-managed listings, the money went entirely to the platform (no split); for the rest, the destination charge above ran. That’s the kind of business rule that lives in payments whether you like it or not — “who gets the money” isn’t one answer, it’s a policy, and it belongs somewhere explicit rather than smeared across the flow. And the fee itself wasn’t one number either: a commission, a service fee, and a legally-required occupancy tax, summed. Marketplaces are made of these little arithmetic policies, all in integer cents, never floats.
What I’d do differently
Two things, said plainly because I’d rather you learn from them than repeat them:
The Stripe secret key was hardcoded in the source, switched on Rails.env — a sk_live_... string
sitting in a model, committed to git. Don’t. Keys go in the environment (ENV.fetch("STRIPE_SECRET_KEY")),
and if one has ever been committed, it’s compromised and needs rotating. This is the single most common
security mistake I see in early-stage codebases, and I made it too.
And payment confirmation polled the Checkout Session — fetching it and checking payment_status == "paid" — instead of listening for Stripe’s webhook. Polling works until it doesn’t: a guest who closes
the tab, a slow redirect, a network blip, and your record never flips to paid even though the money
moved. The correct design is a checkout.session.completed webhook that updates the booking, made
idempotent so a replayed event is a no-op. Reacting to Stripe telling you what happened beats asking it
on a timer.
Marketplace payments feel intimidating the first time because they touch money, identity, and tax at once. But the shape is small: onboard the seller with a hosted Express flow so you never hold their compliance data, and split the charge with a destination charge so the money lands in the right two places in one operation. Get those two right and the rest is arithmetic.
I build billing and marketplace payments in Rails — splits, payouts, fees, the parts where “it charged but didn’t pay out” is a real incident. If that’s what you’re wiring up, get in touch. Related: an availability search that didn’t scale.