Back to Blog
πŸ’°Fintech

Stripe Connect in 2026: Platform Fees, Payouts, and Express Onboarding

Build a Stripe Connect marketplace: Express and Custom account onboarding, platform fees, automatic payouts, transfer reversals, and webhook handling for multi-party payments.

Viprasol Tech Team
December 23, 2026
14 min read

Stripe Connect in 2026: Platform Fees, Payouts, and Express Onboarding

Stripe Connect is the payment infrastructure for marketplaces, SaaS platforms, and any product where money flows from a customer through your platform to a third-party vendor or service provider. It's significantly more complex than a basic Stripe integrationβ€”you're now responsible for onboarding connected accounts, routing funds, taking platform fees, handling KYC requirements, and reconciling payouts across potentially thousands of sellers.

This post covers the complete production implementation: account type decisions, Express onboarding flow, charge routing with application fees, automatic payouts, webhook handling for Connect events, and the database schema that ties it together.


Account Type Decision

Account TypeBest ForPlatform ControlStripe BrandingOnboarding Handled By
ExpressMarketplaces, gig platformsMediumStripe-hostedStripe
StandardB2B platformsLowStripeSeller
CustomFull white-labelFullNoneYou

Use Express for 90% of marketplace use cases. Stripe handles KYC, identity verification, bank account collection, and the dashboard for sellers. You get webhook notifications when accounts are verified.

Use Custom only when you need full control over the onboarding UX and are prepared to handle KYC, data storage, and compliance obligations yourself.


Database Schema

-- migrations/20260101_connect_accounts.sql

CREATE TYPE connect_account_status AS ENUM (
  'pending',          -- Created, onboarding not started
  'onboarding',       -- Onboarding link sent, not complete
  'restricted',       -- Payouts blocked (needs more info)
  'active',           -- Fully verified, payouts enabled
  'rejected',         -- Stripe rejected the account
  'deactivated'       -- Platform deactivated
);

CREATE TABLE connect_accounts (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id               UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  stripe_account_id     TEXT NOT NULL UNIQUE,   -- acct_1234...
  account_type          TEXT NOT NULL DEFAULT 'express',
  status                connect_account_status NOT NULL DEFAULT 'pending',
  
  -- Stripe capability flags
  charges_enabled       BOOLEAN NOT NULL DEFAULT FALSE,
  payouts_enabled       BOOLEAN NOT NULL DEFAULT FALSE,
  details_submitted     BOOLEAN NOT NULL DEFAULT FALSE,
  
  -- Verification info
  country               CHAR(2),
  currency              CHAR(3),
  business_type         TEXT,                   -- individual | company
  
  -- Metadata
  onboarding_url        TEXT,
  onboarding_expires_at TIMESTAMPTZ,
  requirements          JSONB NOT NULL DEFAULT '[]'::jsonb,
  metadata              JSONB NOT NULL DEFAULT '{}'::jsonb,
  
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at            TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE platform_transfers (
  id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  order_id              UUID NOT NULL REFERENCES orders(id),
  connect_account_id    UUID NOT NULL REFERENCES connect_accounts(id),
  
  -- Stripe IDs
  payment_intent_id     TEXT NOT NULL,
  charge_id             TEXT,
  transfer_id           TEXT,
  
  -- Amounts (all in smallest currency unit)
  gross_amount          INTEGER NOT NULL,        -- Customer paid
  platform_fee          INTEGER NOT NULL,        -- Platform takes
  net_amount            INTEGER NOT NULL,        -- Seller receives (gross - fee)
  currency              CHAR(3) NOT NULL DEFAULT 'usd',
  
  -- State
  status                TEXT NOT NULL DEFAULT 'pending',  -- pending|succeeded|failed|reversed
  reversed_at           TIMESTAMPTZ,
  reversal_reason       TEXT,
  
  created_at            TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_transfers_connect_account ON platform_transfers(connect_account_id);
CREATE INDEX idx_transfers_order ON platform_transfers(order_id);

πŸ’³ Fintech That Passes Compliance β€” Not Just Demos

Payment integrations, KYC/AML flows, trading APIs, and regulatory compliance β€” we build fintech that survives real audits, not just product demos.

  • PCI DSS, PSD2, FCA, GDPR-aware architecture
  • Stripe, Plaid, Rapyd, OpenBanking integrations
  • Real-time transaction monitoring and fraud flags
  • UK/EU/US compliance requirements mapped from day one

Create Express Account and Onboarding Link

// lib/stripe/connect.ts
import Stripe from "stripe";
import { db } from "@/lib/db";

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

export async function createConnectAccount(
  userId: string,
  email: string,
  country: string = "US"
): Promise<{ stripeAccountId: string; onboardingUrl: string }> {
  // Create the Express account
  const account = await stripe.accounts.create({
    type: "express",
    country,
    email,
    capabilities: {
      card_payments: { requested: true },
      transfers: { requested: true },
    },
    business_profile: {
      url: process.env.NEXT_PUBLIC_APP_URL,
      mcc: "7372", // Software
    },
    settings: {
      payouts: {
        schedule: {
          interval: "weekly",
          weekly_anchor: "monday",
        },
        debit_negative_balances: true,
      },
    },
    metadata: {
      platformUserId: userId,
    },
  });

  // Save to DB
  await db
    .insertInto("connect_accounts")
    .values({
      user_id: userId,
      stripe_account_id: account.id,
      account_type: "express",
      status: "pending",
      country,
      metadata: { email },
    })
    .execute();

  // Generate onboarding link
  const onboardingUrl = await createOnboardingLink(account.id);

  return { stripeAccountId: account.id, onboardingUrl };
}

export async function createOnboardingLink(
  stripeAccountId: string
): Promise<string> {
  const appUrl = process.env.NEXT_PUBLIC_APP_URL!;

  const accountLink = await stripe.accountLinks.create({
    account: stripeAccountId,
    refresh_url: `${appUrl}/dashboard/payouts/onboarding/refresh?account=${stripeAccountId}`,
    return_url: `${appUrl}/dashboard/payouts/onboarding/complete`,
    type: "account_onboarding",
    collect: "eventually_due", // Collect all eventually-due requirements
  });

  // Store link with expiry (links expire in 5 minutes)
  await db
    .updateTable("connect_accounts")
    .set({
      onboarding_url: accountLink.url,
      onboarding_expires_at: new Date(accountLink.expires_at * 1000),
      status: "onboarding",
      updated_at: new Date(),
    })
    .where("stripe_account_id", "=", stripeAccountId)
    .execute();

  return accountLink.url;
}

API route:

// app/api/connect/accounts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { createConnectAccount, createOnboardingLink } from "@/lib/stripe/connect";
import { db } from "@/lib/db";

export async function POST(req: NextRequest) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { country = "US" } = await req.json();

  // Check if already has a Connect account
  const existing = await db
    .selectFrom("connect_accounts")
    .select(["id", "stripe_account_id", "status", "onboarding_url", "onboarding_expires_at"])
    .where("user_id", "=", user.id)
    .executeTakeFirst();

  if (existing) {
    if (existing.status === "active") {
      return NextResponse.json({ error: "Already onboarded" }, { status: 409 });
    }

    // Regenerate onboarding link if expired or not started
    const isExpired = !existing.onboarding_expires_at || 
                      new Date() > existing.onboarding_expires_at;

    if (isExpired) {
      const onboardingUrl = await createOnboardingLink(existing.stripe_account_id);
      return NextResponse.json({ onboardingUrl });
    }

    return NextResponse.json({ onboardingUrl: existing.onboarding_url });
  }

  const result = await createConnectAccount(user.id, user.email, country);

  return NextResponse.json(result, { status: 201 });
}

Charge with Application Fee (Destination Charges)

The most common Connect pattern: customer pays your platform, you route to the seller and take a fee.

// lib/stripe/charges.ts
import Stripe from "stripe";
import { db } from "@/lib/db";

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

interface CreatePlatformChargeParams {
  orderId: string;
  sellerId: string;          // Your internal user ID for the seller
  grossAmountCents: number;  // Total customer pays
  platformFeePercent: number; // e.g., 0.1 = 10%
  currency: string;
  customerId: string;        // Stripe customer ID
  paymentMethodId: string;
  description: string;
  metadata?: Record<string, string>;
}

export async function createPlatformCharge({
  orderId,
  sellerId,
  grossAmountCents,
  platformFeePercent,
  currency,
  customerId,
  paymentMethodId,
  description,
  metadata = {},
}: CreatePlatformChargeParams) {
  // Look up seller's Stripe account
  const connectAccount = await db
    .selectFrom("connect_accounts")
    .select(["id", "stripe_account_id", "charges_enabled", "payouts_enabled"])
    .where("user_id", "=", sellerId)
    .where("status", "=", "active")
    .executeTakeFirst();

  if (!connectAccount) {
    throw new Error("Seller has no active Connect account");
  }

  if (!connectAccount.charges_enabled) {
    throw new Error("Seller's charges are not enabled");
  }

  const applicationFeeAmount = Math.round(grossAmountCents * platformFeePercent);
  const netAmount = grossAmountCents - applicationFeeAmount;

  // Create PaymentIntent on the platform account but route to connected account
  const paymentIntent = await stripe.paymentIntents.create({
    amount: grossAmountCents,
    currency,
    customer: customerId,
    payment_method: paymentMethodId,
    confirm: true,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/orders/${orderId}/complete`,

    // Route to the connected account
    transfer_data: {
      destination: connectAccount.stripe_account_id,
    },

    // Platform fee (taken before transfer)
    application_fee_amount: applicationFeeAmount,

    description,
    metadata: {
      orderId,
      sellerId,
      platformFeePercent: String(platformFeePercent),
      ...metadata,
    },

    // Automatically confirm payment
    automatic_payment_methods: {
      enabled: false,
    },
  });

  // Record the transfer
  await db
    .insertInto("platform_transfers")
    .values({
      order_id: orderId,
      connect_account_id: connectAccount.id,
      payment_intent_id: paymentIntent.id,
      gross_amount: grossAmountCents,
      platform_fee: applicationFeeAmount,
      net_amount: netAmount,
      currency,
      status: paymentIntent.status === "succeeded" ? "succeeded" : "pending",
    })
    .execute();

  return {
    paymentIntentId: paymentIntent.id,
    status: paymentIntent.status,
    clientSecret: paymentIntent.client_secret,
    grossAmount: grossAmountCents,
    platformFee: applicationFeeAmount,
    netAmount,
  };
}

🏦 Trading Systems, Payment Rails, and Financial APIs

From algorithmic trading platforms to neobank backends β€” Viprasol has built the full spectrum of fintech. Senior engineers, no junior handoffs, verified track record.

  • MT4/MT5 EA development for prop firms and hedge funds
  • Custom payment gateway and wallet systems
  • Regulatory reporting automation (MiFID, EMIR)
  • Free fintech architecture consultation

Separate Charges and Transfers

For delayed or conditional transfers (e.g., after service delivery confirmation):

// lib/stripe/transfers.ts

export async function chargeAndHold(
  grossAmountCents: number,
  currency: string,
  customerId: string,
  paymentMethodId: string,
  orderId: string
) {
  // Charge the customer to your platform account (no transfer yet)
  const paymentIntent = await stripe.paymentIntents.create({
    amount: grossAmountCents,
    currency,
    customer: customerId,
    payment_method: paymentMethodId,
    confirm: true,
    // No transfer_data β€” funds stay on platform
    metadata: { orderId, type: "hold" },
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/orders/${orderId}/complete`,
  });

  return paymentIntent;
}

export async function releaseToSeller(
  chargeId: string,
  stripeAccountId: string,
  netAmountCents: number,
  currency: string,
  orderId: string
) {
  // Transfer from platform to seller after delivery confirmation
  const transfer = await stripe.transfers.create({
    amount: netAmountCents,
    currency,
    destination: stripeAccountId,
    source_transaction: chargeId,  // Links transfer to original charge
    metadata: { orderId },
  });

  return transfer;
}

export async function reverseTransfer(
  transferId: string,
  amountCents?: number,   // Partial reversal if specified
  reason?: string
) {
  const reversal = await stripe.transfers.createReversal(transferId, {
    amount: amountCents, // undefined = full reversal
    metadata: { reason: reason ?? "refund" },
  });

  await db
    .updateTable("platform_transfers")
    .set({
      status: "reversed",
      reversed_at: new Date(),
      reversal_reason: reason ?? "refund",
    })
    .where("transfer_id", "=", transferId)
    .execute();

  return reversal;
}

Connect Webhooks

Connect events are sent to a separate webhook endpoint configured for your platform account:

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

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

// IMPORTANT: Use the CONNECT webhook secret, not the standard one
const CONNECT_WEBHOOK_SECRET = process.env.STRIPE_CONNECT_WEBHOOK_SECRET!;

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

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, CONNECT_WEBHOOK_SECRET);
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  // Connect events include an `account` property identifying the connected account
  const stripeAccountId = (event as any).account as string | undefined;

  try {
    switch (event.type) {
      case "account.updated":
        await handleAccountUpdated(event.data.object as Stripe.Account, stripeAccountId);
        break;

      case "account.application.deauthorized":
        await handleDeauthorized(stripeAccountId!);
        break;

      case "payment_intent.succeeded":
        await handlePaymentSucceeded(
          event.data.object as Stripe.PaymentIntent,
          stripeAccountId
        );
        break;

      case "payment_intent.payment_failed":
        await handlePaymentFailed(
          event.data.object as Stripe.PaymentIntent,
          stripeAccountId
        );
        break;

      case "transfer.created":
        await handleTransferCreated(event.data.object as Stripe.Transfer);
        break;

      case "payout.paid":
        // Log successful payouts to connected account
        console.log("Payout completed for account:", stripeAccountId);
        break;

      case "payout.failed":
        await handlePayoutFailed(
          event.data.object as Stripe.Payout,
          stripeAccountId!
        );
        break;

      default:
        console.log(`Unhandled Connect event: ${event.type}`);
    }
  } catch (err) {
    console.error(`Error handling Connect event ${event.type}:`, err);
    return NextResponse.json({ error: "Handler failed" }, { status: 500 });
  }

  return NextResponse.json({ received: true });
}

async function handleAccountUpdated(
  account: Stripe.Account,
  stripeAccountId: string | undefined
) {
  const id = stripeAccountId ?? account.id;

  // Determine new status
  let status: string = "onboarding";
  if (account.details_submitted && account.charges_enabled && account.payouts_enabled) {
    status = "active";
  } else if (
    account.requirements?.disabled_reason &&
    account.requirements.disabled_reason !== "requirements.past_due"
  ) {
    status = "restricted";
  }

  await db
    .updateTable("connect_accounts")
    .set({
      status: status as any,
      charges_enabled: account.charges_enabled ?? false,
      payouts_enabled: account.payouts_enabled ?? false,
      details_submitted: account.details_submitted ?? false,
      requirements: JSON.stringify(account.requirements?.currently_due ?? []),
      updated_at: new Date(),
    })
    .where("stripe_account_id", "=", id)
    .execute();
}

async function handleDeauthorized(stripeAccountId: string) {
  await db
    .updateTable("connect_accounts")
    .set({ status: "deactivated", updated_at: new Date() })
    .where("stripe_account_id", "=", stripeAccountId)
    .execute();
}

async function handlePayoutFailed(payout: Stripe.Payout, stripeAccountId: string) {
  // Notify seller about failed payout β€” most commonly wrong bank details
  const account = await db
    .selectFrom("connect_accounts")
    .innerJoin("users", "users.id", "connect_accounts.user_id")
    .select(["users.email", "users.id"])
    .where("connect_accounts.stripe_account_id", "=", stripeAccountId)
    .executeTakeFirst();

  if (account) {
    // Send email notification
    await sendPayoutFailedEmail({
      email: account.email,
      failureCode: payout.failure_code ?? "unknown",
      failureMessage: payout.failure_message ?? "Unknown error",
      amount: payout.amount,
      currency: payout.currency,
    });
  }
}

async function handleTransferCreated(transfer: Stripe.Transfer) {
  await db
    .updateTable("platform_transfers")
    .set({
      transfer_id: transfer.id,
      charge_id: transfer.source_transaction as string,
      status: "succeeded",
    })
    .where("payment_intent_id", "=", transfer.source_transaction as string)
    .execute();
}

Seller Dashboard: Earnings and Payout History

// app/api/connect/earnings/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";

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

export async function GET(req: NextRequest) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const account = await db
    .selectFrom("connect_accounts")
    .select(["stripe_account_id", "status", "payouts_enabled"])
    .where("user_id", "=", user.id)
    .executeTakeFirst();

  if (!account || account.status !== "active") {
    return NextResponse.json({ error: "No active Connect account" }, { status: 404 });
  }

  // Fetch balance from Stripe directly for the connected account
  const balance = await stripe.balance.retrieve({
    stripeAccount: account.stripe_account_id,
  });

  // Fetch recent payouts
  const payouts = await stripe.payouts.list(
    { limit: 10 },
    { stripeAccount: account.stripe_account_id }
  );

  // Earnings from our DB (platform-side view)
  const earnings = await db
    .selectFrom("platform_transfers")
    .innerJoin("connect_accounts", "connect_accounts.id", "platform_transfers.connect_account_id")
    .select([
      db.fn.sum("platform_transfers.net_amount").as("totalNet"),
      db.fn.sum("platform_transfers.platform_fee").as("totalFees"),
      db.fn.count("platform_transfers.id").as("transactionCount"),
    ])
    .where("connect_accounts.stripe_account_id", "=", account.stripe_account_id)
    .where("platform_transfers.status", "=", "succeeded")
    .executeTakeFirst();

  return NextResponse.json({
    balance: {
      available: balance.available.map((b) => ({
        amount: b.amount,
        currency: b.currency,
      })),
      pending: balance.pending.map((b) => ({
        amount: b.amount,
        currency: b.currency,
      })),
    },
    payouts: payouts.data.map((p) => ({
      id: p.id,
      amount: p.amount,
      currency: p.currency,
      arrivalDate: new Date(p.arrival_date * 1000).toISOString(),
      status: p.status,
      failureCode: p.failure_code,
      failureMessage: p.failure_message,
    })),
    lifetime: {
      netEarnings: Number(earnings?.totalNet ?? 0),
      platformFeesPaid: Number(earnings?.totalFees ?? 0),
      transactionCount: Number(earnings?.transactionCount ?? 0),
    },
  });
}

Express Dashboard Login Link

Sellers can view their Stripe dashboard without leaving your platform:

// app/api/connect/dashboard-link/route.ts
export async function POST(req: NextRequest) {
  const user = await getCurrentUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const account = await getActiveConnectAccount(user.id);
  if (!account) return NextResponse.json({ error: "Not found" }, { status: 404 });

  // Creates a temporary link to the Stripe Express dashboard
  const loginLink = await stripe.accounts.createLoginLink(
    account.stripe_account_id
  );

  return NextResponse.json({ url: loginLink.url });
}

Platform Fee Strategy

Fee ModelStructureBest For
Flat percentage5–15% of transactionMarketplaces
Subscription + low %$99/mo + 1%High-volume sellers
Tiered by volume10% β†’ 7% β†’ 5%Incentivize growth
Fixed per transaction$2.50 flatLow-value digital goods
Freemium0% free, 2% paidDeveloper tools

Our recommendation: start with 8–12% for marketplace, 2–5% for B2B SaaS platforms, and negotiate enterprise rates manually above $100K GMV.


Cost and Timeline Estimates

ComponentTimelineCost (USD)
Express onboarding flow2–3 days$1,600–$2,500
Destination charges + fee calculation1–2 days$800–$1,600
Webhook handler (account.updated + transfers)1–2 days$800–$1,600
Seller earnings dashboard2–3 days$1,600–$2,500
Refund and transfer reversal flows1–2 days$800–$1,600
Full marketplace payment system2–3 weeks$8,000–$18,000

Stripe's Connect fees (2026): 0.25% + $0.25 per payout for Express accounts. Factor this into your platform fee structure.


See Also


Working With Viprasol

We build payment infrastructure for marketplaces and SaaS platforms. Our team has implemented Stripe Connect for platforms handling millions in annual GMV, including gig economy apps, B2B SaaS, and digital goods marketplaces.

What we deliver:

  • Complete Connect onboarding (Express or Custom)
  • Platform fee calculation and transfer orchestration
  • Webhook infrastructure with idempotency
  • Seller earnings dashboards and payout reporting
  • Compliance documentation for financial platforms

Visit our fintech development services or contact us to discuss your marketplace payment requirements.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building Fintech Solutions?

Payment integrations, trading systems, compliance β€” we build fintech that passes audits.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Trading Software

Building fintech or trading infrastructure?

Viprasol delivers custom trading software β€” MT4/MT5 EAs, TradingView indicators, backtesting frameworks, and real-time execution systems. Trusted by traders and prop firms worldwide.