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.
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 Type | Best For | Platform Control | Stripe Branding | Onboarding Handled By |
|---|---|---|---|---|
| Express | Marketplaces, gig platforms | Medium | Stripe-hosted | Stripe |
| Standard | B2B platforms | Low | Stripe | Seller |
| Custom | Full white-label | Full | None | You |
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 Model | Structure | Best For |
|---|---|---|
| Flat percentage | 5β15% of transaction | Marketplaces |
| Subscription + low % | $99/mo + 1% | High-volume sellers |
| Tiered by volume | 10% β 7% β 5% | Incentivize growth |
| Fixed per transaction | $2.50 flat | Low-value digital goods |
| Freemium | 0% free, 2% paid | Developer 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
| Component | Timeline | Cost (USD) |
|---|---|---|
| Express onboarding flow | 2β3 days | $1,600β$2,500 |
| Destination charges + fee calculation | 1β2 days | $800β$1,600 |
| Webhook handler (account.updated + transfers) | 1β2 days | $800β$1,600 |
| Seller earnings dashboard | 2β3 days | $1,600β$2,500 |
| Refund and transfer reversal flows | 1β2 days | $800β$1,600 |
| Full marketplace payment system | 2β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
- Stripe Webhook Handling β Idempotent webhook processing patterns
- SaaS Billing Portal β Customer-facing billing management
- PostgreSQL Row-Level Security β Isolating seller financial data
- SaaS Audit Logging β Logging all financial transactions
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.
About the Author
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.
Building Fintech Solutions?
Payment integrations, trading systems, compliance β we build fintech that passes audits.
Free consultation β’ No commitment β’ Response within 24 hours
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.