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:

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:

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:

  1. A customer initiates a purchase on the platform
  2. The platform creates a Stripe Checkout Session
  3. Payment is done on Stripe hosted checkout page
  4. Payment goes directly to seller connected account
  5. 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:

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:

What it does not guarantee is that the account can:

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:

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:

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:

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:

Below is a high-level example how Stripe webhooks can be implemented.

  1. 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 });
  }
);

  1. Register your API endpoint in Stripe Workbench (or via API)

In the Stripe Dashboard:

Stripe will give you a Webhook Signing Secret to use in your endpoint.

  1. 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:

Once webhooks drive seller state, they stop being “extra integration work” and become part of your core platform infrastructure.

They are how:

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:

These events cover:

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:

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:

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:

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.