Overview
We have recently built and launched our multi-seller SaaS B2B platform with Stripe Connect. Our platform is intended for small businesses with mainly offline presence to easily start selling gift cards online.
Stripe Connect Express makes it possible to build a multi-seller platform surprisingly quickly.
You can onboard sellers, take application fees, and route payments directly to third-party accounts with relatively little code. Stripe can handle compliance, payouts, and much of the operational complexity.
However, in practice, a multi-seller platform is not defined by its happy path. Status of connected accounts and capabilities change over time. Payments that looked valid when they were created can fail later for reasons your platform didn’t design for. If your system is designed around static assumptions, it will eventually break.
This article focuses on how to design a Stripe Connect Express integration that survives changes as those assumptions stop holding based on our own experience and lessons learnt while building and launching a multi-seller B2B SaaS platform.
We will walk through the core design decisions behind a production-ready multi-seller platform: how money moves, how seller state should be modelled, and why webhook-driven state is essential for this use case.
How Payments Move in a Multi-Seller Platform
Before discussing onboarding, seller state, or webhooks, it’s important to be precise about what problem you’re actually solving.
Stripe Connect supports multiple models, and they differ in meaningful ways:
- Connect Standard, Express, and Custom offer different levels of control and responsibility
- A platform can be the merchant of record, or the seller can be
- Money can flow through the platform, or directly to connected accounts
There is no single “correct” payment model for all multi-seller platforms.
The design decisions in the rest of this article assume the following setup:
- Sellers create their own Stripe Connect Express accounts via our platform
- Payments are made directly to the seller’s account
- The platform takes a percentage-based application fee
This model is well-suited for platforms where sellers are independent businesses and Stripe handles most compliance and payout responsibilities.
Other models, for example where all payments go to platform Stripe account and then are distributed amongst the connected accounts, are valid, but they introduce different constraints and are out of scope here.
A concrete payment flow for this use-case
In this setup, a typical payment looks like this:
- A customer initiates a purchase on the platform
- The platform creates a Stripe Checkout Session
- Payment is done on Stripe hosted checkout page
- Payment goes directly to seller connected account
- The platform collects an application fee
Using Stripe Checkout, this is expressed by creating a session that includes both the destination account and the application fee, for example:
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: "gbp",
product_data: {
name: "Gift Card",
},
unit_amount: price * 100,
},
quantity: 1,
},
],
payment_intent_data: {
application_fee_amount: Math.round(price * 0.15 * 100), // 15% application fee
transfer_data: {
destination: stripeAccountId, // ID of Stripe connected account
},
},
success_url: `${hostUrl}/${saleId}/success`,
cancel_url: `${hostUrl}/${saleId}/failure`,
});
Above example returns a payment session URL, which you can use to redirect the buyer to the Stripe hosted checkout page. This way checkout abstracts away much of the underlying payment logic, though the key constraint remains: the payment depends on the seller’s account state and readiness to accept payments.
Why this distinction matters
Once sellers are the merchant of record and payments are created on their accounts:
- The platform does not fully control whether a payment can succeed
- Seller compliance and capability changes directly affect checkout
- Some failures occur after a session has already been created
If your platform is built around static assumptions like “the seller is onboarded” or “payments are enabled”, those assumptions will eventually stop holding.
The rest of this article builds on this foundation: if money flows through seller accounts, then seller state becomes a first-class concern, and your system must be designed to react when it changes.
What a Seller Account Represents
From your platform’s perspective, a created Stripe account is not a signal that a seller can accept payments. It’s simply a container that may become capable of accepting payments, depending on its state.
Account creation vs account usability
Creating a Stripe account is a synchronous operation:
const account = await stripe.accounts.create({
type: "express",
country: "GB",
default_currency: "GBP",
email,
capabilities: {
card_payments: { requested: true },
},
});
At this point:
- the account exists
- it has an ID
- it can be linked to a user in your database
What it does not guarantee is that the account can:
- accept card payments
- remain enabled over time
Those are outcomes of an onboarding and verification process that happens outside your request/response cycle.
Onboarding is not a single step
When you generate an onboarding link, e.g.:
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${hostUrl}/${sellerId}?success=false`,
return_url: `${hostUrl}/${sellerId}?success=true`,
type: "account_onboarding",
});
you are not “completing onboarding”, you are giving the seller an opportunity to submit information. Stripe may accept it, reject it, or request more later.
In our case onboarding flow is also hosted on Stripe which further reduces our load to manage onboarding. What our platform does is just generating Stripe onboarding link and redirecting sellers to complete onboarding, i.e. get their Stripe account ready to accept payments, on Stripe platform.
However even after the onboarding is complete and seller is ready to accept payments, it can change over time.
The next sections will look at why seller state changes, why synchronous checks are insufficient, and how a webhook-driven model lets your platform stay correct as those changes happen.
Why Seller Capabilities Change Over Time
Once a seller account exists, onboarding has been completed and payments work fine, it’s tempting to think the hard part is over.
In reality, this is where most multi-seller platforms start to encounter problems, because seller readiness to accept payments is not stable. A seller being able to accept payments is not a permanent property of their account. It’s a current state based on connected account information Stripe has at a given point in time.
A seller’s ability to accept card payments depends on factors such as:
- completeness and validity of verification information
- country-specific compliance requirements
- business type and activity
- changes in Stripe’s own regulatory obligations
Based on above factors, capabilities can be revoked as well as granted.
Therefore, if your system assumes that onboarding completes once, seller readiness is a boolean, capability checks are final, then capability changes will lead to failures such as:
- Checkout Sessions that fail after being created
- Payments that never reach the seller
- Sellers reporting issues your platform didn’t anticipate
These are not edge cases, but expected result of building on top of a regulated financial system.
A more reliable approach is to treat seller capability as dynamic state, not configuration.
That means:
- assuming capabilities can change at any time
- designing payment flows that tolerate late failure
- avoiding logic that relies on one-time checks
In the next section, we’ll look at how a webhook-driven model helps us build a resilient system that survives the changes.
Stripe Webhooks To The Rescue
Now that we know that seller capabilities can change over time, the next question is how your platform should detect and respond to those changes.
Webhooks are how Stripe communicates those changes and provides us with the latest information about a seller “state”.
This state is derived from events such as:
account.updatedaccount.application.deauthorized
Below is a high-level example how Stripe webhooks can be implemented.
- Create an API endpoint to receive webhook events
import express from "express";
import Stripe from "stripe";
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
router.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error("Webhook signature verification failed:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
handleStripeEvent(event);
res.json({ received: true });
}
);
- Register your API endpoint in Stripe Workbench (or via API)
In the Stripe Dashboard:
-
Go to Developers → Workbench → Webhooks
-
Add an endpoint, e.g.:
https://your-api.com/webhooks/stripe -
Select required events, e.g.:
account.updated
Stripe will give you a Webhook Signing Secret to use in your endpoint.
- Route different events to your event handlers
Do not put logic directly in the webhook endpoint. Instead, route events to small, specific handlers, e.g.:
function handleStripeEvent(event) {
switch (event.type) {
case "account.updated":
return handleAccountUpdated(event.data.object);
default:
return;
}
}
The above webhooks approach shifts responsibility:
- Stripe decides when a seller’s status changes
- Your platform records that change
- The rest of your system reacts to your internal state
Once webhooks drive seller state, they stop being “extra integration work” and become part of your core platform infrastructure.
They are how:
- seller dashboards stay accurate
- payment flows stay predictable
- operational failures are reduced
In the next section, we’ll look at how to formalise this further by turning Stripe events into a clean internal state model that the rest of the system can depend on.
Turning Stripe Events into Internal Seller State
At this point, Stripe is already telling your platform when seller account changes. The remaining question is how you turn those events into something the rest of your system can safely depend on.
The mistake to avoid here is treating Stripe events as business logic.
Stripe events are signals, not decisions. Your platform still needs to decide what those signals mean.
Define internal seller state model
Define a small internal model that captures only what your platform actually needs.
For example:
type SellerState =
| "pending"
| "enabled"
| "restricted"
| "disabled";
This state is intentionally simple. It allows the rest of your system to ask one clear question: Can this seller accept payments right now?
Everything else is implementation detail.
Decide which Stripe events matter
Not all Stripe events are equally useful.
For seller state, a minimal and effective starting set is:
account.updatedaccount.application.deauthorized
These events cover:
- capability changes
- account restrictions
- account disconnection
You can expand this later, but starting small keeps the system understandable.
Centralise event handling
Your webhook should route events to focused handlers, not scatter logic across the codebase.
function handleStripeEvent(event) {
switch (event.type) {
case "account.updated":
return handleAccountUpdated(event.data.object);
case "account.application.deauthorized":
return handleAccountDeauthorized(event.data.object);
default:
return;
}
}
Each handler should do one thing only: update internal state.
Derive account state
Stripe already exposes whether an account can accept payments via account.charges_enabled
Use that signal directly, and derive your internal state from it.
function deriveSellerState(account) {
if (!account.charges_enabled) return "restricted";
return "enabled";
}
This function becomes a critical boundary. If Stripe changes how it expresses readiness in the future, this is the only place you need to update.
Persist state atomically
When handling account.updated, update your database in a single operation.
async function handleAccountUpdated(account) {
const sellerState = deriveSellerState(account);
await updateUserByStripeAccountId(account.id, {
sellerState,
stripeChargesEnabled: account.charges_enabled,
});
}
Important characteristics of this update:
- idempotent
- safe to run multiple times
- independent of request context
Webhook retries should not cause problems.
Handle account deauthorization explicitly
When a seller disconnects their Stripe account, Stripe will send account.application.deauthorized
This should always move the seller into a terminal or restricted state.
async function handleAccountDeauthorized(account) {
await updateUserByStripeAccountId(account.id, {
sellerState: "disabled",
});
}
This prevents your platform from attempting payments on a disconnected account.
Why this design scales
By translating Stripe events into internal state:
- payment flows no longer depend on live Stripe calls
- seller dashboards within your platform reflect reality
- payment failures are reduced as you react to capability changes
In the final section, we’ll look at how this internal seller state feeds back into payment flow decisions — and why that’s where the system becomes predictable.
Making Payment Flows React to Seller State
Once seller state is derived from Stripe events and stored internally, payment logic becomes simpler and more predictable.
At this point, your platform no longer needs to guess whether a seller can accept payments. It already knows.
Gate payment creation early
Before creating a Checkout Session, your backend should check internal seller state, not Stripe directly.
For example, in your create-checkout-session route:
if (seller.sellerState !== "enabled") {
return res.status(403).json({
success: false,
message: "Seller is currently unable to accept payments",
});
}
This avoids sending customers into a checkout flow that may fail.
Designing payment flows around internal seller state leads to fewer failed checkouts and clearer error messages for improved UX.
At this point, the system has a complete feedback loop:
- Stripe changes seller capabilities
- Webhooks update internal state
- Payment flows react to that state
That loop is what makes a multi-seller platform operable over time.
Conclusion
Building and launching a multi-seller SaaS B2B platform with Stripe Connect Express taught us that most of the real complexity doesn’t live in the initial integration. It shows up later when seller accounts change state, when capabilities are adjusted, and when payments fail for reasons that aren’t visible at the time they’re implemented.
What worked for us was treating Stripe as an event-driven system, not a synchronous API. By relying on webhooks, translating Stripe events into a small internal state model, and making payment flows react to that state, we ended up with a platform that behaves predictably even as conditions change.
This approach doesn’t entirely eliminate failures, but it makes failures more predictable and manageable.
If there’s one takeaway from building and launching this platform, it’s this: design your Stripe Connect integration for how it will behave after launch, not just for how quickly you can get to first payment.
That mindset made the difference between something that worked in development and something we could confidently run in production.