Skip to content
Vertex Stack
Menu

Two build engagements per year

Payments · 8 min read

Stripe Connect for marketplaces: escrow, split payments, and the mistakes founders make

The Connect API is the easy part. The hard part is deciding who the merchant is, when sellers get paid, and who absorbs a chargeback, before you write the first payment call.

By Adil Mir ·

Stripe Connect is the default way to move money between buyers, sellers and a platform, and it's good at the job. It's also where an early decision quietly sets who loses money when something goes wrong.

The API is the easy part. The hard part is deciding who the merchant is, when the seller gets paid and who absorbs a chargeback, before the first paymentIntents.create. Connect's API keeps evolving, so treat this as a map and check Stripe's current docs before you build.

Decide who the merchant is first

Every card payment has a merchant of record: the business named on the customer's statement, responsible for the sale, and usually carrying refunds and chargebacks. On a marketplace that's the platform or the seller, and choosing is a business and legal question before it's a technical one.

If the platform is the merchant, you're selling the service and paying sellers from the proceeds. Customers see your name and disputes come to you. If the seller is the merchant, you're facilitating a sale and taking a fee, and sellers carry more of the risk. Most of the Stripe choices below follow from this.

Connected account types

Each seller is a connected account. Stripe has long offered three types:

StandardExpressCustom
Seller dashboardFull Stripe DashboardExpress DashboardNone; you build it
OnboardingStripe-hostedStripe-hostedStripe-hosted, embedded components or yours
Platform liable for negative balancesNoYesYes

Stripe now treats these as legacy types and recommends describing connected accounts by controller properties instead: who pays Stripe's fees, who is liable for losses, which dashboard the account gets, and whether Stripe or the platform collects onboarding requirements. The newer Accounts v2 API expresses the same choices, with names like fees collector and losses collector. The three legacy types map onto combinations of those. That framing makes the real choice explicit: who owns risk, fees, onboarding and support.

My default for a new marketplace is Express-style accounts. Stripe hosts onboarding and identity verification, sellers get a dashboard you didn't build, and the platform controls payout timing. Custom is worth it when the seller experience is the product and you'll own more compliance and support.

Charge types, and who carries the loss

Direct charges are created on the connected account, using the stripeAccount option in stripe-node. The seller is the merchant. Refunds and disputes come out of the seller's balance, and so do Stripe's fees unless the platform is set up to pay them. The platform takes application_fee_amount. This fits Standard accounts and seller-branded businesses.

Destination charges are created on the platform with transfer_data.destination naming the seller, and Stripe transfers the seller's share automatically. By default the platform is the merchant and pays Stripe's fees. Refunds and disputes come out of your balance first, and you recover the seller's share by reversing the transfer. Adding on_behalf_of makes the seller the settlement merchant, with their name on the statement and settlement in their country, but the charge still lives on your account, so refunds and disputes still debit you first.

Separate charges and transfers charge the customer on the platform account, then move funds to one or more connected accounts whenever you decide. A transfer_group links them, and source_transaction ties a transfer to the charge it's funded by. There's no application fee: you keep whatever you don't transfer. It's the flexible model, suited to payment and fulfillment separated in time, or one payment split across several sellers. It's supported across fewer country combinations than the others, so check yours first.

import Stripe from "stripe";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
// 1. Charge the buyer on the platform account. Amounts are integers in minor units.
export async function chargeBooking(booking: Booking) {
  return stripe.paymentIntents.create(
    {
      amount: booking.totalMinor,
      currency: booking.currency,               // e.g. "eur"
      customer: booking.stripeCustomerId,
      transfer_group: `booking_${booking.id}`,
      automatic_payment_methods: { enabled: true },
      metadata: { bookingId: booking.id },
    },
    { idempotencyKey: `booking:${booking.id}:charge` },
  );
}
 
// 2. Later, when the booking completes and your hold window passes, pay the seller.
export async function releaseToSeller(booking: Booking, chargeId: string) {
  return stripe.transfers.create(
    {
      amount: booking.totalMinor - booking.platformFeeMinor,
      currency: booking.currency,
      destination: booking.sellerAccountId,     // "acct_..."
      transfer_group: `booking_${booking.id}`,
      source_transaction: chargeId,             // the PaymentIntent's latest_charge
      metadata: { bookingId: booking.id },
    },
    { idempotencyKey: `booking:${booking.id}:transfer` },
  );
}
Charge typeMerchant by defaultRefunds and disputes hitPlatform's cut
DirectSellerSeller's balanceapplication_fee_amount
DestinationPlatformPlatform, recovered by transfer reversalapplication_fee_amount
Separate charges and transfersPlatformPlatform, recovered by transfer reversalWhatever isn't transferred

Application fees and Connect pricing

Decide early how your application fee behaves on a partial refund: pass refund_application_fee to return it proportionally, or keep it. Either is defensible. Not having decided isn't.

Stripe's processing fee is separate, and Stripe doesn't return it when you refund. Connect pricing depends on who handles pricing for connected accounts: where Stripe does, as with Standard accounts, there's no separate Connect fee; where your platform does, Stripe charges per active account and per payout. Check the current pricing page, and model every fee in your ledger.

Stripe is not an escrow service

Founders often describe the flow they want as escrow: the buyer pays, the platform holds the money, the seller is paid when the job is done. Stripe doesn't offer escrow, and money in a Stripe balance isn't escrow in the legal sense. What you can build is a hold with release conditions.

  • Authorize now, capture later. capture_method: "manual" reserves funds on the card; you capture when the service is confirmed. Authorizations expire, typically after seven days for online card payments, with extended authorization available in some cases. Check the current limits. Good for a same-week job, not a booking three months out.
  • Delay or control payouts. Where the platform controls payouts, set a payout delay or switch to manual payouts and pay out when a release condition is met. Stripe limits how long funds can sit unpaid in a connected account, and the limit varies by country. Check the docs before you promise a release window.
  • Charge now, transfer later. With separate charges and transfers, money stays in the platform balance until you transfer it. Release becomes a business event: booking completed, no complaint inside your window, transfer created. Stripe has rules on what transfers can be used for and when linked funds become available, so check those too.

Whichever you use, release logic belongs in your system as a state machine with timestamps.

Onboarding and the account.updated webhook

A connected account can't take money until Stripe has verified it, and verification isn't one-off: requirements change as volume grows and documents expire. Mirror charges_enabled, payouts_enabled and the requirements hash (currently_due, past_due, disabled_reason) on your seller record, update them from the account.updated webhook, and drive the UI from them. A seller with outstanding requirements sees a link back into onboarding. A seller who can't be paid out shouldn't be able to publish listings that take payment.

Webhooks and idempotency for Connect

Events on your platform account and events on connected accounts arrive at separate webhook endpoints, each with its own signing secret. Connected-account events carry an account field.

import express from "express";
 
app.post(
  "/webhooks/stripe/connect",
  express.raw({ type: "application/json" }),   // signature needs the raw body
  async (req, res) => {
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"] as string,
        process.env.STRIPE_CONNECT_WEBHOOK_SECRET!,
      );
    } catch {
      return res.status(400).end();
    }
 
    // Stripe delivers at least once. Skip events already processed.
    if (await processedEvents.has(event.id)) return res.status(200).end();
 
    if (event.type === "account.updated" && event.account) {
      const account = event.data.object;
      await sellers.syncStripeStatus(event.account, {
        chargesEnabled: account.charges_enabled,
        payoutsEnabled: account.payouts_enabled,
        currentlyDue: account.requirements?.currently_due ?? [],
        disabledReason: account.requirements?.disabled_reason ?? null,
      });
    }
 
    await processedEvents.add(event.id);
    res.status(200).end();
  },
);

Every retryable write to Stripe should carry an idempotency key derived from your own IDs, as in the transfer example, so a retry can't create a second transfer. SkillStreamsConnect applies the same idea on a smaller surface: before granting credits, its webhook handler checks for an existing transaction keyed on the Checkout session, so a replayed event can't double-credit a provider. More in webhook security.

Payouts and cross-border limits

Sellers are paid out to bank accounts in their own country and supported currencies. Cross-border is where assumptions break. Stripe's self-serve cross-border payouts only run between certain regions, and only for destination charges or separate charges and transfers without on_behalf_of. Accounts on the recipient service agreement, which can receive transfers but not take payments, are excluded from them; Stripe points those to its Global Payouts product instead. Currency conversion fees apply when charge and payout currencies differ. Check every country combination against Stripe's current docs.

Refunds, reversals and disputes

On a direct charge, a refund comes out of the seller's balance. On a destination charge it comes out of yours; pass reverse_transfer: true to pull back the seller's share. With separate charges and transfers, you reverse the right transfer yourself.

await stripe.refunds.create(
  { payment_intent: booking.paymentIntentId, amount: refundMinor },
  { idempotencyKey: `booking:${booking.id}:refund:${refundId}` },
);
await stripe.transfers.createReversal(
  booking.transferId,
  { amount: sellerShareOf(refundMinor) },
  { idempotencyKey: `booking:${booking.id}:reversal:${refundId}` },
);

If the seller has already been paid out, there may be nothing left to reverse. Stripe generally reverses a transfer only when the connected account's available balance covers it, so the refund stays your cost until you recover it another way. On Express and Custom accounts, any negative balance a seller runs up is ultimately your liability too. That's the strongest argument for holding funds until the refund window has passed.

Disputes arrive on the bank's schedule, not yours, with a dispute fee. On destination charges and separate charges and transfers, both hit the platform balance first. Recovering from the seller has to be allowed by your seller terms. Messages and booking logs are your evidence, so make them exportable. More in refunds, disputes and chargebacks.

Tax reporting

Paying sellers can create reporting duties: 1099 forms in the US, DAC7 reporting in the EU. Stripe has tooling for some of this, and the rules change, so ask your accountant. The build should capture seller legal names, tax IDs where required, and payout records per seller per year.

When Stripe Connect isn't available

Connect isn't available everywhere. Baytup, a rental marketplace for Algeria, takes card payments through SlickPay, a local gateway, and pays hosts through local rails such as RIB transfers, CCP and BaridiMob, with admin approval on each payout. Nothing there splits payments automatically, so the platform tracks host earnings itself. VetRealm, a SaaS for veterinary clinics across MENA, bills subscriptions through PayTabs. Go regional and you inherit the ledger, payout operations and more compliance. See Stripe vs PayTabs vs regional gateways.

The mistakes founders make

Picking a charge type before deciding who the merchant is

The charge type follows from the merchant of record. Pick destination charges because a tutorial did, and you may find your sellers, or your lawyer, expected the seller to be the merchant. Switching later changes how every payment, refund and dispute flows.

Promising escrow in marketing copy

"Your money is held in escrow" can create obligations, and in some jurisdictions escrow is a regulated term. Describe what actually happens, such as payment taken at booking and released after completion, and make sure the system does exactly that.

Ignoring dispute liability

With destination charges or separate charges and transfers, disputes come to you first, and on Express or Custom accounts so do sellers' negative balances. Budget for them and write recovery into seller terms.

Building payouts before onboarding is finished

The demo pays a seller who onboarded yesterday. Production has sellers with requirements past due and bank accounts that bounce. Build onboarding states and the account.updated sync first.

Not reconciling

Stripe, your database and your bank should agree. Match every charge, fee, transfer, refund and payout to a record in your system, daily, using balance transactions and payout reconciliation reports. Drift found in week two is a bug. Drift found at year end is an accounting project.

Treating Connect as a licensing answer

Connect handles much of the regulated work of moving money for others. It doesn't automatically answer every licensing, consumer-protection or tax question for your business in your markets. That's a question for a payments lawyer where you operate. This isn't legal advice; it's a reminder to ask.

Where I'd start

If you're designing the money flow for a marketplace, I'd start with a Discovery Sprint. It's a short, fixed-fee engagement that settles the merchant of record, charge type, hold and release rules, and payout path for each launch market. It ends with a scoped brief, a risk map, an architecture and a fixed-price proposal, and the fee is credited toward the build. Start a conversation.

Payments · 6 min read

Handling refunds, disputes, and chargebacks in SaaS

SaaS is uniquely exposed to chargebacks because the product is intangible and subscriptions auto-renew. Friendly fraud is a large share of card losses. Here is the refund policy, evidence package, and dunning discipline that keep dispute ratios well below the card networks' monitoring thresholds.

Marketplaces · 6 min read

Local payment rails for global marketplaces: beyond Stripe

Stripe handles the US and Europe gracefully. It handles Brazil, India, Saudi Arabia, and Nigeria less gracefully — or not at all. Here's how to architect a multi-gateway marketplace without drowning in ifs, and the local rails worth wiring up first.

See it in practice: the Baytup case study.