Back to Blog
Technical
13 min read
Chris MaskChris Mask
May 4, 2025

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:

MVP & Launch

Building your minimum viable product and preparing for market launch.

Best For Role:

Developers

Technical implementation guides and code examples for developers.

Expected Impact:

Strategic

Medium-term initiatives that build competitive advantages.

Platform: Platform Agnostic
Reading Level: Advanced

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 },
    );
  }
}
// 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

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:

  1. Start with Express accounts (Standard for MVP, migrate later)
  2. Use Destination Charge for escrow/flexible fees
  3. Handle webhooks religiously (they're not optional)
  4. Test everything in Stripe test mode first
  5. 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 Calculator
#stripe-connect
#payment-processing
#escrow
#split-payments
Found this helpful? Share it
Share:

About the Author

Chris Mask

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.