Back to Resources
Build & Launch
Advanced
50 min
Chris MaskChris Mask
Oct 6, 2025

Implementing Marketplace Payment Processing: Complete Technical Guide

Implement split payments, escrow, and compliance in your marketplace. Learn Stripe Connect setup, webhook handling, dispute resolution, and security best practices with production-ready code examples.

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

What You'll Learn

  • Implement Stripe Connect for marketplace split payments
  • Configure connected accounts with proper KYC/AML compliance
  • Build webhook handlers for real-time payment synchronization
  • Handle refunds, disputes, and failed transfers correctly
  • Secure payment processing with PCI DSS compliance

Prerequisites

  • Basic understanding of payment processing concepts
  • Familiarity with TypeScript/Node.js
  • Understanding of async/await patterns
  • Knowledge of database transactions

Payment processing is one of the most critical—and complex—components of any marketplace. Unlike traditional e-commerce, marketplaces need to split payments between sellers and the platform while ensuring compliance, security, and a smooth user experience.

This guide provides production-ready code and architectural patterns for implementing marketplace payments with Stripe Connect.

Understanding Marketplace Payment Architecture

Marketplaces have unique payment requirements that traditional payment gateways can't handle alone.

The Split Payment Challenge

When a buyer purchases from a seller on your marketplace, the payment needs to:

  1. Charge the buyer for the full amount
  2. Collect platform fees (your commission)
  3. Transfer to the seller the remaining amount
  4. Handle refunds with proper fee adjustments
  5. Manage payouts on a schedule (daily, weekly, etc.)

Payment Flow Architecture

Buyer Payment ($100)
     ↓
Platform Charges ($100)
     ↓
Platform Fee ($10-20) → Platform Account
     ↓
Seller Payout ($80-90) → Seller Account

Choosing the Right Payment Provider

For marketplaces, Stripe Connect is the industry standard. Here's why:

Stripe Connect Advantages

  • Split payments: Automatic fee distribution
  • Compliance handling: KYC/AML built-in
  • Global support: 135+ currencies, 45+ countries
  • Flexible fee structures: Application fees or direct charges
  • Payout management: Automated or manual scheduling
  • Dashboard access: Sellers can view their earnings
  • Robust webhooks: Real-time payment updates

Alternative Providers to Consider

PayPal for Marketplaces:

  • Good for global reach and buyer trust
  • Higher fees (3.9% + $0.30)
  • Less flexible than Stripe Connect

Adyen for Platforms:

  • Enterprise-grade solution
  • Better for very high volume ($10M+ monthly)
  • More complex integration

Mangopay:

  • European-focused
  • Strong regulatory compliance
  • Limited to Europe

Recommendation: Use Stripe Connect for 90% of use cases. Only deviate if you have specific regional requirements or enterprise volume needs.

Implementing Stripe Connect

Let's walk through a complete implementation with production-ready code.

1. Create Connected Accounts for Sellers

When a seller signs up, create a connected account:

import Stripe from "stripe";
import { db } from "@/lib/database";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-11-20.acacia",
  typescript: true,
});

interface Seller {
  id: string;
  email: string;
  country: string;
  firstName: string;
  lastName: string;
}

export async function createConnectedAccount(seller: Seller) {
  // Create Stripe Connect account
  const account = await stripe.accounts.create({
    type: "express", // 'express' for easier onboarding, 'standard' for more control
    country: seller.country,
    email: seller.email,
    capabilities: {
      card_payments: { requested: true },
      transfers: { requested: true },
    },
    business_type: "individual", // or 'company' for businesses
    metadata: {
      seller_id: seller.id,
      marketplace: "your-marketplace-name",
      created_source: "seller_registration",
    },
  });

  // Save account ID to your database
  await db.sellers.update({
    where: { id: seller.id },
    data: {
      stripe_account_id: account.id,
      stripe_onboarding_completed: false,
      stripe_charges_enabled: false,
      stripe_payouts_enabled: false,
    },
  });

  // Generate onboarding link for seller to complete verification
  const accountLink = await stripe.accountLinks.create({
    account: account.id,
    refresh_url: `${process.env.APP_URL}/sellers/onboarding/refresh`,
    return_url: `${process.env.APP_URL}/sellers/dashboard`,
    type: "account_onboarding",
  });

  return {
    accountId: account.id,
    onboardingUrl: accountLink.url,
  };
}

Key decisions:

  • Express vs Standard accounts:

    • Express: Easier onboarding, Stripe handles compliance UI
    • Standard: More control, seller manages own Stripe dashboard
    • Most marketplaces use Express
  • Business type:

    • Individual: Faster onboarding, suitable for freelancers
    • Company: Required for businesses, more verification needed

2. Monitor Onboarding Status

Check if sellers have completed verification:

export async function checkOnboardingStatus(stripeAccountId: string) {
  const account = await stripe.accounts.retrieve(stripeAccountId);

  const status = {
    detailsSubmitted: account.details_submitted || false,
    chargesEnabled: account.charges_enabled || false,
    payoutsEnabled: account.payouts_enabled || false,
    requirementsCurrentlyDue: account.requirements?.currently_due || [],
    requirementsEventuallyDue: account.requirements?.eventually_due || [],
    requirementsPastDue: account.requirements?.past_due || [],
  };

  // Update database with current status
  await db.sellers.update({
    where: { stripe_account_id: stripeAccountId },
    data: {
      stripe_onboarding_completed: status.detailsSubmitted,
      stripe_charges_enabled: status.chargesEnabled,
      stripe_payouts_enabled: status.payoutsEnabled,
    },
  });

  return status;
}

3. Process Split Payments

Charge the buyer and automatically split to the seller:

interface Order {
  id: string;
  amount: number; // in cents
  currency: string;
  sellerId: string;
  buyerId: string;
  description: string;
}

export async function processMarketplacePayment(
  order: Order,
  paymentMethodId: string,
) {
  // Get seller's Stripe account
  const seller = await db.sellers.findUnique({
    where: { id: order.sellerId },
    select: { stripe_account_id: true, stripe_charges_enabled: true },
  });

  if (!seller?.stripe_account_id || !seller.stripe_charges_enabled) {
    throw new Error("Seller is not ready to accept payments");
  }

  // Calculate platform fee (e.g., 10% + $0.30)
  const platformFee = calculatePlatformFee(order.amount);

  // Create payment intent with split
  const paymentIntent = await stripe.paymentIntents.create({
    amount: order.amount,
    currency: order.currency,
    payment_method: paymentMethodId,
    confirm: true, // Immediately confirm the payment
    application_fee_amount: platformFee,
    transfer_data: {
      destination: seller.stripe_account_id,
    },
    metadata: {
      order_id: order.id,
      seller_id: order.sellerId,
      buyer_id: order.buyerId,
    },
    description: order.description,
  });

  // Save payment record to database
  await db.payments.create({
    data: {
      order_id: order.id,
      payment_intent_id: paymentIntent.id,
      amount_cents: order.amount,
      platform_fee_cents: platformFee,
      status: paymentIntent.status,
      stripe_account_id: seller.stripe_account_id,
    },
  });

  return paymentIntent;
}

function calculatePlatformFee(amountCents: number): number {
  // 10% commission + $0.30 fixed fee
  const percentageFee = Math.round(amountCents * 0.1);
  const fixedFee = 30; // $0.30 in cents
  return percentageFee + fixedFee;
}

Alternative: Separate Charge + Transfer (for Escrow)

If you need to hold funds before transferring to seller (e.g., after delivery confirmation):

export async function createEscrowPayment(
  order: Order,
  paymentMethodId: string,
) {
  // Charge buyer (funds held on platform account)
  const paymentIntent = await stripe.paymentIntents.create({
    amount: order.amount,
    currency: order.currency,
    payment_method: paymentMethodId,
    confirm: true,
    metadata: {
      order_id: order.id,
      seller_id: order.sellerId,
      buyer_id: order.buyerId,
      escrow: "true",
    },
  });

  return paymentIntent;
}

export async function releaseEscrowFunds(orderId: string) {
  const payment = await db.payments.findUnique({
    where: { order_id: orderId },
    include: { order: { include: { seller: true } } },
  });

  if (!payment?.payment_intent_id) {
    throw new Error("Payment not found");
  }

  const platformFee = calculatePlatformFee(payment.amount_cents);
  const sellerAmount = payment.amount_cents - platformFee;

  // Transfer to seller
  const transfer = await stripe.transfers.create({
    amount: sellerAmount,
    currency: payment.order.currency,
    destination: payment.order.seller.stripe_account_id!,
    metadata: {
      order_id: orderId,
      payment_intent_id: payment.payment_intent_id,
    },
  });

  // Update payment record
  await db.payments.update({
    where: { id: payment.id },
    data: {
      transfer_id: transfer.id,
      status: "transferred",
      transferred_at: new Date(),
    },
  });

  return transfer;
}

4. Handle Refunds Correctly

Refunds need to adjust platform fees appropriately:

export async function refundMarketplaceOrder(orderId: string, reason?: string) {
  const order = await db.orders.findUnique({
    where: { id: orderId },
    include: { payment: true, seller: true },
  });

  if (!order?.payment?.payment_intent_id) {
    throw new Error("Payment intent not found");
  }

  // Refund reverses the application fee automatically
  const refund = await stripe.refunds.create({
    payment_intent: order.payment.payment_intent_id,
    reverse_transfer: true, // Returns funds from connected account
    metadata: {
      order_id: orderId,
      reason: reason || "customer_request",
    },
  });

  // Update order status
  await db.orders.update({
    where: { id: orderId },
    data: {
      status: "refunded",
      refunded_at: new Date(),
      refund_reason: reason,
    },
  });

  // Update payment record
  await db.payments.update({
    where: { id: order.payment.id },
    data: {
      refund_id: refund.id,
      status: "refunded",
    },
  });

  return refund;
}

// Partial refund
export async function partialRefund(
  orderId: string,
  refundAmountCents: number,
  reason?: string,
) {
  const order = await db.orders.findUnique({
    where: { id: orderId },
    include: { payment: true },
  });

  if (!order?.payment?.payment_intent_id) {
    throw new Error("Payment not found");
  }

  // Calculate proportional platform fee refund
  const originalAmount = order.payment.amount_cents;
  const originalPlatformFee = order.payment.platform_fee_cents;
  const platformFeeRefund = Math.round(
    (refundAmountCents / originalAmount) * originalPlatformFee,
  );

  const refund = await stripe.refunds.create({
    payment_intent: order.payment.payment_intent_id,
    amount: refundAmountCents,
    reverse_transfer: true,
    refund_application_fee: true, // Refund platform fee proportionally
    metadata: {
      order_id: orderId,
      reason: reason || "partial_refund",
    },
  });

  return refund;
}

Handling Edge Cases

Real-world payment processing requires handling various failure scenarios.

Disputed Payments

Implement a dispute handling system:

// Webhook handler for disputes
export async function handleDispute(dispute: Stripe.Dispute) {
  const order = await db.orders.findFirst({
    where: { payment_intent_id: dispute.payment_intent as string },
    include: { seller: true, buyer: true },
  });

  if (!order) {
    console.error("Order not found for disputed payment:", dispute.id);
    return;
  }

  // Create dispute record
  await db.disputes.create({
    data: {
      order_id: order.id,
      stripe_dispute_id: dispute.id,
      amount_cents: dispute.amount,
      reason: dispute.reason,
      status: dispute.status,
      evidence_due_by: new Date(dispute.evidence_details?.due_by! * 1000),
    },
  });

  // Notify seller
  await sendEmail({
    to: order.seller.email,
    subject: `Payment Dispute: Order #${order.id}`,
    template: "dispute-notification",
    data: {
      orderNumber: order.id,
      disputeReason: dispute.reason,
      amount: (dispute.amount / 100).toFixed(2),
      evidenceDueBy: new Date(dispute.evidence_details?.due_by! * 1000),
      disputeDashboardUrl: `${process.env.APP_URL}/sellers/disputes/${dispute.id}`,
    },
  });

  // Update order status
  await db.orders.update({
    where: { id: order.id },
    data: { status: "disputed" },
  });
}

// Submit evidence for dispute
export async function submitDisputeEvidence(
  disputeId: string,
  evidence: {
    customerName?: string;
    customerEmailAddress?: string;
    customerPurchaseIp?: string;
    billingAddress?: string;
    receipt?: string;
    shippingDocumentation?: string;
    customerSignature?: string;
    uncategorizedText?: string;
  },
) {
  const dispute = await stripe.disputes.update(disputeId, {
    evidence,
    submit: true,
  });

  await db.disputes.update({
    where: { stripe_dispute_id: disputeId },
    data: {
      status: dispute.status,
      evidence_submitted_at: new Date(),
    },
  });

  return dispute;
}

Failed Transfers

Handle scenarios where transfers to sellers fail:

export async function handleFailedTransfer(transfer: Stripe.Transfer) {
  // Log the failure
  await db.failedTransfers.create({
    data: {
      transfer_id: transfer.id,
      seller_stripe_account: transfer.destination as string,
      amount_cents: transfer.amount,
      failure_code: transfer.failure_code,
      failure_message: transfer.failure_message,
      order_id: transfer.metadata.order_id,
    },
  });

  // Notify admin for manual review
  await notifyAdmin("Failed Transfer Alert", {
    transferId: transfer.id,
    amount: transfer.amount / 100,
    seller: transfer.destination,
    reason: transfer.failure_message,
  });

  // Update order status
  if (transfer.metadata.order_id) {
    await db.orders.update({
      where: { id: transfer.metadata.order_id },
      data: { status: "transfer_failed" },
    });
  }
}

// Retry failed transfer
export async function retryFailedTransfer(transferId: string) {
  const failedTransfer = await db.failedTransfers.findUnique({
    where: { transfer_id: transferId },
    include: { order: { include: { seller: true } } },
  });

  if (!failedTransfer?.order) {
    throw new Error("Failed transfer or order not found");
  }

  try {
    // Attempt new transfer
    const transfer = await stripe.transfers.create({
      amount: failedTransfer.amount_cents,
      currency: "usd",
      destination: failedTransfer.order.seller.stripe_account_id!,
      metadata: {
        order_id: failedTransfer.order_id,
        retry_of: transferId,
      },
    });

    // Mark as resolved
    await db.failedTransfers.update({
      where: { id: failedTransfer.id },
      data: { resolved: true, retry_transfer_id: transfer.id },
    });

    return transfer;
  } catch (error) {
    console.error("Retry failed:", error);
    throw error;
  }
}

Compliance and Security

Payment processing comes with significant compliance requirements.

PCI DSS Compliance Checklist

Stripe handles most PCI compliance, but you must:

  • Never store raw credit card numbers, CVV/CVC codes, or track data
  • Use Stripe Elements for card collection (keeps card data off your servers)
  • Implement HTTPS everywhere (enforce with HSTS headers)
  • Log access to payment data (audit trail)
  • Validate webhook signatures to prevent injection attacks
  • Use environment variables for API keys (never commit to code)

Implementation:

// ❌ NEVER DO THIS - PCI violation
const cardNumber = request.body.cardNumber;
await db.payment.create({
  data: { cardNumber }, // ILLEGAL
});

// ✅ CORRECT: Use Stripe tokens
export async function POST(request: Request) {
  const { paymentMethodId, amount, orderId } = await request.json();

  // Stripe handles the card data - you only store IDs
  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency: "usd",
    payment_method: paymentMethodId,
    confirm: true,
  });

  // You only store Stripe IDs - safe to store
  await db.payment.create({
    data: {
      order_id: orderId,
      payment_intent_id: paymentIntent.id, // Safe
      amount_cents: amount,
    },
  });
}

KYC/AML Requirements

Connected accounts must complete verification:

export async function checkAccountVerification(accountId: string) {
  const account = await stripe.accounts.retrieve(accountId);

  const requirements = {
    currentlyDue: account.requirements?.currently_due || [],
    eventuallyDue: account.requirements?.eventually_due || [],
    pastDue: account.requirements?.past_due || [],
    disabled: account.requirements?.disabled_reason,
  };

  // Account is restricted if there are past due requirements
  const isRestricted = requirements.pastDue.length > 0;

  if (isRestricted) {
    // Disable seller from creating new listings
    await db.sellers.update({
      where: { stripe_account_id: accountId },
      data: {
        account_restricted: true,
        restriction_reason: account.requirements?.disabled_reason,
      },
    });

    // Notify seller to complete verification
    await notifySeller(account.metadata.seller_id, {
      type: "verification_required",
      requirements: requirements.pastDue,
      action_url: await createAccountUpdateLink(accountId),
    });
  }

  return {
    isVerified: account.details_submitted,
    isRestricted,
    requirements,
  };
}

async function createAccountUpdateLink(accountId: string) {
  const accountLink = await stripe.accountLinks.create({
    account: accountId,
    refresh_url: `${process.env.APP_URL}/sellers/verification/refresh`,
    return_url: `${process.env.APP_URL}/sellers/dashboard`,
    type: "account_update",
  });

  return accountLink.url;
}

Payout Management

Control when and how sellers receive payments.

Configure Payout Schedules

export async function updatePayoutSchedule(
  accountId: string,
  schedule: "daily" | "weekly" | "monthly" | "manual",
) {
  const settings: Stripe.AccountUpdateParams.Settings.Payouts.Schedule = {
    interval: schedule === "manual" ? "manual" : schedule,
  };

  // Add specific day for weekly/monthly
  if (schedule === "weekly") {
    settings.weekly_anchor = "friday";
  }

  if (schedule === "monthly") {
    settings.monthly_anchor = 1; // 1st of month
  }

  await stripe.accounts.update(accountId, {
    settings: {
      payouts: {
        schedule: settings,
      },
    },
  });
}

Manual Payouts for High-Value Transactions

export async function createManualPayout(
  sellerId: string,
  amountCents: number,
  description?: string,
) {
  const seller = await db.sellers.findUnique({
    where: { id: sellerId },
  });

  if (!seller?.stripe_account_id) {
    throw new Error("Seller not connected to Stripe");
  }

  // Create payout on seller's connected account
  const payout = await stripe.payouts.create(
    {
      amount: amountCents,
      currency: "usd",
      description: description || `Manual payout to ${seller.business_name}`,
      metadata: {
        seller_id: sellerId,
        payout_type: "manual",
      },
    },
    {
      stripeAccount: seller.stripe_account_id,
    },
  );

  // Record payout in database
  await db.payouts.create({
    data: {
      seller_id: sellerId,
      stripe_payout_id: payout.id,
      amount_cents: amountCents,
      status: payout.status,
    },
  });

  return payout;
}

Webhook Implementation

Webhooks keep your system in sync with Stripe events in real-time.

Complete Webhook Handler

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { stripe } from "@/lib/stripe";

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json(
      { error: "Missing stripe-signature header" },
      { status: 400 },
    );
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err) {
    console.error("Webhook signature verification failed:", err);
    return NextResponse.json(
      { error: "Webhook signature verification failed" },
      { status: 400 },
    );
  }

  // Handle different event types
  try {
    switch (event.type) {
      case "payment_intent.succeeded":
        await handlePaymentSuccess(event.data.object);
        break;

      case "payment_intent.payment_failed":
        await handlePaymentFailure(event.data.object);
        break;

      case "charge.refunded":
        await handleRefund(event.data.object);
        break;

      case "charge.dispute.created":
        await handleDispute(event.data.object);
        break;

      case "account.updated":
        await handleAccountUpdate(event.data.object);
        break;

      case "transfer.failed":
        await handleFailedTransfer(event.data.object);
        break;

      case "payout.paid":
        await handlePayoutPaid(event.data.object);
        break;

      case "payout.failed":
        await handlePayoutFailed(event.data.object);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true }, { status: 200 });
  } catch (error) {
    console.error("Error processing webhook:", error);
    return NextResponse.json(
      { error: "Webhook processing failed" },
      { status: 500 },
    );
  }
}

async function handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
  await db.payments.update({
    where: { payment_intent_id: paymentIntent.id },
    data: { status: "succeeded" },
  });

  // Update order status
  const payment = await db.payments.findUnique({
    where: { payment_intent_id: paymentIntent.id },
  });

  if (payment?.order_id) {
    await db.orders.update({
      where: { id: payment.order_id },
      data: { status: "paid", paid_at: new Date() },
    });
  }
}

async function handlePaymentFailure(paymentIntent: Stripe.PaymentIntent) {
  await db.payments.update({
    where: { payment_intent_id: paymentIntent.id },
    data: {
      status: "failed",
      failure_code: paymentIntent.last_payment_error?.code,
      failure_message: paymentIntent.last_payment_error?.message,
    },
  });

  // Notify buyer
  const payment = await db.payments.findUnique({
    where: { payment_intent_id: paymentIntent.id },
    include: { order: { include: { buyer: true } } },
  });

  if (payment?.order?.buyer) {
    await sendEmail({
      to: payment.order.buyer.email,
      subject: "Payment Failed",
      template: "payment-failed",
      data: {
        orderId: payment.order_id,
        reason: paymentIntent.last_payment_error?.message,
      },
    });
  }
}

async function handleAccountUpdate(account: Stripe.Account) {
  const seller = await db.sellers.findFirst({
    where: { stripe_account_id: account.id },
  });

  if (!seller) return;

  await checkAccountVerification(account.id);
}

async function handlePayoutPaid(payout: Stripe.Payout) {
  await db.payouts.update({
    where: { stripe_payout_id: payout.id },
    data: {
      status: "paid",
      paid_at: new Date(payout.arrival_date * 1000),
    },
  });
}

async function handlePayoutFailed(payout: Stripe.Payout) {
  await db.payouts.update({
    where: { stripe_payout_id: payout.id },
    data: {
      status: "failed",
      failure_code: payout.failure_code,
      failure_message: payout.failure_message,
    },
  });

  // Notify seller
  const payoutRecord = await db.payouts.findUnique({
    where: { stripe_payout_id: payout.id },
    include: { seller: true },
  });

  if (payoutRecord?.seller) {
    await notifySeller(payoutRecord.seller_id, {
      type: "payout_failed",
      amount: payout.amount / 100,
      reason: payout.failure_message,
    });
  }
}

Testing Strategy

Thorough testing is essential for payment systems.

Stripe Test Card Numbers

export const testCards = {
  success: "4242424242424242",
  decline: "4000000000000002",
  insufficientFunds: "4000000000009995",
  requiresAuthentication: "4000002500003155", // 3D Secure
  expiredCard: "4000000000000069",
  incorrectCVC: "4000000000000127",
  processingError: "4000000000000119",
};

// Test in different countries
export const testCardsByCountry = {
  US: "4242424242424242",
  UK: "4000008260000000",
  CA: "4000001240000000",
  AU: "4000000360000000",
};

Integration Test Examples

// tests/payments.test.ts
import { describe, it, expect } from "vitest";

describe("Marketplace Payments", () => {
  it("should split payment correctly", async () => {
    const order = await createTestOrder({ amount: 10000 }); // $100

    const payment = await processMarketplacePayment(
      order,
      "pm_card_visa", // Test payment method
    );

    expect(payment.amount).toBe(10000);
    expect(payment.application_fee_amount).toBe(1030); // 10% + $0.30
  });

  it("should handle refunds with fee reversal", async () => {
    const order = await createTestOrderWithPayment();

    const refund = await refundMarketplaceOrder(order.id);

    expect(refund.status).toBe("succeeded");
    expect(refund.amount).toBe(order.amount);

    // Verify order status updated
    const updatedOrder = await db.orders.findUnique({
      where: { id: order.id },
    });
    expect(updatedOrder?.status).toBe("refunded");
  });

  it("should handle partial refunds correctly", async () => {
    const order = await createTestOrderWithPayment({ amount: 10000 });

    const refund = await partialRefund(order.id, 5000); // Refund $50

    expect(refund.amount).toBe(5000);
  });

  it("should prevent payments to unverified sellers", async () => {
    const unverifiedSeller = await createTestSeller({
      stripe_charges_enabled: false,
    });

    const order = await createTestOrder({ sellerId: unverifiedSeller.id });

    await expect(
      processMarketplacePayment(order, "pm_card_visa"),
    ).rejects.toThrow("Seller is not ready to accept payments");
  });

  it("should handle failed transfers", async () => {
    // Create order with invalid seller account
    const order = await createTestOrder();
    await createEscrowPayment(order, "pm_card_visa");

    // Attempt transfer to invalid account
    await expect(releaseEscrowFunds(order.id)).rejects.toThrow();

    // Verify failure logged
    const failedTransfer = await db.failedTransfers.findFirst({
      where: { order_id: order.id },
    });
    expect(failedTransfer).toBeDefined();
  });
});

Manual Testing Checklist

## Payment Flow Testing

- [ ] Create connected account successfully
- [ ] Complete onboarding flow (test mode)
- [ ] Process split payment with 10% fee
- [ ] Verify platform fee collected correctly
- [ ] Verify seller receives 90% of payment
- [ ] Process full refund
- [ ] Verify platform fee reversed on refund
- [ ] Process partial refund (50%)
- [ ] Test payment with 3D Secure card
- [ ] Handle declined card gracefully
- [ ] Test insufficient funds error
- [ ] Create escrow payment
- [ ] Release escrow after 7 days
- [ ] Test manual payout creation
- [ ] Verify webhook signature validation
- [ ] Test dispute creation and notification
- [ ] Test failed transfer handling

## Edge Cases

- [ ] Payment to unverified seller (should fail)
- [ ] Refund after transfer completed
- [ ] Dispute after refund issued
- [ ] Multiple rapid payments (rate limiting)
- [ ] Payment in different currencies
- [ ] Seller account disabled during payment
- [ ] Network failure during payment
- [ ] Webhook retry behavior

Production Deployment Checklist

Before going live with payments:

## Pre-Launch Requirements

- [ ] Move from test API keys to live API keys
- [ ] Configure live webhook endpoints
- [ ] Verify webhook signature validation works
- [ ] Set up monitoring for payment failures
- [ ] Configure alerts for failed transfers
- [ ] Test actual bank account payouts (small amount)
- [ ] Verify tax collection if required (Stripe Tax)
- [ ] Review and set payout schedule defaults
- [ ] Configure dispute email notifications
- [ ] Set up Stripe Radar rules for fraud prevention
- [ ] Document refund policy clearly
- [ ] Train support team on payment issues
- [ ] Create runbook for payment failures
- [ ] Set up backup payment processor (optional)
- [ ] Verify PCI compliance checklist complete

## Legal/Compliance

- [ ] Terms of Service include payment terms
- [ ] Privacy Policy covers payment data
- [ ] Seller agreement includes fee structure
- [ ] Refund policy documented
- [ ] Dispute resolution process defined
- [ ] Tax compliance verified (1099-K reporting if US)
- [ ] International seller compliance (if applicable)

Monitoring and Alerting

Set up monitoring for critical payment issues:

// lib/monitoring/payments.ts
export async function monitorPaymentHealth() {
  const last24Hours = new Date(Date.now() - 24 * 60 * 60 * 1000);

  // Check failed payment rate
  const totalPayments = await db.payments.count({
    where: { created_at: { gte: last24Hours } },
  });

  const failedPayments = await db.payments.count({
    where: {
      created_at: { gte: last24Hours },
      status: "failed",
    },
  });

  const failureRate = totalPayments > 0 ? failedPayments / totalPayments : 0;

  if (failureRate > 0.05) {
    // Alert if >5% failure rate
    await alertAdmin({
      severity: "high",
      message: `High payment failure rate: ${(failureRate * 100).toFixed(1)}%`,
      metric: { total: totalPayments, failed: failedPayments },
    });
  }

  // Check for stuck transfers
  const stuckTransfers = await db.payments.count({
    where: {
      created_at: { lt: new Date(Date.now() - 48 * 60 * 60 * 1000) },
      status: "succeeded",
      transfer_id: null,
    },
  });

  if (stuckTransfers > 0) {
    await alertAdmin({
      severity: "medium",
      message: `${stuckTransfers} payments not transferred after 48 hours`,
    });
  }

  // Check dispute rate
  const disputes = await db.disputes.count({
    where: { created_at: { gte: last24Hours } },
  });

  const disputeRate = totalPayments > 0 ? disputes / totalPayments : 0;

  if (disputeRate > 0.01) {
    // Alert if >1% dispute rate
    await alertAdmin({
      severity: "medium",
      message: `Elevated dispute rate: ${(disputeRate * 100).toFixed(2)}%`,
    });
  }
}

Common Issues and Solutions

Issue: "Account not found" when creating payment

Solution: Verify seller has completed onboarding and account is active:

const account = await stripe.accounts.retrieve(stripeAccountId);
if (!account.charges_enabled) {
  throw new Error("Seller account not ready for payments");
}

Issue: "Transfer failed: insufficient funds"

Solution: Ensure payment intent uses confirm: true or is confirmed before creating transfer. For escrow, wait for payment to clear.

Issue: Webhook signature verification fails

Solution: Ensure you're using raw request body (not parsed JSON):

// ✅ Correct
const body = await request.text();
const event = stripe.webhooks.constructEvent(body, signature, secret);

// ❌ Wrong
const body = await request.json();
const event = stripe.webhooks.constructEvent(body, signature, secret);

Issue: Platform fee calculation errors

Solution: Always calculate fees in cents and round:

const platformFee = Math.round(amountCents * 0.1) + 30;
// NOT: const platformFee = amountCents * 0.10 + 0.30 (floating point errors)

Conclusion

Implementing marketplace payments correctly is critical for business success. Key takeaways:

  1. Use Stripe Connect for robust split payment infrastructure
  2. Handle compliance through proper KYC/AML verification
  3. Implement webhooks for real-time payment synchronization
  4. Test thoroughly with all edge cases before launch
  5. Monitor closely for payment failures and disputes
  6. Plan for refunds and handle them gracefully
  7. Secure API keys and validate webhook signatures

Following these patterns will give you production-grade payment processing that scales from MVP to millions in GMV.

Additional Resources

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
#split-payments
#escrow
#pci-compliance
#webhooks
#payment-security
#refunds
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.

    Stripe Connect Implementation Guide for Marketplaces | Directorism | Directorism