The challenge of splitting revenue in a marketplace
Building a marketplace means handling money from multiple parties: customers pay you, you keep a commission, and sellers need their share. Marketplace payments with Stripe Connect for production solves this by automating payouts, fee collection, and seller onboarding at scale. Without it, you're managing bank transfers manually, tracking who owes what, and building custom reconciliation—a recipe for lost revenue and compliance headaches.
This is day 35 in a series where we've covered authentication, idempotency, and webhooks. Today we focus on the business logic that actually moves money in a real marketplace: how to onboard sellers, charge customers, take your cut, and pay sellers reliably.
Account types—control versus effort
Stripe Connect offers three account types. Each trades control for operational burden. Choose poorly and you'll either over-engineer or hand off too much responsibility.
Standard accounts
Sellers create and manage their own Stripe account. Your platform never touches their banking details. You charge the customer, apply an application fee, and Stripe sends the seller's portion to their own account automatically.
Pros: Maximum seller control, minimal compliance on your side, sellers own their payment history and can port to another platform later.
Cons: Sellers must create a Stripe account themselves. Many won't. Your onboarding funnel breaks. You'll spend support time answering "why can't I get paid?"
Use this if your sellers are already payment-savvy: B2B marketplaces, contractor networks, or platforms where sellers have their own business entities.
Express accounts
Sellers onboard through your app in minutes. Stripe handles identity verification in the background. The seller provides minimal info—name, email, bank details—and you embed an onboarding flow via account links. Stripe manages the seller's compliance and payout schedule.
Pros: Fast onboarding, low friction, sellers feel like they're onboarding to your platform, not Stripe. Stripe handles regulatory checks.
Cons: Less branding control over the onboarding flow. Sellers can still log into Stripe and see your application fees listed. Some sellers find this confusing.
This is the default for consumer marketplaces: food delivery, task services, resale platforms. Most teams choose this.
Custom accounts
You build the entire onboarding UX yourself. You collect all seller data, run your own identity verification, and manage the Stripe connection programmatically. Maximum branding control and UX customization.
Pros: Complete control over onboarding flow and messaging. Fully-branded experience.
Cons: You're now responsible for KYC/AML compliance, identity verification, and regulatory reporting. This means legal review, fraud detection logic, and audit trails. Stripe won't hold your hand.
Only choose this if you have a compliance team or the regulatory burden is truly justified by your use case.
For most production marketplaces, Express accounts are the pragmatic middle ground. You get fast seller onboarding without building a compliance layer.
Charges—destination charges versus separate charges and transfers
Once you've onboarded a seller, you need to charge the customer and route the money. Stripe Connect gives you two patterns.
Destination charges
You create a single charge that goes directly to the seller's connected account. Stripe deducts your application fee in the same operation. The flow: customer card → Stripe → seller's account minus your fee.
const charge = await stripe.charges.create({
amount: 10000, // $100.00
currency: 'usd',
source: 'tok_visa', // Customer's card token
destination: {
account: 'acct_seller123', // Connected account ID
amount: 8500, // Seller gets $85
},
application_fee_amount: 1500, // Platform keeps $15
metadata: {
orderId: 'order_abc123',
sellerId: 'seller_xyz789',
},
});
Pros: Single API call, atomic—either the whole transaction succeeds or fails together. Simpler reconciliation.
Cons: Stripe's newer payment APIs (Payment Intents, Setup Intents) have better fraud detection and support more card types. Destination charges work but feel legacy.
Separate charges and transfers
You charge the customer to your platform account, then transfer funds to the seller's connected account. This gives you a holding period—the money sits in your platform account briefly before dispersing.
// Step 1: Charge customer to platform account
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000,
currency: 'usd',
customer: 'cus_abc123',
payment_method: 'pm_card_visa',
confirm: true,
});
// Step 2: Transfer seller's portion
const transfer = await stripe.transfers.create({
amount: 8500, // Seller's cut
currency: 'usd',
destination: 'acct_seller123', // Connected account
metadata: {
orderId: 'order_abc123',
},
});
// Platform keeps: 10000 - 8500 = 1500 (the $15 fee)
Pros: You see all customer charges in your account. You have explicit control over when sellers get paid (useful for dispute windows, chargebacks, or holds). Modern payment methods are fully supported. Better for high-risk scenarios.
Cons: Two API calls. If the transfer fails after the charge succeeds, you're tracking a failed payout separately. Slightly more reconciliation work, but manageable if you log transfers properly.
Use separate charges and transfers for production. It's more explicit, supports modern payment APIs, and gives you a safety window if a dispute arises.
Application fees—taking your platform cut automatically
Your commission happens through application fees. Whether using destination charges or separate transfers, the fee is deducted immediately and becomes unavailable to the seller.
With destination charges, the fee is explicit in the charge object. With separate transfers, you simply transfer the seller's net amount and keep the difference in your platform account.
Be transparent in your API and dashboard. Log every fee clearly:
async function processMarketplaceOrder(
customerId: string,
sellerId: string,
amount: number, // in cents
platformFeePercent: number = 15, // 15% platform cut
) {
const platformFee = Math.round(amount * (platformFeePercent / 100));
const sellerAmount = amount - platformFee;
// Charge customer
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
customer: customerId,
confirm: true,
});
// Transfer to seller
const transfer = await stripe.transfers.create({
amount: sellerAmount,
currency: 'usd',
destination: getSellerConnectedAccountId(sellerId),
metadata: {
orderId: generateOrderId(),
platformFeeAmount: platformFee,
platformFeePercent: platformFeePercent,
},
});
// Log in your database
await logTransaction({
customerId,
sellerId,
chargeAmount: amount,
sellerAmount,
platformFeeAmount: platformFee,
stripePaymentIntentId: paymentIntent.id,
stripeTransferId: transfer.id,
});
return { paymentIntent, transfer };
}
Key points: Store the fee percentage and amount in metadata and your database. This matters for audits, disputes, and seller transparency. Don't surprise sellers with undocumented deductions.
Onboarding—account links handle identity verification
Getting a seller from signup to payouts is the critical path. Stripe's account links automate the compliance piece.
When a seller signs up, create a Stripe Express connected account and generate an account link. The link redirects the seller to Stripe's hosted onboarding flow, which collects required identity and banking info. After they finish, they return to your app.
async function createSellerOnboardingLink(
sellerId: string,
sellerEmail: string,
) {
// Create the connected account
const account = await stripe.accounts.create({
type: 'express',
email: sellerEmail,
metadata: {
sellerId,
},
});
// Generate a onboarding link
const accountLink = await stripe.accountLinks.create({
account: account.id,
type: 'account_onboarding',
refresh_url: 'https://yourapp.com/seller/onboarding/refresh',
return_url: 'https://yourapp.com/seller/onboarding/complete',
});
// Store the account ID in your database
await db.sellers.update(sellerId, {
stripeConnectedAccountId: account.id,
});
return accountLink.url; // Redirect seller here
}
The seller clicks the link, enters their details in Stripe's form, and gets redirected back to your return_url. At that point, check the account status via the Stripe API to confirm verification passed.
async function checkSellerOnboardingStatus(sellerId: string) {
const seller = await db.sellers.get(sellerId);
const stripeAccount = await stripe.accounts.retrieve(
seller.stripeConnectedAccountId,
);
return {
chargesEnabled: stripeAccount.charges_enabled,
payoutsEnabled: stripeAccount.payouts_enabled,
requirementsStatus: stripeAccount.requirements?.currently_due || [],
};
}
Only allow charges and transfers once charges_enabled is true. If currently_due is non-empty, the seller needs to provide additional info—generate a fresh account link to let them complete it.
Common mistakes
- Charging before account verification is complete: A seller signs up, you immediately let them list items, then a customer buys. If the seller's account isn't verified, the transfer fails and the customer's money sits in your platform account. Check
charges_enabledbefore allowing orders. - Not logging application fees: Six months later, a seller disputes their payout. You can't explain the fee deduction because you didn't store the metadata. Always log the fee amount, percentage, and reason in your database.
- Expired account links: Account links expire after 24 hours. If a seller closes the browser and returns the next day, the link is dead. Monitor
account_onboarding_refresh_url` hits and regenerate links automatically. - Missing idempotency on transfers: A network timeout causes a transfer API call to retry. Without an idempotency key, you send the seller's money twice. Always include
idempotency_keyon transfer creation. - Ignoring payout failures: A seller's bank account becomes invalid. Stripe tries to payout and fails silently in your logs. Set up webhook listeners for
account.updatedandpayout.failedto catch these.
Key takeaways
- Choose Express accounts for most consumer marketplaces. They balance onboarding friction with compliance responsibility.
- Use separate charges and transfers over destination charges. It's more explicit, supports modern payment APIs, and gives you control over payout timing.
- Log every application fee in your database with metadata. Seller disputes and audits depend on clear records.
- Verify
charges_enabledbefore allowing orders from a newly onboarded seller. - Monitor
account.updatedandpayout.failedwebhooks. Silent failures are worse than loud ones. - Use idempotency keys on all transfer and charge creation calls to prevent duplicate payouts.






Comments
Loading comments…