TL;DR: To avoid illegal money transmission risks and fragile payment flows, marketplaces must implement an event-driven architecture using Stripe Connect's Separate Charges and Transfers. This guide demonstrates how to build a compliant escrow and split payout system in Node.js, featuring a Prisma schema designed to manage complex transaction lifecycles as a strict state machine.
⚡ Key Takeaways
- Bypass corporate bank accounts and route funds directly through Stripe Escrow to avoid triggering strict Money Services Business (MSB) regulations.
- Implement Stripe's "Separate Charges and Transfers" flow to safely handle delayed fulfillment, multi-vendor shopping carts, and partial refunds.
- Model payments as a state machine in your database (e.g.,
AUTHORIZED,CAPTURED,PARTIALLY_TRANSFERRED) using Stripe webhooks as the absolute source of truth rather than optimistic UI updates. - Store all currency amounts as integers (cents) in your Prisma schema to prevent floating-point rounding errors that cause failed Stripe API calls and accounting discrepancies.
- Use Stripe Connect "Express" rather than "Custom" to offload complex KYC (Know Your Customer) compliance and maintenance while retaining optimal UX control.
You've built the core features of your multi-sided marketplace. Buyers can browse, sellers can list, and matches are made. But then you hit the hardest part of any marketplace business: moving the money.
In a naive implementation, a buyer pays the platform, the platform holds the funds in a corporate bank account, and later manually wires the platform's cut to itself and the remainder to the seller. This architecture is an operational nightmare and, in many jurisdictions, highly illegal. Unless you possess a Money Services Business (MSB) license, holding funds on behalf of a third party turns your platform into an unregulated financial entity, exposing you to massive regulatory penalties.
Even if you dodge regulatory scrutiny, poor payment architectures inevitably lead to fragile systems. When you introduce delayed fulfillment, multi-vendor shopping carts, partial refunds, chargebacks, and continuous seller KYC requirements, simple point-to-point transfers fail. Your database state drifts from the payment gateway's actual state. Sellers scream about missing payouts, and your finance team bleeds capital covering unrecoverable refunds.
The solution requires abandoning simple linear payment flows. Instead, you must build an idempotent, event-driven architecture using Stripe Connect, managed by a strict backend state machine. By leveraging Separate Charges and Transfers, treating webhooks as the absolute source of truth, and implementing robust retry mechanisms in Node.js, you insulate your platform from compliance risks while guaranteeing atomic ledger updates.
Let’s architect a production-grade marketplace payment engine.
The Architecture of Multi-Sided Money Movement
To avoid being classified as a money transmitter, funds must bypass your company bank account completely. The compliant flow is: Buyer → Stripe Escrow → Platform & Seller.
To track this safely, your application database must model a payment not as a single action, but as a lifecycle. A Transaction acts as a state machine that transitions based only on confirmed webhook events from Stripe, never on optimistic UI interactions.
Here is a Prisma schema designed to safely track complex multi-party splits:
// schema.prisma
enum TransactionState {
PENDING
REQUIRES_ACTION
AUTHORIZED
CAPTURED
PARTIALLY_TRANSFERRED
TRANSFERRED
REFUNDED
DISPUTED
}
model Transaction {
id String @id @default(uuid())
buyerId String
buyer User @relation("Buyer", fields: [buyerId], references: [id])
totalAmount Int // In cents
currency String @default("usd")
state TransactionState @default(PENDING)
// Stripe Identifiers
stripePaymentIntentId String? @unique
stripeChargeId String? @unique
transferGroup String @unique // Crucial for tying charges to multiple transfers
// Relations
payouts Payout[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Payout {
id String @id @default(uuid())
transactionId String
transaction Transaction @relation(fields: [transactionId], references: [id])
sellerId String
seller User @relation("Seller", fields: [sellerId], references: [id])
amount Int // Seller's cut in cents
platformFee Int // Platform's cut in cents
stripeTransferId String? @unique
stripeReversalId String? @unique
isProcessed Boolean @default(false)
createdAt DateTime @default(now())
}
Production Note: Always store currency amounts as integers (cents/smallest currency unit). Using floating-point arithmetic for financial ledgers inevitably introduces fractional rounding errors over time, leading to failed Stripe API calls and accounting audit nightmares.
When planning marketplace architecture, engineering leaders often ask about the trade-off between building this complex state management in-house versus buying a boxed solution. We discuss this extensively in our pricing and engagement models and outline our rigorous architectural planning phases in how we build.
Onboarding and Compliance: Escaping the KYC Nightmare
Before a seller can receive a single cent, they must be KYC (Know Your Customer) verified. Stripe handles this heavy lifting, but you must choose your Connect integration type carefully: Standard, Express, or Custom.
For most multi-sided marketplaces, Express is the optimal choice. Custom gives you complete UX control but saddles your engineering team with maintaining complex onboarding UI flows, handling ID document uploads, and tracking constantly evolving global compliance rules. Express offloads the sensitive UI to a secure, Stripe-hosted page while keeping the seller's dashboard tightly integrated into your platform.
Here is the TypeScript service to provision an Express account and generate an onboarding link:
// services/stripe/onboarding.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export async function createSellerAccount(userEmail: string, userId: string) {
// 1. Create the Connect Account
const account = await stripe.accounts.create({
type: 'express',
email: userEmail,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: 'individual',
metadata: {
internalUserId: userId,
},
});
// 2. Generate an Account Link for onboarding
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${process.env.APP_URL}/api/stripe/onboarding/refresh?accountId=${account.id}`,
return_url: `${process.env.APP_URL}/dashboard/seller/success`,
type: 'account_onboarding',
});
return {
accountId: account.id,
onboardingUrl: accountLink.url,
};
}
Your database must store the resulting account.id against the seller's user profile. Until the seller completes onboarding (tracked via the account.updated webhook checking for details_submitted: true), all payouts to them must remain queued.
Securing the Escrow: Separate Charges and Transfers
Most marketplaces require holding funds until a specific condition is met—a product is shipped, a gig is finished, or a rental period ends.
Instead of using Destination Charges (which rigidly couple the buyer charge to a single seller payout instantly), we use Separate Charges and Transfers. This decouples inbound money movement from outbound money movement. You charge the buyer, let the funds sit in your platform's Stripe balance, and then trigger a transfer to the seller only when business logic permits.
Here is how you initiate the escrow holding pattern via a PaymentIntent:
// services/stripe/checkout.ts
import Stripe from 'stripe';
import { prisma } from '../db';
import { v4 as uuidv4 } from 'uuid';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createEscrowCharge(buyerId: string, cartTotalCents: number) {
// 1. Generate a unique Transfer Group ID
const transferGroup = `GROUP_${uuidv4()}`;
// 2. Create the Database Transaction Record (State: PENDING)
const transaction = await prisma.transaction.create({
data: {
buyerId,
totalAmount: cartTotalCents,
transferGroup,
state: 'PENDING',
},
});
// 3. Create the PaymentIntent
const paymentIntent = await stripe.paymentIntents.create({
amount: cartTotalCents,
currency: 'usd',
transfer_group: transferGroup,
// We do not specify application_fee_amount here!
// The platform fee is realized by transferring *less* to the seller later.
metadata: {
transactionId: transaction.id,
},
});
// 4. Update the DB with Stripe's Intent ID
await prisma.transaction.update({
where: { id: transaction.id },
data: { stripePaymentIntentId: paymentIntent.id },
});
return paymentIntent.client_secret;
}
Warning: You cannot hold funds indefinitely. Under Stripe Connect's rules, depending on your region, you generally must transfer funds to the connected account within 90 days. If your marketplace involves bookings a year in advance, you need a different strategy, such as deferring the charge capture until closer to the event.
For robust enterprise architectures, handling the edge cases of these transaction lifecycles requires specialized expertise. Our API integration and payment gateway services are purpose-built to engineer these resilient, compliant backends for global clients.
Managing Split Payouts and Platform Fees
Once the trigger condition is met (e.g., the buyer marks the item as "Delivered"), the platform executes the payout. Because we used a transfer_group earlier, Stripe automatically links the outbound transfer to the original charge, ensuring clear reconciliation and lowering risk flags.
With Separate Charges and Transfers, the platform fee isn't explicitly defined in an API parameter. Instead, the platform fee is simply the remainder left in your platform balance after you transfer the seller's cut.
// services/stripe/payouts.ts
import Stripe from 'stripe';
import { prisma } from '../db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function executeMarketplacePayout(payoutId: string) {
const payout = await prisma.payout.findUnique({
where: { id: payoutId },
include: {
transaction: true,
seller: true
},
});
if (!payout || payout.isProcessed) {
throw new Error("Invalid or already processed payout");
}
// Execute the transfer using an Idempotency Key to prevent double-payouts
const transfer = await stripe.transfers.create(
{
amount: payout.amount,
currency: 'usd',
destination: payout.seller.stripeAccountId,
transfer_group: payout.transaction.transferGroup,
metadata: {
payoutId: payout.id,
},
},
{
idempotencyKey: `payout_exec_${payout.id}`
}
);
// Update State
await prisma.payout.update({
where: { id: payout.id },
data: {
isProcessed: true,
stripeTransferId: transfer.id,
},
});
return transfer;
}
Notice the use of idempotencyKey. Network requests fail. If Node.js crashes right after Stripe successfully processes the transfer but before Prisma updates the database, a background cron job might run this function again. The idempotency key ensures Stripe safely ignores the second request, returning the original transfer object and saving you from paying a seller twice.
Webhooks and Idempotency: The Source of Truth
Never trust the frontend. If a user closes their browser during a 3D Secure redirect, the frontend will never send the success signal to your server, but Stripe will still process the money.
Your webhook handler must be the single source of truth for updating the TransactionState in your database. Here is a Next.js (Pages Router) example that safely processes these events:
// pages/api/webhooks/stripe.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import Stripe from 'stripe';
import { prisma } from '../../../db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
// Disable Next.js body parsing to access the raw buffer for signature validation
export const config = { api: { bodyParser: false } };
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).end('Method Not Allowed');
}
const reqBuffer = await buffer(req);
const signature = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(reqBuffer, signature, endpointSecret);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
return res.status(400).send(`Webhook Error: ${message}`);
}
// Handle the event idempotently
try {
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await prisma.transaction.updateMany({
where: {
stripePaymentIntentId: paymentIntent.id,
state: { notIn: ['CAPTURED', 'TRANSFERRED'] } // Idempotency check
},
data: { state: 'CAPTURED' },
});
break;
}
case 'transfer.created': {
const transfer = event.data.object as Stripe.Transfer;
await prisma.payout.updateMany({
where: {
stripeTransferId: transfer.id,
isProcessed: false
},
data: { isProcessed: true },
});
break;
}
default:
console.log(`Unhandled event type ${event.type}`);
}
} catch (error) {
console.error("Database update failed:", error);
// Return 500 so Stripe explicitly retries the webhook later
return res.status(500).send('Database Error');
}
res.status(200).json({ received: true });
}
Production Note: By using
updateManycombined with a state check (state: { notIn: ['CAPTURED'] }), we ensure that even if Stripe fires thepayment_intent.succeededwebhook three times concurrently, the database mutation only executes once.
Handling the Dark Side: Chargebacks, Refunds, and Reversals
In a multi-sided marketplace using Separate Charges and Transfers, the platform is ultimately liable for disputes. If a buyer files a chargeback, Stripe forcefully withdraws the funds (plus a dispute fee) from your platform balance, not the seller's.
You must build automated defenses. If a dispute arrives, immediately halt any pending payouts to that seller. If the payout has already occurred, you must create a TransferReversal to pull the funds back from the connected seller account to your platform balance to cover your loss.
// snippet from within the webhook switch statement
case 'charge.dispute.created': {
const dispute = event.data.object as Stripe.Dispute;
const chargeId = dispute.charge as string;
// 1. Find the transaction
const disputedTx = await prisma.transaction.findUnique({
where: { stripeChargeId: chargeId },
include: { payouts: true }
});
if (!disputedTx) break;
// 2. Mark Transaction as Disputed
await prisma.transaction.update({
where: { id: disputedTx.id },
data: { state: 'DISPUTED' }
});
// 3. Attempt to reverse the transfer from the seller
for (const payout of disputedTx.payouts) {
if (payout.isProcessed && payout.stripeTransferId) {
try {
const reversal = await stripe.transfers.createReversal(
payout.stripeTransferId,
{
description: `Reversal due to chargeback: ${dispute.id}`,
},
{ idempotencyKey: `reversal_${dispute.id}_${payout.id}` }
);
await prisma.payout.update({
where: { id: payout.id },
data: { stripeReversalId: reversal.id }
});
} catch (err: unknown) {
// If the seller account has a zero balance, the reversal will fail.
// You must track this negative balance internally to deduct from future sales.
const msg = err instanceof Error ? err.message : 'Unknown error';
console.error(`Reversal failed for payout ${payout.id}:`, msg);
}
}
}
break;
}
This is where poor architectures bleed capital. If you don't build automated transfer reversals, you will manually eat every chargeback cost. Furthermore, if a seller’s Connect account hits a negative balance because of a reversal, Stripe allows the platform to recover those funds automatically from the seller's future sales, preserving your profit margins seamlessly.
Architecting a compliant, failure-resistant payment engine requires more than just calling the Stripe API. It requires treating money movement as a distributed systems problem where network partitions, race conditions, and hostile actors are expected norms.
Ready to bulletproof your marketplace? Book a free architecture review with our backend engineers to map out your state machines, escrow flows, and compliance strategy.
Work With Us
Need help building this in production? SoftwareCrafting is a full-stack dev agency — we ship React, Next.js, Node.js, React Native & Flutter apps for global clients.
Frequently Asked Questions
What is the main difference between the Pages Router and the App Router in Next.js?
The Pages Router relies on file-based routing mapped directly to React components, fetching data via specific functions like getServerSideProps. The App Router introduces a new paradigm based on React Server Components, offering nested layouts, streaming, and built-in optimized data fetching. This architectural shift allows for significantly less client-side JavaScript and better overall application performance.
How do I handle data fetching in the Next.js App Router without getServerSideProps?
In the App Router, you fetch data directly inside your Server Components using standard asynchronous JavaScript fetch() calls. Next.js extends the native fetch API to automatically handle caching and revalidation at the request level. For direct database queries or third-party SDKs, you simply await them directly within the async component without needing a separate data-fetching lifecycle method.
Can I incrementally adopt the App Router in an existing Next.js project?
Yes, Next.js allows both the pages and app directories to coexist in the same project, automatically routing requests to the appropriate directory. This enables you to migrate your application route by route rather than rewriting the entire codebase at once. If you need expert guidance ensuring a seamless transition, SoftwareCrafting services can help you plan and execute an incremental migration strategy without disrupting your production environment.
What are React Server Components (RSC) and why are they the default in the App Router?
React Server Components are components that render exclusively on the server, sending only the generated HTML and minimal UI state to the client. They are the default in the App Router because they drastically reduce the client-side JavaScript bundle size, leading to faster page loads and improved Core Web Vitals. You only need to opt-in to Client Components using the "use client" directive when you require interactivity, state, or browser APIs.
How do I migrate my custom _app.js and _document.js files to the new architecture?
In the App Router, the functionality of _app.js and _document.js is replaced by a single Root Layout file (app/layout.js). This file must contain the <html> and <body> tags and serves as the top-level wrapper for your entire application. You can now place global state providers, custom fonts, and SEO metadata configurations directly within this Root Layout.
How can I resolve performance bottlenecks and slow build times after migrating to the App Router?
Slow build times during an App Router migration often stem from unoptimized static generation, excessive client boundaries, or misconfigured caching strategies. Profiling your build process and analyzing your component tree to maximize Server Component usage is critical for optimal performance. If your team is struggling with post-migration performance, SoftwareCrafting services specializes in auditing Next.js architectures to identify and resolve these exact bottlenecks.
📎 Full Code on GitHub Gist: The complete
unresolved-variables-error.jsfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
