Web Engineering ·

Building Embedded Payment Flows with Stripe Connect: Multi-Party Payouts, Fee Splitting, and Platform Architecture

How to architect a platform payment system using Stripe Connect. Covers account types, charge models, fee splitting, payout mechanics, onboarding flows, webhook handling, dispute management, and compliance.

Building Embedded Payment Flows with Stripe Connect: Multi-Party Payouts, Fee Splitting, and Platform Architecture

Most payment integration guides stop at a single merchant collecting money. Marketplace and platform products are a different problem: you have a platform operator, one or more sellers or service providers, and end customers, all with competing interests in how money moves. Stripe Connect was designed to solve this, but its surface area is large and the tradeoffs between its three account types and three charge models are not obvious from the docs.

This article covers the architectural decisions that matter for a production platform: which account type to use and why, how the charge models differ in terms of liability and fund routing, how fee splitting actually works at the API level, payout scheduling and settlement timing, how to handle onboarding without losing users halfway through KYC, webhook patterns for payment lifecycle events, and what happens to disputes and refunds when money has already moved to a connected account. There are also production gotchas around currency conversion and cross-border payouts that are easy to overlook until a payout fails.


The Three Connect Account Types

Choosing an account type is a platform-level architectural decision. It controls who owns the Stripe relationship, who bears compliance burden, and how much UX flexibility you have.

Standard Accounts

Standard accounts are existing Stripe accounts that a user connects to your platform via OAuth. The connected account owns their Stripe relationship, handles their own tax reporting, and can receive payouts from multiple platforms. The platform gets delegated access to create charges on their behalf.

This is the right model when your sellers are businesses that already use Stripe or are sophisticated enough to manage their own account. Marketplaces for SaaS products, API resellers, or B2B platforms fit here. The onboarding flow is Stripe’s own OAuth flow, which means you have no control over the UX but also no compliance liability.

import Stripe from "stripe";

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

// Generate OAuth URL for Standard account connection
function buildConnectOAuthUrl(state: string): string {
  const params = new URLSearchParams({
    response_type: "code",
    client_id: process.env.STRIPE_CLIENT_ID!,
    scope: "read_write",
    state,
    redirect_uri: `${process.env.APP_URL}/connect/callback`,
  });

  return `https://connect.stripe.com/oauth/authorize?${params}`;
}

// Exchange OAuth code for connected account ID
async function handleOAuthCallback(code: string): Promise<string> {
  const response = await stripe.oauth.token({
    grant_type: "authorization_code",
    code,
  });

  // Store response.stripe_user_id as the connected account ID
  return response.stripe_user_id!;
}

Where Standard breaks down: you cannot customize the onboarding experience, and you cannot see or control the connected account’s payout schedule. If your platform needs tight UX control or needs to delay payouts for escrow-style flows, Standard accounts are not viable.

Express Accounts

Express accounts are created and owned by Stripe but managed through your platform. The connected account gets a hosted Stripe onboarding flow with your platform’s branding. You control the payout schedule. Stripe handles KYC, identity verification, and compliance.

This is the most common choice for consumer marketplaces (gig economy, service providers, digital creators). The tradeoff is that you are committing to Stripe’s onboarding UX, which you can brand but not deeply customize. For most platforms, this is an acceptable constraint.

// Create an Express account and generate onboarding link
async function createExpressAccount(params: {
  email: string;
  country: string;
  businessType: "individual" | "company";
}): Promise<{ accountId: string; onboardingUrl: string }> {
  const account = await stripe.accounts.create({
    type: "express",
    country: params.country,
    email: params.email,
    business_type: params.businessType,
    capabilities: {
      card_payments: { requested: true },
      transfers: { requested: true },
    },
  });

  const accountLink = await stripe.accountLinks.create({
    account: account.id,
    refresh_url: `${process.env.APP_URL}/connect/refresh?account=${account.id}`,
    return_url: `${process.env.APP_URL}/connect/return?account=${account.id}`,
    type: "account_onboarding",
  });

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

One gotcha: account links expire after a short window. If your user abandons onboarding and returns later, you need to generate a new link. The refresh_url is where Stripe redirects if the link has expired; handle it by generating a fresh link.

Custom Accounts

Custom accounts give you full control: you own the onboarding flow, collect KYC documents, and submit them to Stripe programmatically. The connected account may not even know they are on Stripe. The platform bears significantly more compliance responsibility.

Custom accounts are appropriate for embedded finance products where you have your own KYC infrastructure, or for platforms serving markets where Stripe’s hosted flows do not support the local identity documents required. The implementation cost is high: you are building identity verification UI, document collection, and handling verification state machines yourself.

// Create a Custom account and provide identity information directly
async function createCustomAccount(params: {
  email: string;
  country: string;
  firstName: string;
  lastName: string;
  dateOfBirth: { day: number; month: number; year: number };
  address: {
    line1: string;
    city: string;
    state: string;
    postalCode: string;
  };
}): Promise<string> {
  const account = await stripe.accounts.create({
    type: "custom",
    country: params.country,
    email: params.email,
    business_type: "individual",
    individual: {
      first_name: params.firstName,
      last_name: params.lastName,
      dob: params.dateOfBirth,
      address: {
        line1: params.address.line1,
        city: params.address.city,
        state: params.address.state,
        postal_code: params.address.postalCode,
        country: params.country,
      },
    },
    capabilities: {
      card_payments: { requested: true },
      transfers: { requested: true },
    },
    tos_acceptance: {
      date: Math.floor(Date.now() / 1000),
      ip: "1.2.3.4", // use real IP from request
    },
  });

  return account.id;
}

Where Custom breaks down: you become responsible for Stripe’s terms of service acceptance, anti-money-laundering screening, and document verification. Stripe will still run their own verifications, but you are the first line of responsibility. For most product companies, this burden is not worth it unless embedded finance is a core product differentiator.


Charge Models: How Money Actually Moves

Once you have connected accounts, you need to decide how charges flow. There are three models, each with different implications for liability, refund handling, and fee splitting.

Direct Charges

The charge is created directly on the connected account. The connected account is the merchant of record. Your platform collects an application fee from the charge at creation time.

// Direct charge: connected account is charged, platform collects fee
async function createDirectCharge(params: {
  amount: number; // in cents
  currency: string;
  paymentMethodId: string;
  connectedAccountId: string;
  platformFeeAmount: number; // in cents
}): Promise<Stripe.PaymentIntent> {
  return stripe.paymentIntents.create(
    {
      amount: params.amount,
      currency: params.currency,
      payment_method: params.paymentMethodId,
      application_fee_amount: params.platformFeeAmount,
      confirm: true,
      automatic_payment_methods: { enabled: true },
    },
    {
      stripeAccount: params.connectedAccountId,
    }
  );
}

With direct charges, Stripe fees come out of the connected account’s balance. Disputes are the connected account’s liability. The platform only sees the application_fee_amount. This model makes sense when connected accounts are established businesses that should own the customer relationship.

Destination Charges

The charge is created on the platform account. The platform routes funds to a connected account using the transfer_data parameter. The platform is the merchant of record.

// Destination charge: platform charges customer, routes funds to connected account
async function createDestinationCharge(params: {
  amount: number;
  currency: string;
  paymentMethodId: string;
  connectedAccountId: string;
  transferAmount: number; // how much the connected account receives
  idempotencyKey: string;
}): Promise<Stripe.PaymentIntent> {
  return stripe.paymentIntents.create(
    {
      amount: params.amount,
      currency: params.currency,
      payment_method: params.paymentMethodId,
      transfer_data: {
        destination: params.connectedAccountId,
        amount: params.transferAmount,
      },
      confirm: true,
      automatic_payment_methods: { enabled: true },
    },
    {
      idempotencyKey: params.idempotencyKey,
    }
  );
}

The platform fee is implicit: params.amount - params.transferAmount stays on the platform. Stripe processing fees come out of the platform account. Disputes are the platform’s liability. This is the right model for most consumer marketplaces where the platform wants to own the customer relationship and simplify the seller’s experience.

Separate Charges and Transfers

The charge and the transfer are two independent API calls. The platform captures the charge, holds funds in the platform balance, and creates transfers to one or more connected accounts later. This enables multi-party payouts from a single charge and delayed settlement.

// Separate charge and transfer: flexible fund distribution
async function createChargeAndDistribute(params: {
  amount: number;
  currency: string;
  paymentMethodId: string;
  idempotencyKey: string;
  recipients: Array<{
    accountId: string;
    amount: number;
    description: string;
  }>;
}): Promise<{ paymentIntentId: string; transferIds: string[] }> {
  // Step 1: Create the charge on the platform
  const paymentIntent = await stripe.paymentIntents.create(
    {
      amount: params.amount,
      currency: params.currency,
      payment_method: params.paymentMethodId,
      confirm: true,
      automatic_payment_methods: { enabled: true },
    },
    { idempotencyKey: `charge-${params.idempotencyKey}` }
  );

  // Step 2: Distribute to connected accounts
  const transferIds: string[] = [];

  for (const recipient of params.recipients) {
    const transfer = await stripe.transfers.create(
      {
        amount: recipient.amount,
        currency: params.currency,
        destination: recipient.accountId,
        description: recipient.description,
        source_transaction: paymentIntent.latest_charge as string,
      },
      { idempotencyKey: `transfer-${params.idempotencyKey}-${recipient.accountId}` }
    );
    transferIds.push(transfer.id);
  }

  return { paymentIntentId: paymentIntent.id, transferIds };
}

The source_transaction parameter ties the transfer to the originating charge, which is critical for dispute handling and reconciliation. Omitting it means the transfer draws from your platform balance regardless of whether the source charge has settled.


Account Type and Charge Model Tradeoffs

DimensionStandardExpressCustom
Onboarding ownershipStripe OAuthStripe hosted (branded)Platform-owned
KYC/compliance burdenConnected accountStripePlatform
Payout schedule controlNoneFullFull
UX customizationNoneLimited (branding)Full
Implementation effortLowMediumHigh
Best charge modelDirectDestinationSeparate charges
Merchant of recordConnected accountPlatformPlatform
Dispute liabilityConnected accountPlatformPlatform
1099 issuerStripe (to connected)Platform (via Stripe)Platform
Cross-border supportWherever connectedStripe-supported countriesStripe-supported countries
DimensionDirect ChargeDestination ChargeSeparate Charge + Transfer
Merchant of recordConnected accountPlatformPlatform
Stripe fee bearerConnected accountPlatformPlatform
Dispute liabilityConnected accountPlatformPlatform
Multi-party payoutNo (single fee)No (single destination)Yes (multiple transfers)
Settlement timingImmediate to connectedConfigurable delayControlled by platform
Refund complexityStraightforwardPlatform must reverse transferPlatform must reverse each transfer
Use caseB2B marketplacesConsumer marketplacesEscrow, splits, delayed payouts

Fee Splitting Mechanics

Platform fees are not a single number. You are splitting the gross charge amount across: Stripe processing fees, your platform margin, and the connected account’s net payout. Getting this calculation wrong in either direction creates reconciliation problems.

interface FeeCalculation {
  grossAmount: number;
  stripeFeeAmount: number; // 2.9% + 30 cents for US cards
  platformFeeAmount: number;
  connectedAccountNet: number;
}

function calculateFees(params: {
  grossAmount: number; // in cents
  platformFeePercent: number; // e.g., 0.05 for 5%
  platformFeeFlat: number; // in cents, e.g., 50 for $0.50
  coverStripeFeesOnPlatform: boolean;
}): FeeCalculation {
  // Stripe domestic card processing: 2.9% + $0.30
  const stripePercent = 0.029;
  const stripeFlat = 30; // cents

  const stripeFeeAmount = Math.round(
    params.grossAmount * stripePercent + stripeFlat
  );

  const platformFeeAmount =
    Math.round(params.grossAmount * params.platformFeePercent) +
    params.platformFeeFlat;

  let connectedAccountNet: number;

  if (params.coverStripeFeesOnPlatform) {
    // Platform absorbs Stripe fee, connected account gets gross minus platform fee
    connectedAccountNet = params.grossAmount - platformFeeAmount;
  } else {
    // Connected account absorbs Stripe fee (for direct charges)
    connectedAccountNet =
      params.grossAmount - platformFeeAmount - stripeFeeAmount;
  }

  return {
    grossAmount: params.grossAmount,
    stripeFeeAmount,
    platformFeeAmount,
    connectedAccountNet,
  };
}

If your platform wants to absorb Stripe fees so that sellers see clean payout amounts, use destination charges and set transfer_data.amount to the seller’s net. If you want sellers to bear Stripe fees, use direct charges and set application_fee_amount to only your platform margin.


Webhook Handling for Payment Lifecycle Events

A platform payment has multiple actors and multiple events. You need to handle events at the platform account level and, for Express and Custom accounts, also at the connected account level.

import { type Request, type Response } from "express";

const PLATFORM_EVENTS = new Set([
  "payment_intent.succeeded",
  "payment_intent.payment_failed",
  "charge.dispute.created",
  "transfer.created",
  "transfer.failed",
  "account.updated", // connected account status changes
]);

const CONNECTED_EVENTS = new Set([
  "payment_intent.succeeded",
  "payout.paid",
  "payout.failed",
]);

async function handleWebhook(req: Request, res: Response): Promise<void> {
  const sig = req.headers["stripe-signature"] as string;
  const isConnectedEvent = !!req.headers["stripe-account"];
  const connectedAccountId = req.headers["stripe-account"] as string | undefined;

  const secret = isConnectedEvent
    ? process.env.STRIPE_CONNECT_WEBHOOK_SECRET!
    : process.env.STRIPE_WEBHOOK_SECRET!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, secret);
  } catch (err) {
    res.status(400).send("Webhook signature verification failed");
    return;
  }

  // Acknowledge receipt immediately, process asynchronously
  res.status(200).json({ received: true });

  if (isConnectedEvent && connectedAccountId) {
    await processConnectedAccountEvent(event, connectedAccountId);
  } else {
    await processPlatformEvent(event);
  }
}

async function processPlatformEvent(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case "payment_intent.succeeded": {
      const pi = event.data.object as Stripe.PaymentIntent;
      await db.payments.updateStatus(pi.id, "succeeded");
      await triggerTransfersIfReady(pi);
      break;
    }
    case "charge.dispute.created": {
      const dispute = event.data.object as Stripe.Dispute;
      await notifyOpsTeam(dispute);
      await holdConnectedAccountPayout(dispute);
      break;
    }
    case "account.updated": {
      const account = event.data.object as Stripe.Account;
      await syncAccountCapabilities(account);
      break;
    }
  }
}

Register separate webhook endpoints for platform events and connected account events in the Stripe dashboard. The stripe-account header in the request tells you which connected account generated the event.


Disputes and Refunds Across Connected Accounts

Disputes are the operational pain point that most platform architectures underestimate.

With destination charges or separate charges, the platform is the merchant of record. When a customer disputes a charge, Stripe debits the platform account. If you have already transferred funds to a connected account, you need to reverse the transfer before the dispute window closes.

async function handleDisputeCreated(
  dispute: Stripe.Dispute
): Promise<void> {
  // Retrieve the original payment intent to find associated transfers
  const charge = await stripe.charges.retrieve(dispute.charge as string, {
    expand: ["transfer"],
  });

  const transfer = charge.transfer as Stripe.Transfer | null;

  if (transfer && transfer.destination) {
    // Reverse the transfer to recover funds for dispute
    await stripe.transfers.createReversal(transfer.id, {
      amount: transfer.amount,
      description: `Dispute reversal for charge ${charge.id}`,
    });

    await db.disputes.create({
      disputeId: dispute.id,
      chargeId: charge.id,
      transferId: transfer.id,
      connectedAccountId: transfer.destination as string,
      amount: dispute.amount,
      reason: dispute.reason,
      status: "under_review",
    });
  }
}

async function submitDisputeEvidence(params: {
  disputeId: string;
  evidenceFiles: string[];
  customerCommunication: string;
  serviceDate: string;
}): Promise<void> {
  await stripe.disputes.update(params.disputeId, {
    evidence: {
      customer_communication: params.customerCommunication,
      service_date: params.serviceDate,
      uncategorized_file: params.evidenceFiles[0],
    },
    submit: true,
  });
}

For refunds on destination charges, you need to explicitly set reverse_transfer: true to pull the funds back from the connected account. Otherwise Stripe deducts the refund from the platform balance, and you are out of pocket for money that is sitting in the connected account.

async function refundWithTransferReversal(params: {
  chargeId: string;
  amount?: number;
}): Promise<Stripe.Refund> {
  return stripe.refunds.create({
    charge: params.chargeId,
    amount: params.amount,
    reverse_transfer: true,
    refund_application_fee: true,
  });
}

Payout Scheduling and Settlement Timing

By default, Stripe holds funds for a rolling period (2 days for US, 7 days for many other countries) before initiating a payout to the connected account’s bank. For Express and Custom accounts, you can override this.

async function configurePayoutSchedule(params: {
  accountId: string;
  schedule:
    | { interval: "daily" }
    | { interval: "weekly"; weeklyAnchor: string }
    | { interval: "monthly"; monthlyAnchor: number }
    | { interval: "manual" };
}): Promise<void> {
  await stripe.accounts.update(params.accountId, {
    settings: {
      payouts: {
        schedule: params.schedule as Stripe.AccountUpdateParams.Settings.Payouts.Schedule,
        debit_negative_balances: true,
      },
    },
  });
}

// Manual payouts give you full control over timing
async function triggerManualPayout(params: {
  accountId: string;
  amount: number;
  currency: string;
  description: string;
}): Promise<Stripe.Payout> {
  return stripe.payouts.create(
    {
      amount: params.amount,
      currency: params.currency,
      description: params.description,
    },
    {
      stripeAccount: params.accountId,
    }
  );
}

For escrow-style platforms (real estate deposits, service completion holds), set the payout schedule to manual and trigger payouts programmatically after your release conditions are met. Keep a state machine in your database that tracks the payment state: funded, held, released, refunded.


Production Considerations

Currency conversion and cross-border payouts. If your platform operates in multiple currencies, the settlement currency matters. Stripe converts funds at the time of transfer using their rate. You cannot lock a rate in advance. For platforms with significant cross-border volume, you will need a reconciliation layer that accounts for rate differences between charge time and settlement time. Stripe charges a 1% conversion fee in addition to processing fees. Build this into your fee model explicitly.

Payout failures. Bank account validation at onboarding does not guarantee future payout success. Banks close accounts, routing numbers change, and daily limits exist. Implement a payout.failed webhook handler that notifies the connected account and triggers a retry flow. Unresolved payout failures block future payouts to that account.

Account verification gaps. Connected accounts can begin accepting payments before full KYC is complete (Stripe allows a payment volume threshold before requiring verification). Track requirements.eventually_due and requirements.currently_due on each account. When currently_due fields are non-empty, the account’s ability to accept charges or receive payouts is at risk. Send proactive onboarding reminders before Stripe disables the account.

async function checkAccountRequirements(accountId: string): Promise<{
  canCharge: boolean;
  canPayout: boolean;
  pendingFields: string[];
}> {
  const account = await stripe.accounts.retrieve(accountId);

  const canCharge =
    account.capabilities?.card_payments === "active";
  const canPayout =
    account.capabilities?.transfers === "active";
  const pendingFields = account.requirements?.currently_due ?? [];

  return { canCharge, canPayout, pendingFields };
}

1099 reporting. For US platforms using Express or Custom accounts, you are responsible for issuing 1099-K forms to connected accounts that exceed IRS thresholds (currently $5,000 per year, changing to $600 over time). Stripe provides 1099 generation in the dashboard for Express accounts. For Custom accounts, you need to track gross payment volume per connected account and use Stripe’s Tax API or export the data to your own 1099 workflow. Build annual gross payment volume tracking into your data model from day one.

Idempotency on transfers. Use idempotency keys on every transfer creation. Transfer retries without idempotency keys result in duplicate payouts that are difficult to reverse once the payout has settled to a bank account. Key on a combination of the source charge ID and the recipient account ID.

Rate limits. Stripe Connect platforms with many connected accounts hit rate limits when triggering bulk transfers (for example, weekly marketplace payouts). Use exponential backoff and distribute transfer creation over time rather than attempting to create hundreds of transfers in parallel.


Decision Framework

Start here: what kind of entities are your connected accounts?

  • Are they existing businesses with their own Stripe accounts? Use Standard. The OAuth flow is good enough and you avoid compliance overhead.
  • Are they consumers or small sellers who should not need to understand Stripe? Use Express. You control the payout schedule, Stripe owns KYC, and the onboarding UX is acceptable.
  • Is embedded finance a core product differentiator and do you already have KYC infrastructure? Use Custom. Otherwise the implementation cost is not justified.

For the charge model:

  • Does the connected account need to own the customer relationship and handle their own disputes? Use direct charges.
  • Does your platform need to own the customer relationship, and do you have a single seller per transaction? Use destination charges.
  • Do you have multiple recipients per transaction, escrow requirements, or delayed settlement? Use separate charges and transfers.

Closing

Stripe Connect is genuinely well-designed for the problems it addresses, but the abstraction is leaky in the places that matter most: dispute liability, transfer reversals, and cross-border settlement. Getting the account type and charge model wrong early is expensive to refactor because it affects your data model, your webhook logic, and your compliance posture simultaneously. Make the architectural choice based on who should be the merchant of record, then let the charge model follow from that decision.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.