ProgaTech
Contact us

Marketplace payments with Stripe Connect

What we learned building payments for Stridist, a platform where coaches sell to their own clients: account setup, hosted onboarding, invoices, subscriptions and webhooks, and why we chose each one.

8 min read

·

Oct 21, 2026

Marketplace payments with Stripe Connect

Marketplace payments with Stripe Connect

If you run a SaaS platform where users sell services to their own clients, such as coaches, tutors or consultants, they’ll eventually need to accept payments inside your product. Supporting that means deciding who holds the money, who verifies each seller’s identity, who handles refunds and disputes, and how your app learns about failed payments.
We faced exactly this at Stridist, a platform for fitness coaches. Coaches were collecting payments outside the app through bank transfers and payment links. That meant they had to check manually who had paid before unlocking a client’s plan. Proga Tech designed and built the integration that moved those payments into the product.
This guide shows how to build it with Stripe Connect and the Accounts v2 API. You’ll create connected accounts for sellers, onboard them through Stripe-hosted KYC, charge their clients with invoices and subscriptions, and keep your app in sync through webhooks.

What Stripe Connect does

A standard Stripe integration has one business and one account, and all payments land there. A platform has two layers: the platform itself and the sellers who use it. Each seller needs their own bank account, their own payouts and, by law, their own identity verification.
Stripe Connect handles this by letting the platform create a Stripe account for each seller, called a connected account, and process payments through it. The platform controls the product experience. Stripe handles the regulated parts: identity checks, holding funds, payouts and, in supported countries, tax reporting.

Why not collect, then pay out

The first idea is usually to charge clients on the platform’s account and pay sellers later. Here is how the options compare:
Approach
Best for
Main downside
Platform collects, pays sellers manually
A handful of sellers, MVP
You hold other people's money, which brings accounting, tax and trust problems
Payment links from the seller's own Stripe
Sellers who already use Stripe
Unless you build an integration around them, the links are managed separately from your product, so payment status doesn’t reach your app and access isn’t automated
Stripe Connect
Platforms where users sell to their users
More integration work up front
With Stripe Connect, each connected account has its own Stripe account context, but the flow of funds depends on the charge type. In our Stridist implementation, we use direct charges, which are created on the connected coach’s account. This keeps the payment associated with the coach while the platform manages the integration and the overall customer experience.
notion image

Two decisions before you write code

The first choice is the account type. Connect offers three:
Type
Who owns the experience
When it fits
Standard
The seller has a full Stripe account and dashboard
Sellers who already run their business on Stripe
Express
Stripe hosts onboarding and a light dashboard; the platform controls the rest
Most marketplaces and SaaS platforms
Custom
The platform builds everything, including onboarding UI and support
Large platforms that want Stripe to be invisible
We chose the Express dashboard for Stridist because coaches needed somewhere to see payments and payouts without becoming Stripe experts, and we didn’t want to build or maintain the seller onboarding and verification experience ourselves. That choice comes with responsibilities, which we cover below: the platform pays Stripe’s processing fees and covers sellers’ negative balances. In Accounts v2 these aren’t separate types. You get the same behavior by choosing the dashboard (full, express or none) and the responsibilities when you create the account.
The second choice is the charge type: where the payment is created and who is responsible for it.
notion image
For Stridist, direct charges were a natural fit because coaches sell their services directly to their own clients. This commercial relationship informed our decision to create payments on the connected coach’s account. When choosing a Connect charge type, platforms should also consider who acts as the merchant of record, how fees and refunds are handled, and who bears responsibility for negative balances.

The building blocks

The setup in this post uses the Accounts v2 API to create Express accounts and Stripe's hosted onboarding, so all identity checks happen on Stripe's side. Payments are made with invoices for one-time purchases and with Checkout and subscriptions for recurring ones. Clients manage their cards and cancellations in Stripe's Billing Portal, and Connect webhooks tell the platform what actually happened with every payment.

Creating the connected account

The merchant configuration lets the seller accept card payments and receive payouts, and it’s what direct charges need. The recipient configuration lets an account receive transfers from the platform, which you need for destination charges or separate charges and transfers.
Save the returned account ID in your database right away, linked to the seller, because every later call uses it.
Two things in this call deserve attention. First, the country is immutable. In v2 you can’t change it after the account is created, and hosted onboarding can’t fix it either, so ask for the country in your own UI before calling Stripe.
Second, the responsibilities are a business decision, not a technical one. With For this implementation, we use Dashboard = "express" with both FeesCollector and LossesCollector set to application. That makes the platform responsible for Stripe processing fees and connected-account negative balances under this configuration.

Onboarding without building a single KYC form

Before a seller can accept payments, Stripe has to verify who they are: their identity, business type and bank details. This is called KYC (“know your customer”). With Express accounts, you don't build any of these forms. You generate an onboarding link, redirect the seller to it, and Stripe sends them back when they're done.
Stripe-hosted onboarding (test mode). Every KYC field here is Stripe's, not ours
Stripe-hosted onboarding (test mode). Every KYC field here is Stripe's, not ours
☝🏻 If you request both configurations at creation, request both here as well. Otherwise the account never reaches an active capability, and the seller gets stuck on “pending” for no visible reason.
Account links are short-lived and single-use, so don't store them or send them by email. Create a new one every time the seller clicks “Connect”. The RefreshUrl is where Stripe sends the seller if the link has already expired. Point it to an endpoint that creates a fresh link and redirects again. The ReturnUrl is where the seller lands when they leave onboarding, and that can happen whether they finished or just closed the form halfway.
That's why the redirect alone tells you nothing. When the seller comes back, fetch the account together with its configuration.merchant and requirements. From that you can tell whether the seller can already accept payments, whether there's still something to fill in, and whether Stripe is waiting for an identity document. These three answers are enough to drive the whole connection UI, from “Finish onboarding” and “Verification pending” to “Connected”.
notion image

Charge clients

Once the seller is connected, every call you make on their behalf carries their account ID:
This header decides where everything lives. The customer, the invoice and the payment are created on the seller’s account, not the platform’s. The seller sees them in their own dashboard, and the client’s receipt shows the seller’s business.
The seller's Express dashboard (test mode). Payments land on their own account
The seller's Express dashboard (test mode). Payments land on their own account
For one-time payments, we chose invoices over a bare PaymentIntent or a one-off Checkout session. At Stridist a coach sends a plan to a client, which is a payment request rather than a cart, and the coach needs a clear invoice record they can track and share with the client. An invoice comes with a hosted payment page, a PDF receipt, email reminders and a clear record in the seller’s dashboard, and you build none of it yourself. If your flow is a client clicking “Buy” in your app, Checkout in payment mode may be simpler. The flow looks like this:
  1. Find or create the client as a customer on the seller’s account.
  1. Create a draft invoice with PendingInvoiceItemsBehavior = "exclude".
  1. Attach the line item explicitly to that invoice.
  1. Read the invoice back and check its total before you finalize it.
  1. Finalize it, so it becomes an open invoice with a hosted payment page.
  1. If the client has a saved card, charge it right away. Otherwise send the link.
notion image
A couple of these steps look like overkill, but each one has a reason. On current API versions (since 2022-08-01), a new invoice already excludes a customer’s pending invoice items by default. On older versions Stripe pulled them in automatically. We still set "exclude" explicitly so the intent is visible in the code and doesn’t depend on the API version. Attaching the line item to a specific invoice, rather than creating a loose pending item, means a leftover item from an earlier failed attempt can never end up on the wrong invoice, and each invoice contains exactly what you put there.
The total check matters for a similar reason: Stripe marks an invoice with a total of 0 as paid the moment you finalize it, and you don’t want to grant access for free.
For invoices, the platform fee is a fixed amount. Calculate it from your percentage and pass it as ApplicationFeeAmount when you create the invoice. Because the platform pays Stripe’s processing fees in this setup, make sure the amount covers them. Also put your own IDs, such as the plan or order ID, into the invoice metadata. When the webhook arrives later, that’s how you find the right record.
The hosted invoice page the client sees, with a PDF and several payment methods out of the box
The hosted invoice page the client sees, with a PDF and several payment methods out of the box
Recurring payments go through Checkout in subscription mode, also created on the seller's account:
Here the platform fee is a percentage, and Stripe applies it to every renewal automatically. Keep in mind that the price has to exist on the seller's account, not on the platform's.
After that, clients manage their subscription in the Billing Portal. Create a portal session with the same StripeAccount header, and the client can update their card, see past invoices or cancel, all on pages you didn't have to build. If you want to charge a client later without asking for card details again, use Checkout in setup mode. It saves the card on the seller's account so you can use it for the next invoices.

Webhooks are the source of truth

Redirects are unreliable: users close tabs, and networks drop. The only reliable way to know that a payment went through is the event Stripe sends you, so change state in your app only from webhooks.
With Accounts v2 you need two kinds of events, delivered at different scopes:
What you’re tracking
Where the events come from
Invoices, subscriptions and payments on sellers’ accounts (direct charges)
Connected accounts: a Connect endpoint that listens to events on connected accounts
Seller onboarding and account status (Accounts v2 lifecycle)
Your platform account: an event destination on your own account
Keep three things apart: payment status, subscription status and your product’s access policy.
  • checkout.session.completed doesn’t always mean the client has paid. With delayed payment methods, such as some bank debits, Checkout completes before the payment succeeds. We use this event only to link the session to the subscription and our own record. Access is granted from invoice.paid. If you do act on the session, check its payment_status and handle checkout.session.async_payment_succeeded and checkout.session.async_payment_failed.
  • invoice.payment_failed doesn’t always mean “past due.” If the first payment of a subscription fails, the subscription stays incomplete, not past_due. For renewals it typically moves to past_due. Read the actual status from customer.subscription.updated rather than inferring it from the failed invoice.
  • Your access policy decides what each status means. For example, you might keep access during a short grace period while the subscription is past_due, and end it on canceled or unpaid

Handling events safely

ConstructEvent verifies the signature, so nobody can send you a fake “paid” event. Don’t skip this step.
Respond quickly, but don’t acknowledge an event you might lose. The safe order is:
  1. Verify the signature.
  1. Persist the event, or put it on a durable queue.
  1. Return 200.
  1. Process it in the background, with retries.
If you return 200 and then crash before saving the work, Stripe considers the event delivered and won’t resend it. If your endpoint is too slow, Stripe treats the delivery as failed and retries it.
Stripe can also deliver the same event twice or out of order, so handlers must be idempotent. For one-time actions, “activating a plan that’s already active does nothing” is enough. For recurring access it isn’t: if a renewal’s invoice.paid is processed twice, a naive handler extends access by two periods. Record which invoices you have already applied, for example with a unique constraint on the invoice ID, and skip repeats. Better still, set access to the period covered by the invoice instead of adding time to the current expiry. For out-of-order events, don’t trust the event payload as the latest state: fetch the current object from Stripe before changing status. The metadata you attached to the invoice or subscription comes back in the event, and that’s how you find the matching record on your side.
In practice, these events let the platform grant or extend access when a payment succeeds, react to failed payments according to its access policy, remind sellers about clients who are late with payments, and keep each seller’s connection status in sync when something changes in Stripe.
notion image

When the seller disconnects

Sellers can close their Express account inside Stripe, without going through your product. Stripe doesn’t delete the account; it sets closed = true. You’ll learn about it from the v2.core.account.closed event on your platform account. Treat a closed account as disconnected: soft-disconnect it on your side and keep the payment history.
If you have older accounts created through v1 or OAuth, first check that you are requesting the merchant configuration with include, since a missing field may simply not have been requested. If the configuration is still absent for those accounts, keep a fallback to the v1 Accounts API.

Key takeaways

  • Collect immutable data before you create the account. Country first, Stripe second.
  • Request the same configurations in creation and onboarding.
  • Never trust a redirect. Read the account and the invoice back.
  • Know what your responsibilities cost. With an Express dashboard the platform pays Stripe fees and covers negative balances. Price your application fee accordingly.
  • Listen at both scopes. Payment events come from connected accounts; Accounts v2 status events come to your platform account.
  • Store before you acknowledge, and make handlers idempotent per invoice.
  • Let Stripe own everything it can: KYC, payment pages, the billing portal and the support details on receipts. Every page you don’t build is a page you don’t maintain.

In production: Stridist

We built this flow for Stridist, a platform for fitness coaches. Coaches used to collect payments outside the app through bank transfers and payment links. Now a coach connects Stripe in a couple of minutes and sends a plan. The client pays straight into the coach’s account, and access to the plan activates when the payment goes through.
Coaches run their business in one place, and the platform never holds their money.

Let your users get paid by their own clients

How we built Stridist’s coach payments with Stripe Connect and Accounts v2: Express dashboard, hosted onboarding, invoices, subscriptions and webhooks.

Contact us

Check out other posts

Kafka explained through a post office
Backend Development
Microservices

5 min read

Oct 14, 2026

Kafka explained through a post office