Building Marketplace Payments with Stripe Connect: Complete Implementation Guide
The complete technical guide to implementing Stripe Connect for marketplaces—from account setup to escrow, based on processing $500M+ in marketplace transactions.
Who Is This For?
This guide is specifically designed for:
Startup Stage:
Building your minimum viable product and preparing for market launch.
Best For Role:
Technical implementation guides and code examples for developers.
Expected Impact:
Medium-term initiatives that build competitive advantages.
We've processed over $500M in marketplace transactions using Stripe Connect. We've handled every edge case, fought every dispute, and debugged every webhook failure.
Here's what nobody tells you: Stripe Connect is simultaneously the easiest and hardest part of building a marketplace. For the complete picture of what you're building, start with what it actually costs to build a marketplace.
Easy because Stripe handles the complex parts: compliance, KYC, bank account verification, international payments, and fraud detection.
Hard because one wrong implementation decision locks you into patterns that become impossible to change when you have 10,000 sellers and millions in monthly GMV.
This is the guide we wish existed when we built our first marketplace payment system. Every decision point explained, every pitfall documented, every optimization battle-tested.
Stripe Connect: The Three Account Types
Before you write a single line of code, you need to understand the fundamental choice that determines everything else: Standard, Express, or Custom accounts.
Most founders pick the wrong one. Here's how to choose correctly.
Standard Accounts
What it is: Sellers create their own Stripe account. Your platform just connects to it.
Code:
const accountLink = await stripe.accountLinks.create({
account: "acct_seller_stripe_id",
refresh_url: "https://yourmarketplace.com/connect/refresh",
return_url: "https://yourmarketplace.com/connect/return",
type: "account_onboarding",
});
// Redirect seller to accountLink.url
Pros:
- •Fastest to implement (2-3 days)
- •Zero liability for payouts
- •Sellers have full Stripe dashboard access
- •Easy to get started
Cons:
- •Sellers see "Powered by Stripe" branding
- •Can't customize onboarding flow
- •Sellers can disconnect and move to competitors
- •Platform has less control
Use when:
- •MVP/testing (ship fast)
- •High-value B2B marketplaces (sellers want control)
- •International sellers (Stripe handles compliance)
Our take: We start 80% of MVPs with Standard accounts. Launch fast, validate, migrate to Express later if needed.
Express Accounts
What it is: Stripe creates sub-accounts under your platform. You control the experience.
Code:
// Create Express account
const account = await stripe.accounts.create({
type: "express",
country: "US",
email: seller.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: "individual", // or 'company'
business_profile: {
mcc: "5734", // Computer software stores
url: `https://yourmarketplace.com/sellers/${seller.username}`,
},
metadata: {
seller_id: seller.id,
seller_email: seller.email,
},
});
// Generate onboarding link
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: "https://yourmarketplace.com/onboarding/refresh",
return_url: "https://yourmarketplace.com/onboarding/complete",
type: "account_onboarding",
});
// Save to database
await db.user.update({
where: { id: seller.id },
data: {
stripeConnectId: account.id,
stripeOnboardingComplete: false,
},
});
Pros:
- •Embedded onboarding (your brand, your flow)
- •Sellers can't see Stripe dashboard (less confusion)
- •Platform controls payout schedule
- •Easier migration from Standard
Cons:
- •More liability for platform
- •Can't handle all edge cases Stripe does
- •More support burden
Use when:
- •Post-PMF with funding
- •Want full control of UX
- •Simple seller profiles (individuals, not complex businesses)
Our take: This is the sweet spot for 70% of marketplaces once they're past MVP.
Custom Accounts
What it is: You build the entire payment flow. Stripe is just the processor.
Pros:
- •Complete control
- •White-label everything
- •Handle any edge case
Cons:
- •You're responsible for compliance
- •10x more engineering work
- •Higher Stripe fees
- •PCI DSS scope increases
Use when:
- •Enterprise marketplaces with specific compliance needs
- •Financial services (you need to be the merchant of record)
- •You have a team of payments engineers
Our take: We've built Custom integrations for 3 clients out of 200+. Unless you're raising Series A+ or have regulatory requirements, don't do this.
The Complete Onboarding Flow
Here's the end-to-end implementation for Express accounts (our recommended approach):
Step 1: Create Connected Account
// app/api/seller/onboarding/create-account/route.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
export async function POST(request: Request) {
const { userId } = await request.json();
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user) {
return Response.json({ error: "User not found" }, { status: 404 });
}
// Check if already has account
if (user.stripeConnectId) {
return Response.json(
{
error: "Account already exists",
accountId: user.stripeConnectId,
},
{ status: 400 },
);
}
try {
const account = await stripe.accounts.create({
type: "express",
country: user.country || "US",
email: user.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: "individual",
business_profile: {
url: `${process.env.NEXT_PUBLIC_APP_URL}/sellers/${user.username}`,
product_description: "Marketplace seller",
},
metadata: {
user_id: user.id,
user_email: user.email,
created_via: "platform_onboarding",
},
});
// Save to database
await db.user.update({
where: { id: userId },
data: {
stripeConnectId: account.id,
isSeller: true,
},
});
return Response.json({ success: true, accountId: account.id });
} catch (error) {
console.error("Stripe account creation failed:", error);
return Response.json(
{ error: "Failed to create account" },
{ status: 500 },
);
}
}
Step 2: Generate Onboarding Link
// app/api/seller/onboarding/link/route.ts
export async function POST(request: Request) {
const { userId } = await request.json();
const user = await db.user.findUnique({
where: { id: userId },
});
if (!user?.stripeConnectId) {
return Response.json({ error: "No Stripe account found" }, { status: 404 });
}
try {
const accountLink = await stripe.accountLinks.create({
account: user.stripeConnectId,
refresh_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/onboarding?refresh=true`,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/seller/onboarding/complete`,
type: "account_onboarding",
});
return Response.json({
success: true,
url: accountLink.url,
});
} catch (error) {
console.error("Stripe account link creation failed:", error);
return Response.json(
{ error: "Failed to create onboarding link" },
{ status: 500 },
);
}
}
Step 3: Handle Onboarding Completion
// app/seller/onboarding/complete/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
export default function OnboardingComplete() {
const router = useRouter()
const [status, setStatus] = useState<'checking' | 'complete' | 'incomplete'>('checking')
useEffect(() => {
async function checkOnboardingStatus() {
const response = await fetch('/api/seller/onboarding/status')
const data = await response.json()
if (data.chargesEnabled && data.payoutsEnabled) {
setStatus('complete')
// Update user in database
await fetch('/api/seller/onboarding/mark-complete', {
method: 'POST'
})
setTimeout(() => router.push('/seller/dashboard'), 2000)
} else {
setStatus('incomplete')
}
}
checkOnboardingStatus()
}, [router])
if (status === 'checking') {
return <div>Verifying your account...</div>
}
if (status === 'incomplete') {
return (
<div>
<h1>Additional Information Required</h1>
<p>Please complete the remaining verification steps.</p>
<button onClick={() => window.location.href = '/seller/onboarding'}>
Continue Onboarding
</button>
</div>
)
}
return (
<div>
<h1>Success! Your seller account is ready</h1>
<p>Redirecting to dashboard...</p>
</div>
)
}
Step 4: Verify Account Status
// app/api/seller/onboarding/status/route.ts
export async function GET(request: Request) {
const session = await getSession();
const user = await db.user.findUnique({
where: { id: session.userId },
});
if (!user?.stripeConnectId) {
return Response.json({ error: "No Stripe account" }, { status: 404 });
}
try {
const account = await stripe.accounts.retrieve(user.stripeConnectId);
return Response.json({
chargesEnabled: account.charges_enabled,
payoutsEnabled: account.payouts_enabled,
detailsSubmitted: account.details_submitted,
requirements: account.requirements,
});
} catch (error) {
console.error("Failed to retrieve account status:", error);
return Response.json({ error: "Failed to check status" }, { status: 500 });
}
}
Payment Flows: Direct Charge vs Destination Charge vs Separate Charge & Transfer
This is where founders make expensive mistakes. Here's when to use each pattern:
Pattern 1: Direct Charge (Simplest)
Use for: Simple marketplaces where platform takes a percentage fee immediately.
// Buyer pays $100, platform keeps $10, seller gets $90
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000, // $100.00
currency: "usd",
application_fee_amount: 1000, // $10 platform fee
transfer_data: {
destination: sellerStripeAccountId,
},
metadata: {
listing_id: listing.id,
buyer_id: buyer.id,
seller_id: seller.id,
},
});
// Save transaction to database
await db.transaction.create({
data: {
listingId: listing.id,
buyerId: buyer.id,
sellerId: seller.id,
subtotalCents: 10000,
platformFeeCents: 1000,
sellerPayoutCents: 9000,
paymentIntentId: paymentIntent.id,
status: "completed",
},
});
Pros:
- •Simplest implementation
- •Seller gets paid immediately (minus platform fee)
- •Least code to maintain
Cons:
- •Can't hold funds in escrow
- •Can't change platform fee after charge
- •Refunds are complicated
Pattern 2: Destination Charge (Recommended)
Use for: Marketplaces with escrow, delivery confirmation, or complex fee structures.
// Step 1: Charge buyer on platform account
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000,
currency: "usd",
on_behalf_of: sellerStripeAccountId, // Seller is merchant of record
transfer_data: {
destination: sellerStripeAccountId,
},
metadata: {
listing_id: listing.id,
buyer_id: buyer.id,
seller_id: seller.id,
},
});
// Step 2: After delivery confirmed, transfer to seller
const transfer = await stripe.transfers.create({
amount: 9000, // $90 to seller
currency: "usd",
destination: sellerStripeAccountId,
source_transaction: paymentIntent.latest_charge,
metadata: {
transaction_id: transaction.id,
},
});
// Platform keeps $10 as application fee
const fee = await stripe.applicationFees.createRefund({
fee: transfer.destination_payment,
});
Pros:
- •Hold funds until delivery/completion
- •Adjust fees based on outcomes
- •Easier refund handling
Cons:
- •More complex code
- •More database tracking
Pattern 3: Separate Charge & Transfer (Maximum Control)
Use for: Complex escrow, milestone payments, or regulated industries.
// Step 1: Charge buyer (funds go to platform)
const charge = await stripe.charges.create({
amount: 10000,
currency: "usd",
source: buyerCardToken,
description: `Payment for ${listing.title}`,
metadata: {
listing_id: listing.id,
buyer_id: buyer.id,
},
});
// Step 2: Hold in escrow (just don't transfer yet)
await db.transaction.create({
data: {
chargeId: charge.id,
status: "escrowed",
escrowReleaseDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
// Step 3: After buyer confirms delivery, transfer to seller
const transfer = await stripe.transfers.create({
amount: 9000,
currency: "usd",
destination: sellerStripeAccountId,
source_transaction: charge.id,
description: `Payout for ${listing.title}`,
metadata: {
transaction_id: transaction.id,
},
});
// Update database
await db.transaction.update({
where: { id: transaction.id },
data: {
transferId: transfer.id,
status: "completed",
escrowReleasedAt: new Date(),
},
});
Pros:
- •Complete control over timing
- •Can handle disputes before transfer
- •Support complex workflows (milestones, etc.)
Cons:
- •Platform holds funds (more liability)
- •More state management
- •Complex error handling
Our recommendation: Start with Direct Charge for MVP. Move to Destination Charge when you add escrow. Only use Separate Charge & Transfer if you're building something like Upwork with milestone payments.
Handling Refunds and Disputes
This is where most marketplace payment systems break. Here's how to handle it correctly:
Full Refund (Before Payout)
async function processFullRefund(transactionId: string, reason: string) {
const transaction = await db.transaction.findUnique({
where: { id: transactionId },
include: { listing: true, buyer: true, seller: true },
});
if (!transaction) throw new Error("Transaction not found");
try {
// Refund the charge
const refund = await stripe.refunds.create({
payment_intent: transaction.paymentIntentId,
reason: "requested_by_customer",
metadata: {
transaction_id: transactionId,
refund_reason: reason,
},
});
// If already transferred to seller, reverse the transfer
if (transaction.transferId) {
await stripe.transfers.createReversal(transaction.transferId, {
amount: transaction.sellerPayoutCents,
});
}
// Update database
await db.transaction.update({
where: { id: transactionId },
data: {
status: "refunded",
refundId: refund.id,
refundedAt: new Date(),
},
});
// Notify buyer and seller
await sendRefundNotifications(transaction);
return { success: true, refund };
} catch (error) {
console.error("Refund failed:", error);
throw error;
}
}
Partial Refund (Restocking Fee)
async function processPartialRefund(
transactionId: string,
refundAmountCents: number,
reason: string,
) {
const transaction = await db.transaction.findUnique({
where: { id: transactionId },
});
// Example: Buyer paid $100, refund $80, platform keeps $10 fee, seller keeps $10 restocking
const refund = await stripe.refunds.create({
payment_intent: transaction.paymentIntentId,
amount: refundAmountCents, // $80
metadata: {
transaction_id: transactionId,
refund_type: "partial",
reason,
},
});
// Reverse only part of the transfer
if (transaction.transferId) {
const reverseAmount = refundAmountCents - transaction.platformFeeCents;
await stripe.transfers.createReversal(transaction.transferId, {
amount: reverseAmount,
});
}
await db.transaction.update({
where: { id: transactionId },
data: {
status: "partially_refunded",
refundAmountCents,
},
});
return { success: true, refund };
}
Handling Disputes (Chargebacks)
// Webhook handler for disputes
async function handleDisputeCreated(dispute: Stripe.Dispute) {
const transaction = await db.transaction.findFirst({
where: { chargeId: dispute.charge },
});
if (!transaction) {
console.error("Transaction not found for dispute:", dispute.id);
return;
}
// Update transaction status
await db.transaction.update({
where: { id: transaction.id },
data: {
status: "disputed",
disputeId: dispute.id,
disputeReason: dispute.reason,
},
});
// Notify seller to provide evidence
await db.notification.create({
data: {
userId: transaction.sellerId,
type: "dispute_created",
title: "Payment Dispute Filed",
message: `A buyer has disputed the payment for ${transaction.listing.title}. Please provide evidence within 7 days.`,
linkUrl: `/seller/disputes/${dispute.id}`,
},
});
// Auto-collect evidence from database
const evidence = {
customer_name: transaction.buyer.name,
customer_email_address: transaction.buyer.email,
billing_address: transaction.buyer.address,
receipt: `${process.env.NEXT_PUBLIC_APP_URL}/receipts/${transaction.id}`,
customer_signature: transaction.buyerSignature, // if available
shipping_tracking_number: transaction.trackingNumber,
};
// Submit to Stripe
await stripe.disputes.update(dispute.id, { evidence });
}
Webhooks: The Critical Infrastructure
Stripe sends webhooks for every event. Handle them correctly or lose money:
// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (error) {
console.error("Webhook signature verification failed:", error);
return Response.json({ error: "Invalid signature" }, { status: 400 });
}
// Handle the event
try {
switch (event.type) {
case "account.updated":
await handleAccountUpdated(event.data.object as Stripe.Account);
break;
case "payment_intent.succeeded":
await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent);
break;
case "payment_intent.payment_failed":
await handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
break;
case "charge.dispute.created":
await handleDisputeCreated(event.data.object as Stripe.Dispute);
break;
case "payout.paid":
await handlePayoutPaid(event.data.object as Stripe.Payout);
break;
case "payout.failed":
await handlePayoutFailed(event.data.object as Stripe.Payout);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return Response.json({ received: true });
} catch (error) {
console.error(`Webhook handler failed for ${event.type}:`, error);
return Response.json({ error: "Webhook handler failed" }, { status: 500 });
}
}
async function handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) {
const transaction = await db.transaction.findFirst({
where: { paymentIntentId: paymentIntent.id },
});
if (!transaction) {
console.error(
"Transaction not found for payment intent:",
paymentIntent.id,
);
return;
}
await db.transaction.update({
where: { id: transaction.id },
data: {
status: "completed",
completedAt: new Date(),
},
});
// Notify seller
await db.notification.create({
data: {
userId: transaction.sellerId,
type: "sale_completed",
title: "You made a sale!",
message: `Buyer purchased ${transaction.listing.title}`,
},
});
}
Testing: Don't Skip This
// Use Stripe test mode for development
const stripe = new Stripe(
process.env.NODE_ENV === "production"
? process.env.STRIPE_SECRET_KEY!
: process.env.STRIPE_TEST_SECRET_KEY!,
);
// Test cards
const testCards = {
success: "4242424242424242",
decline: "4000000000000002",
insufficientFunds: "4000000000009995",
dispute: "4000000000000259",
};
// Integration tests
describe("Payment Flow", () => {
it("should process successful payment", async () => {
const payment = await createPayment({
amount: 10000,
card: testCards.success,
});
expect(payment.status).toBe("succeeded");
});
it("should handle declined card", async () => {
await expect(
createPayment({
amount: 10000,
card: testCards.decline,
}),
).rejects.toThrow("Your card was declined");
});
});
The Bottom Line
Stripe Connect is powerful but unforgiving. The decisions you make in week one determine whether you spend week 50 refactoring payments or scaling to $10M GMV.
Key takeaways:
- •Start with Express accounts (Standard for MVP, migrate later)
- •Use Destination Charge for escrow/flexible fees
- •Handle webhooks religiously (they're not optional)
- •Test everything in Stripe test mode first
- •Monitor disputes like your revenue depends on it (it does)
We've implemented Stripe Connect for 200+ marketplaces and processed $500M+ in transactions. Every pattern in this guide is battle-tested at scale. For the broader architecture context, see our payment processing implementation guide and marketplace trust and safety guide.
Next Steps
Want to skip the Stripe integration learning curve?
- •Free payment architecture review: We'll audit your planned payment flow and flag expensive mistakes
- •Implementation: We'll build your Stripe Connect integration using these proven patterns
- •Rescue: We'll fix broken payment systems that are losing you money
Book a call and we'll show you the payment infrastructure from our last 5 marketplace builds—code, edge case handling, and dispute resolution rates.
The question isn't whether Stripe Connect works. It's whether you want to learn payments infrastructure the expensive way or skip straight to what processes millions without breaking.
How much should your build actually cost?
Get a personalized investment estimate based on your platform type, scope, and timeline.
Open the Investment CalculatorAbout the Author

Chris Mask
Founder & CEO
Serial entrepreneur, marketplace architect, and AI-assisted development pioneer with 7+ years building two-sided platforms. Founded Directorism after launching and exiting two successful marketplace businesses. Has personally architected and consulted on 200+ marketplace and directory projects. Recognized authority on cold-start problems, platform economics, marketplace SEO, and leveraging AI tools for rapid development. Early adopter of AI-powered coding workflows, integrating Claude, Cursor, and agentic development patterns into production systems.
Related Articles
No-Code Marketplace Builders: Complete Cost and Capability Guide
No-code tools like Sharetribe and Bubble enable fast marketplace launches. They're genuinely valuable for the right situations. Here's the complete picture: when they shine, when they struggle, and how to decide.
7 Architecture Decisions Every Marketplace Founder Regrets
The technical choices you make in month 1 become the constraints you fight in year 2. After rebuilding dozens of struggling marketplaces, here are the architecture decisions founders wish they'd made differently.
AI-Powered Development: A Superweapon in Expert Hands, A Trap for Everyone Else
AI is revolutionizing how we build software. In expert hands, it's a 10x productivity multiplier. But 'vibe coding'—using AI without understanding the output—has created a $400M cleanup crisis. Here's how to be on the right side of this divide.