Building a Marketplace with Stripe Connect: Platform Fees and Payouts
You've built a marketplace MVP where sellers can list products and buyers can purchase them. Now comes the hard part: actually moving money. You need to collect...
Osmoto Team
Senior Software Engineer

Introduction
You've built a marketplace MVP where sellers can list products and buyers can purchase them. Now comes the hard part: actually moving money. You need to collect payment from buyers, take your platform fee, and send the remaining funds to sellers—all while handling tax implications, compliance requirements, and the inevitable edge cases that come with multi-party payments.
This is where most marketplace founders hit a wall. Unlike simple one-to-one payment flows, marketplace payments involve three parties with competing interests: the buyer, the seller, and your platform. Get the money movement wrong, and you'll face angry sellers waiting for payouts, confused buyers dealing with refund complications, or worse—regulatory issues that could shut down your platform.
Stripe Connect solves these problems by providing three distinct account types and multiple charge flows designed specifically for marketplace scenarios. In this guide, we'll walk through implementing a production-ready marketplace payment system using Stripe Connect, covering platform fee calculations, payout timing, refund handling, and the critical security considerations that can make or break your integration.
Understanding Stripe Connect Account Types
Before writing any code, you need to choose the right Connect account type for your sellers. This decision fundamentally affects your platform's liability, user experience, and implementation complexity.
Standard Accounts
Standard accounts give sellers complete control over their Stripe dashboard. When a seller connects to your platform, they're creating or linking their own full Stripe account. They see all transactions, can issue refunds directly, and receive payouts according to their own schedule.
When to use Standard accounts:
- Your platform facilitates connections but doesn't control the transaction flow
- Sellers need full visibility into their Stripe data
- You want minimal liability for disputes and chargebacks
- Regulatory requirements demand seller autonomy
The key limitation: sellers must complete Stripe's full onboarding flow, which can create friction. They'll see Stripe branding throughout, and you have less control over the experience.
Express Accounts
Express accounts offer a middle ground. Sellers get a simplified Stripe dashboard with limited functionality, while your platform maintains more control over the payment experience. This is the most common choice for marketplaces.
When to use Express accounts:
- You want to balance control with reduced liability
- Sellers need some visibility into their transactions
- You're building a traditional marketplace (like Etsy or Airbnb)
- You want faster onboarding than Standard accounts
Express accounts still require identity verification, but the onboarding flow is embedded in your application and branded to your platform.
Custom Accounts
Custom accounts give you complete control. Sellers never interact with Stripe directly—they don't even know Stripe is powering payments. Your platform is fully responsible for onboarding, compliance, payouts, and disputes.
When to use Custom accounts:
- You need complete control over the payment experience
- Your sellers shouldn't know about Stripe
- You're willing to handle increased compliance burden
- You have the resources to manage disputes and fraud
The trade-off is significant: you become liable for chargebacks, disputes, and regulatory compliance. Most marketplaces should avoid Custom accounts unless they have a compelling reason and dedicated compliance resources.
For this guide, we'll focus on Express accounts—the sweet spot for most marketplaces.
Implementing Connect Onboarding
Your sellers need to complete identity verification before they can receive payouts. Here's how to implement a production-ready onboarding flow.
Creating Connected Accounts
First, create a Connect account when a seller signs up:
import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); async function createConnectedAccount( email: string, businessType: 'individual' | 'company' ) { const account = await stripe.accounts.create({ type: 'express', country: 'US', email: email, capabilities: { card_payments: { requested: true }, transfers: { requested: true }, }, business_type: businessType, }); return account.id; }
Store this account.id in your database associated with the seller. You'll need it for every transaction involving this seller.
Generating Account Links
Express accounts require sellers to complete an onboarding flow. Generate an account link that redirects sellers to Stripe's hosted onboarding:
async function createAccountLink( accountId: string, returnUrl: string, refreshUrl: string ) { const accountLink = await stripe.accountLinks.create({ account: accountId, refresh_url: refreshUrl, return_url: returnUrl, type: 'account_onboarding', }); return accountLink.url; }
Critical implementation detail: Account links expire after a few minutes. If a seller doesn't complete onboarding immediately, you need to generate a new link. Store the refresh_url to regenerate links when needed.
Here's a complete onboarding endpoint:
// API route: /api/seller/onboarding export async function POST(req: Request) { const { sellerId } = await req.json(); // Retrieve seller's Stripe account ID from your database const seller = await db.seller.findUnique({ where: { id: sellerId }, select: { stripeAccountId: true }, }); if (!seller?.stripeAccountId) { return Response.json({ error: 'Seller not found' }, { status: 404 }); } // Check if already onboarded const account = await stripe.accounts.retrieve(seller.stripeAccountId); if (account.charges_enabled && account.payouts_enabled) { return Response.json({ onboarded: true, message: 'Account already onboarded' }); } // Generate new account link const accountLink = await stripe.accountLinks.create({ account: seller.stripeAccountId, refresh_url: `${process.env.NEXT_PUBLIC_BASE_URL}/seller/onboarding/refresh`, return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/seller/dashboard`, type: 'account_onboarding', }); return Response.json({ url: accountLink.url }); }
Verifying Onboarding Status
After sellers complete onboarding, verify their account status before allowing them to receive payments:
async function isAccountFullyOnboarded(accountId: string): Promise<boolean> { const account = await stripe.accounts.retrieve(accountId); return ( account.charges_enabled === true && account.payouts_enabled === true && account.details_submitted === true ); }
Don't skip this check. If you create charges for accounts that haven't completed onboarding, those charges will fail or funds will be held indefinitely.
Implementing Platform Fees with Destination Charges
Now for the core functionality: taking payments from buyers and distributing funds to sellers while collecting your platform fee. Stripe Connect offers two charge models: destination charges and separate charges and transfers. We'll focus on destination charges, which work better for most marketplaces.
Basic Destination Charge
A destination charge collects payment from the buyer on your platform account, then automatically transfers the funds (minus your fee) to the seller's Connect account:
async function createDestinationCharge( amount: number, // in cents currency: string, buyerPaymentMethodId: string, sellerAccountId: string, platformFee: number, // in cents metadata: Record<string, string> ) { const paymentIntent = await stripe.paymentIntents.create({ amount, currency, payment_method: buyerPaymentMethodId, confirm: true, application_fee_amount: platformFee, transfer_data: { destination: sellerAccountId, }, metadata: { ...metadata, seller_account: sellerAccountId, }, }); return paymentIntent; }
How the money flows:
- Buyer pays $100 to your platform
- Platform keeps $10 (application fee)
- Seller receives $90 (minus Stripe processing fees)
The key parameter is application_fee_amount—this is your platform's revenue. The remaining amount (minus Stripe's processing fees) goes to the seller.
Handling Stripe Processing Fees
Here's where it gets tricky: who pays Stripe's processing fees? You have two options:
Option 1: Platform absorbs processing fees
// Buyer pays $100 // Stripe takes ~$3.20 (2.9% + $0.30) // Platform takes $10 application fee // Seller receives $86.80 const paymentIntent = await stripe.paymentIntents.create({ amount: 10000, // $100 currency: 'usd', application_fee_amount: 1000, // $10 platform fee transfer_data: { destination: sellerAccountId, }, });
With this approach, your $10 application fee is actually only ~$6.80 after Stripe's fees.
Option 2: Seller absorbs processing fees
// Buyer pays $100 // Platform takes $10 application fee (full amount) // Stripe takes ~$3.20 from seller's portion // Seller receives $86.80 const paymentIntent = await stripe.paymentIntents.create({ amount: 10000, // $100 currency: 'usd', application_fee_amount: 1000, // $10 platform fee transfer_data: { destination: sellerAccountId, }, on_behalf_of: sellerAccountId, // Key difference });
Adding on_behalf_of shifts Stripe's processing fees to the seller's Connect account. Your platform receives the full application fee.
Which should you choose? Most marketplaces use Option 2 (seller pays fees) because:
- It's more predictable for your revenue
- It's standard in the industry (sellers expect to pay processing fees)
- It simplifies accounting
Just make sure your terms of service clearly state who pays processing fees.
Dynamic Platform Fee Calculation
Real marketplaces rarely charge a flat fee. You'll likely need percentage-based fees, tiered pricing, or category-specific rates:
interface FeeCalculationParams { amount: number; sellerTier: 'bronze' | 'silver' | 'gold'; productCategory: string; } function calculatePlatformFee(params: FeeCalculationParams): number { const { amount, sellerTier, productCategory } = params; // Base percentage by seller tier const tierRates = { bronze: 0.15, // 15% silver: 0.10, // 10% gold: 0.05, // 5% }; let feePercentage = tierRates[sellerTier]; // Category adjustments if (productCategory === 'digital') { feePercentage -= 0.02; // 2% discount for digital goods } // Calculate fee let fee = Math.round(amount * feePercentage); // Minimum fee of $1 fee = Math.max(fee, 100); // Maximum fee cap of $50 fee = Math.min(fee, 5000); return fee; }
Important: Always calculate fees server-side. Never trust fee amounts from the client—this would allow buyers to manipulate your revenue.
Handling Refunds in Marketplace Transactions
Refunds in marketplaces are complex because you need to decide: does the platform refund its fee, or keep it?
Full Refund (Platform Returns Fee)
async function refundMarketplaceTransaction( paymentIntentId: string, reason?: string ) { const refund = await stripe.refunds.create({ payment_intent: paymentIntentId, reverse_transfer: true, // Reverses the transfer to seller refund_application_fee: true, // Refunds your platform fee metadata: { reason: reason || 'customer_request', }, }); return refund; }
With reverse_transfer: true and refund_application_fee: true, everyone gets their money back:
- Buyer receives full refund
- Seller's transfer is reversed
- Platform's application fee is refunded
Partial Refund (Platform Keeps Fee)
async function partialRefundKeepFee( paymentIntentId: string, refundAmount: number ) { const refund = await stripe.refunds.create({ payment_intent: paymentIntentId, amount: refundAmount, reverse_transfer: true, refund_application_fee: false, // Platform keeps fee }); return refund; }
This is common for restocking fees or when the platform incurred costs processing the order.
Seller-Initiated Refunds
If sellers can issue refunds directly (common in Express accounts), you need webhook handlers to track these:
// Webhook handler for refund events async function handleRefundCreated(refund: Stripe.Refund) { const paymentIntent = await stripe.paymentIntents.retrieve( refund.payment_intent as string ); const sellerAccountId = paymentIntent.transfer_data?.destination; // Check if refund reversed transfer if (refund.reverse_transfer) { await db.transaction.update({ where: { stripePaymentIntentId: paymentIntent.id }, data: { status: 'refunded', refundedAt: new Date(), refundAmount: refund.amount, }, }); // Notify seller await sendSellerNotification(sellerAccountId!, { type: 'refund_processed', amount: refund.amount, }); } }
For more details on webhook security and implementation patterns, see our Webhook Implementation Guide.
Managing Payout Timing and Schedules
By default, Stripe pays out Connect accounts on a rolling basis (daily for most accounts after an initial waiting period). But you often want more control.
Custom Payout Schedules
You can modify payout timing when creating the Connect account:
async function createAccountWithCustomPayouts(email: string) { const account = await stripe.accounts.create({ type: 'express', email, capabilities: { card_payments: { requested: true }, transfers: { requested: true }, }, settings: { payouts: { schedule: { interval: 'weekly', weekly_anchor: 'friday', // Pay out every Friday }, // Or for monthly payouts: // schedule: { // interval: 'monthly', // monthly_anchor: 1, // Pay out on 1st of each month // }, }, }, }); return account; }
Manual Payouts
For marketplaces that need approval workflows before releasing funds:
async function enableManualPayouts(accountId: string) { await stripe.accounts.update(accountId, { settings: { payouts: { schedule: { interval: 'manual', }, }, }, }); } async function triggerManualPayout( accountId: string, amount: number, currency: string ) { // First, verify the account has sufficient balance const balance = await stripe.balance.retrieve({ stripeAccount: accountId, }); const availableAmount = balance.available.find( (b) => b.currency === currency )?.amount || 0; if (availableAmount < amount) { throw new Error('Insufficient balance for payout'); } // Create the payout const payout = await stripe.payouts.create( { amount, currency, }, { stripeAccount: accountId, } ); return payout; }
Use case: You're building a marketplace where sellers need to complete a service before receiving payment. You hold funds in their Connect account balance, then trigger manual payouts after the buyer confirms satisfaction.
Checking Available Balance
Before triggering payouts, always check the Connect account's available balance:
async function getSellerAvailableBalance(accountId: string) { const balance = await stripe.balance.retrieve({ stripeAccount: accountId, }); // Balance includes both 'available' and 'pending' amounts return { available: balance.available.map((b) => ({ amount: b.amount, currency: b.currency, })), pending: balance.pending.map((b) => ({ amount: b.amount, currency: b.currency, })), }; }
Funds move from pending to available based on Stripe's risk assessment and your account history. New accounts typically have longer pending periods.
Implementing Multi-Currency Marketplaces
If your marketplace operates globally, you'll need to handle multiple currencies correctly.
Presentment Currency vs Settlement Currency
Presentment currency is what the buyer sees and pays in. Settlement currency is what the seller receives. These can differ:
async function createMultiCurrencyCharge( amount: number, presentmentCurrency: string, // What buyer pays sellerAccountId: string, platformFeeAmount: number ) { // Retrieve seller's default currency const account = await stripe.accounts.retrieve(sellerAccountId); const settlementCurrency = account.default_currency || 'usd'; const paymentIntent = await stripe.paymentIntents.create({ amount, currency: presentmentCurrency, application_fee_amount: platformFeeAmount, transfer_data: { destination: sellerAccountId, }, on_behalf_of: sellerAccountId, }); return paymentIntent; }
Critical detail: Stripe automatically converts currencies, but you pay conversion fees (~1%). Factor this into your platform fee calculation.
Handling Currency Conversion Fees
When buyer and seller currencies differ, someone pays the conversion fee:
function calculateFeeWithConversion( amountInBuyerCurrency: number, buyerCurrency: string, sellerCurrency: string, baseFeePct: number ): number { let platformFee = Math.round(amountInBuyerCurrency * baseFeePct); // If currencies differ, add conversion fee to platform fee if (buyerCurrency !== sellerCurrency) { const conversionFee = Math.round(amountInBuyerCurrency * 0.01); // 1% platformFee += conversionFee; } return platformFee; }
This ensures your platform doesn't lose money on currency conversion.
Critical Security Considerations
Marketplace payment implementations introduce unique security risks. Here are the non-negotiables:
Never Trust Client-Side Fee Calculations
// ❌ WRONG: Accepting fee amount from client export async function POST(req: Request) { const { amount, sellerId, platformFee } = await req.json(); // Attacker can manipulate platformFee to $0 const paymentIntent = await stripe.paymentIntents.create({ amount, application_fee_amount: platformFee, // DANGEROUS transfer_data: { destination: sellerId }, }); } // ✅ CORRECT: Calculate fees server-side export async function POST(req: Request) { const { amount, sellerId, productId } = await req.json(); // Retrieve product and seller from database const product = await db.product.findUnique({ where: { id: productId }, include: { seller: true }, }); // Verify seller owns product if (product.sellerId !== sellerId) { throw new Error('Invalid seller for product'); } // Calculate fee server-side const platformFee = calculatePlatformFee({ amount, sellerTier: product.seller.tier, productCategory: product.category, }); const paymentIntent = await stripe.paymentIntents.create({ amount, application_fee_amount: platformFee, transfer_data: { destination: product.seller.stripeAccountId }, }); }
Validate Seller Ownership
Always verify the seller owns the product before creating charges:
async function validateSellerOwnership( sellerId: string, productId: string ): Promise<boolean> { const product = await db.product.findUnique({ where: { id: productId }, select: { sellerId: true }, }); return product?.sellerId === sellerId; }
Without this check, attackers could redirect payments to their own Connect accounts.
Webhook Signature Verification
Connect webhooks are critical for tracking payout status and account updates. Always verify signatures:
import { headers } from 'next/headers'; export async function POST(req: Request) { const body = await req.text(); const signature = headers().get('stripe-signature'); if (!signature) { return Response.json({ error: 'No signature' }, { status: 400 }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_CONNECT_WEBHOOK_SECRET! ); } catch (err) { console.error('Webhook signature verification failed:', err); return Response.json({ error: 'Invalid signature' }, { status: 400 }); } // Process event... await handleConnectWebhook(event); return Response.json({ received: true }); }
For comprehensive webhook security practices, see our guide on Fixing Common Stripe Integration Security Vulnerabilities.
Rate Limiting Connect API Calls
Stripe rate limits Connect API calls more aggressively than standard API calls. Implement retry logic with exponential backoff:
async function createConnectAccountWithRetry( email: string, maxRetries = 3 ): Promise<Stripe.Account> { let lastError: Error | null = null; for (let i = 0; i < maxRetries; i++) { try { return await stripe.accounts.create({ type: 'express', email, capabilities: { card_payments: { requested: true }, transfers: { requested: true }, }, }); } catch (error) { lastError = error as Error; if ((error as any).type === 'StripeRateLimitError') { const delay = Math.pow(2, i) * 1000; // Exponential backoff await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw error; // Re-throw non-rate-limit errors } } throw lastError!; }
Common Pitfalls and How to Avoid Them
Pitfall 1: Not Handling Pending Balances
New Connect accounts often have extended pending periods. Funds from the first transactions may be held for 7-14 days before becoming available for payout.
Solution: Communicate this clearly to sellers during onboarding. Display both pending and available balances in your seller dashboard:
async function getSellerBalanceStatus(accountId: string) { const balance = await stripe.balance.retrieve({ stripeAccount: accountId, }); const account = await stripe.accounts.retrieve(accountId); return { available: balance.available, pending: balance.pending, payoutSchedule: account.settings?.payouts?.schedule, accountAge: Math.floor( (Date.now() - account.created * 1000) / (1000 * 60 * 60 * 24) ), }; }
Pitfall 2: Incorrect Refund Flow for Disputes
When buyers dispute a charge, the dispute process differs from refunds. Don't attempt to refund a disputed charge:
async function handleDisputeCreated(dispute: Stripe.Dispute) { const paymentIntent = await stripe.paymentIntents.retrieve( dispute.payment_intent as string ); // ❌ DON'T: Create a refund for disputed charges // The dispute process handles fund movement automatically // ✅ DO: Update your records and notify the seller await db.transaction.update({ where: { stripePaymentIntentId: paymentIntent.id }, data: { status: 'disputed', disputeId: dispute.id, disputeReason: dispute.reason, }, }); const sellerAccountId = paymentIntent.transfer_data?.destination; await notifySeller(sellerAccountId!, { type: 'dispute_created', disputeId: dispute.id, amount: dispute.amount, }); }
Pitfall 3: Not Accounting for Failed Payouts
Payouts can fail due to invalid bank details, closed accounts, or regulatory issues. Monitor payout failures:
async function handlePayoutFailed(payout: Stripe.Payout) { const accountId = payout.destination as string; // Log the failure await db.payoutFailure.create({ data: { sellerAccountId: accountId, payoutId: payout.id, amount: payout.amount, currency: payout.currency, failureCode: payout.failure_code, failureMessage: payout.failure_message, }, }); // Notify seller to update banking details await sendSellerEmail(accountId, { subject: 'Payout Failed - Action Required', template: 'payout-failed', data: { amount: payout.amount / 100, currency: payout.currency, failureReason: payout.failure_message, updateUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/seller/banking`, }, }); // Optionally pause future payouts until resolved await db.seller.update({ where: { stripeAccountId: accountId }, data: { payoutsEnabled: false }, }); }
Pitfall 4: Ignoring Account Capabilities Status
Just because an account is created doesn't mean it can accept charges. Always check capabilities:
async function canAccountAcceptCharges(accountId: string): Promise<boolean> { const account = await stripe.accounts.retrieve(accountId); const cardPayments = account.capabilities?.card_payments; const transfers = account.capabilities?.transfers; return ( cardPayments === 'active' && transfers === 'active' && account.charges_enabled === true ); }
Capabilities can be inactive, pending, or active. Only active capabilities allow transactions.
Best Practices Summary
Here's your implementation checklist for production-ready marketplace payments:
Account Setup:
- ✅ Choose the right Connect account type (usually Express)
- ✅ Implement account link refresh logic for expired onboarding sessions
- ✅ Verify
charges_enabledandpayouts_enabledbefore allowing transactions - ✅ Store Connect account IDs securely in your database
Payment Flow:
- ✅ Calculate platform fees server-side, never trust client input
- ✅ Use
on_behalf_ofto shift processing fees to sellers - ✅ Validate seller ownership before creating charges
- ✅ Include comprehensive metadata for transaction tracking
- ✅ Handle both presentment and settlement currencies correctly
Refunds and Disputes:
- ✅ Decide on refund policy (platform keeps fee vs. returns fee)
- ✅ Use
reverse_transferandrefund_application_feeappropriately - ✅ Don't refund disputed charges—let the dispute process handle it
- ✅ Implement webhook handlers for seller-initiated refunds
Payouts:
- ✅ Set appropriate payout schedules for your business model
- ✅ Display both pending and available balances to sellers
- ✅ Implement manual payout triggers if needed
- ✅ Monitor and handle failed payouts proactively
Security:
- ✅ Verify webhook signatures for all Connect events
- ✅ Implement rate limiting and retry logic
- ✅ Never expose seller account IDs to unauthorized users
- ✅ Validate all seller-product relationships before transactions
- ✅ Use idempotency keys for all charge creation
Monitoring:
- ✅ Track platform fee revenue separately from gross transaction volume
- ✅ Monitor payout failure rates
- ✅ Alert on accounts with extended pending periods
- ✅ Track dispute rates per seller
Conclusion
Building a marketplace with Stripe Connect involves more than just moving money—you're creating a three-sided platform where trust, compliance, and user experience all matter. The implementation patterns covered here form the foundation of a production-ready marketplace payment system.
The key takeaways: always calculate fees server-side, choose the right Connect account type for your use case, and handle edge cases like refunds, disputes, and payout failures gracefully. Your sellers depend on reliable, timely payouts, and your buyers expect seamless refund experiences when things go wrong.
As your marketplace grows, you'll encounter additional complexity: multi-currency support, tax calculation with Stripe Tax, subscription-based marketplace models, and more sophisticated fee structures. Each of these builds on the foundation we've covered here.
If you're building a marketplace and need help with your Stripe Connect implementation—whether it's architecting the initial integration, migrating from a different payment provider, or fixing issues in an existing implementation—our marketplace payment solutions can help. We specialize in production-ready Connect integrations that handle the edge cases and security considerations that make the difference between a working demo and a reliable platform.
