Building a Multi-Tenant SaaS with Stripe Connect in 2026
<p>If your SaaS handles payments on behalf of users (marketplace, platform, agency tool), you need Stripe Connect. Here's the architecture that actually works, based on building one from scratch.</p> <h2> The core problem </h2> <p>Your SaaS has multiple customers. Each customer has their own end-users who pay them. You need to:</p> <ol> <li>Let each customer connect their own Stripe account</li> <li>Process payments from their end-users into their Stripe account</li> <li>Take a platform fee from each transaction</li> <li>Handle webhooks for all connected accounts</li> <li>Keep everyone's data isolated</li> </ol> <p>This is what Stripe Connect solves. But the docs are 200+ pages and most tutorials skip the hard parts.</p> <h2> Picking the right Connect type </h2> <p>Stripe offers three acco
If your SaaS handles payments on behalf of users (marketplace, platform, agency tool), you need Stripe Connect. Here's the architecture that actually works, based on building one from scratch.
The core problem
Your SaaS has multiple customers. Each customer has their own end-users who pay them. You need to:
-
Let each customer connect their own Stripe account
-
Process payments from their end-users into their Stripe account
-
Take a platform fee from each transaction
-
Handle webhooks for all connected accounts
-
Keep everyone's data isolated
This is what Stripe Connect solves. But the docs are 200+ pages and most tutorials skip the hard parts.
Picking the right Connect type
Stripe offers three account types: Standard, Express, and Custom. Here's the actual decision:
Standard accounts (recommended for most SaaS): Your customer already has a Stripe account or creates one during onboarding. They manage their own payouts, disputes, and compliance. You just create charges through their account.
Express accounts: You want a lighter onboarding flow. Stripe hosts the dashboard for your connected accounts. Good for marketplaces where sellers don't need full Stripe features.
Custom accounts: You build the entire onboarding UI yourself and handle compliance. Only use this if you need complete white-labeling. The compliance burden is significant.
For most SaaS platforms, Standard accounts win. Your customers keep their existing Stripe relationship, Stripe handles compliance, and your integration is simpler.
The OAuth flow
Standard Connect uses OAuth. Here's the flow:
// 1. Generate the connect URL const connectUrl = // 1. Generate the connect URL const connectUrl = response_type=code& +
client_id=${process.env.STRIPE_CLIENT_ID}& +
scope=read_write& +
redirect_uri=${process.env.NEXT_PUBLIC_URL}/api/stripe/callback& +
state=${organizationId}; // CSRF protection`// 2. User clicks "Connect Stripe" -> redirects to Stripe // 3. After approval, Stripe redirects back with an authorization code
// 4. Exchange the code for an account ID export async function GET(request: Request) { const { searchParams } = new URL(request.url); const code = searchParams.get('code'); const state = searchParams.get('state'); // your org ID
const response = await stripe.oauth.token({ grant_type: 'authorization_code', code, });
// response.stripe_user_id is the connected account ID // Save this: it's the only thing you need to charge on their behalf await db.organizations.update({ where: { id: state }, data: { stripeAccountId: response.stripe_user_id }, }); }`
Enter fullscreen mode
Exit fullscreen mode
The stripe_user_id is the permanent link between your customer and their Stripe account. Store it. You'll use it for every API call on their behalf.
Creating charges on connected accounts
When an end-user pays, you create a PaymentIntent on the connected account:
const paymentIntent = await stripe.paymentIntents.create({ amount: 4900, // $49.00 currency: 'usd', application_fee_amount: 490, // your 10% platform fee // This is the key: charge goes to the connected account transfer_data: { destination: organization.stripeAccountId, }, });const paymentIntent = await stripe.paymentIntents.create({ amount: 4900, // $49.00 currency: 'usd', application_fee_amount: 490, // your 10% platform fee // This is the key: charge goes to the connected account transfer_data: { destination: organization.stripeAccountId, }, });Enter fullscreen mode
Exit fullscreen mode
The application_fee_amount is your revenue. Stripe sends amount - application_fee_amount to the connected account. Clean, automatic, no manual splits.
The webhook architecture (this is the hard part)
For a multi-tenant SaaS, you need to handle webhooks from both your own Stripe account AND all connected accounts. Stripe sends Connect webhooks to a single endpoint.
// api/webhooks/stripe/route.ts export async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get('stripe-signature');// api/webhooks/stripe/route.ts export async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get('stripe-signature');// Use the CONNECT webhook secret, not the regular one const event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_CONNECT_WEBHOOK_SECRET );
// event.account tells you WHICH connected account this is about const connectedAccountId = event.account;
switch (event.type) { case 'invoice.payment_failed': await handlePaymentFailed(event.data.object, connectedAccountId); break; case 'invoice.payment_succeeded': await handlePaymentSucceeded(event.data.object, connectedAccountId); break; case 'customer.subscription.deleted': await handleSubscriptionCanceled(event.data.object, connectedAccountId); break; }
return new Response('ok', { status: 200 }); }`
Enter fullscreen mode
Exit fullscreen mode
Two critical details:
-
Use the Connect webhook secret, not your regular webhook secret. These are different endpoints in the Stripe Dashboard (Settings > Webhooks > "Add endpoint" under "Connected accounts").
-
Make every handler idempotent. Stripe retries failed webhooks. Store the event.id and skip duplicates:
async function handlePaymentFailed(invoice, accountId) { // Idempotency check const existing = await db.webhookEvents.findUnique({ where: { stripeEventId: event.id } }); if (existing) return; // Already processedasync function handlePaymentFailed(invoice, accountId) { // Idempotency check const existing = await db.webhookEvents.findUnique({ where: { stripeEventId: event.id } }); if (existing) return; // Already processed// Find the org by their connected account const org = await db.organizations.findFirst({ where: { stripeAccountId: accountId } });
// Schedule dunning sequence for this org's customer await scheduleDunningEmails(org.id, invoice);
// Record the event await db.webhookEvents.create({ data: { stripeEventId: event.id, type: 'payment_failed' } }); }`
Enter fullscreen mode
Exit fullscreen mode
Data isolation with Row Level Security
Each organization's billing data must be isolated. With Supabase/Postgres, RLS handles this:
-- Every billing-related table has an org_id column ALTER TABLE recovery_campaigns ENABLE ROW LEVEL SECURITY;-- Every billing-related table has an org_id column ALTER TABLE recovery_campaigns ENABLE ROW LEVEL SECURITY;CREATE POLICY "org_isolation" ON recovery_campaigns FOR ALL USING (org_id = auth.jwt()->>'org_id');`
Enter fullscreen mode
Exit fullscreen mode
Your API never needs to filter by org manually. The database enforces isolation at the query level. This matters because a single webhook endpoint serves all connected accounts. Without RLS, a bug in your account-to-org mapping could leak data across tenants.
Testing Connect locally
Stripe CLI forwards webhooks to localhost. For Connect, use:
stripe listen --forward-connect-to localhost:3000/api/webhooks/stripe
Enter fullscreen mode
Exit fullscreen mode
The --forward-connect-to flag is different from --forward-to. It forwards Connect events (from connected accounts) instead of direct events. Get this wrong and you'll spend an hour wondering why your webhook handler never fires.
Test payment failures:
# Create a subscription with a card that fails on the next payment stripe customers create --stripe-account acct_CONNECTED_ID stripe subscriptions create \ --customer cus_xxx \ --items[0][price]=price_xxx \ --payment-settings[payment_method_types][0]=card \ --stripe-account acct_CONNECTED_ID# Create a subscription with a card that fails on the next payment stripe customers create --stripe-account acct_CONNECTED_ID stripe subscriptions create \ --customer cus_xxx \ --items[0][price]=price_xxx \ --payment-settings[payment_method_types][0]=card \ --stripe-account acct_CONNECTED_IDEnter fullscreen mode
Exit fullscreen mode
The pricing model
Your platform fee structure matters. Three options:
Percentage fee (most common): Take 5-15% of each transaction via application_fee_amount. Simple, scales with customer revenue.
Fixed subscription + percentage: Charge customers a monthly SaaS fee via your own Stripe account, plus a smaller percentage on their connected account transactions.
Fixed subscription only: Monthly fee, no per-transaction charge. Simpler billing but you don't benefit from customer growth.
For a billing/recovery SaaS like the one I built, percentage makes the most sense. You recover their failed payments, you take a cut of what you recovered. Aligned incentives.
Things that bit me
Webhook signature verification with raw body. Next.js App Router parses the request body by default. You need the raw body for signature verification. Use request.text() not request.json().
API version mismatches. If you hardcode an API version in your Stripe initialization (apiVersion: "2024-10-28.acacia") and then use a parameter that was added in a newer version, you'll get cryptic errors. Either pin to the latest version or don't pin at all.
Connect onboarding state. After OAuth, the connected account might not be fully set up (missing bank account, identity verification pending). Check account.charges_enabled and account.payouts_enabled before letting them use your platform. Show a setup checklist if they're not complete.
Test mode vs. live mode keys. Connect webhook secrets are different between test and live mode. I had test mode working perfectly, deployed to production, and webhooks silently failed for 3 days because I was using the test webhook secret. Check your environment variables twice.
Summary
The architecture for a multi-tenant Stripe Connect SaaS:
-
Standard accounts via OAuth (store stripe_user_id per org)
-
PaymentIntents with transfer_data.destination and application_fee_amount
-
Single Connect webhook endpoint with event.account routing
-
Idempotent handlers keyed on event.id
-
RLS for data isolation at the database level
-
stripe listen --forward-connect-to for local testing
The hard part isn't any single piece. It's getting all of them right at the same time. Connect webhooks are particularly tricky because bugs are silent. Your webhook returns 200, Stripe is happy, but the handler did nothing because it used the wrong secret or missed the event.account field.
Build it methodically, test each event type with Stripe CLI, and check your webhook logs in the Dashboard. The webhooks page shows every delivery with the full payload and response.
I build production AI systems. If you're working on something similar, I'm at astraedus.dev. My book Production AI Agents covers patterns like this in depth.
DEV Community
https://dev.to/diven_rastdus_c5af27d68f3/building-a-multi-tenant-saas-with-stripe-connect-in-2026-jjnSign in to highlight and annotate this article

Conversation starters
Daily AI Digest
Get the top 5 AI stories delivered to your inbox every morning.
More about
modelversionupdateFrom Direct Classification to Agentic Routing: When to Use Local Models vs Azure AI
<p>In many enterprise workflows, classification sounds simple.</p> <p>An email arrives.<br><br> A ticket is created.<br><br> A request needs to be routed.</p> <p>At first glance, it feels like a straightforward model problem:</p> <ul> <li>classify the input</li> <li>assign a category</li> <li>trigger the next step</li> </ul> <p>But in practice, enterprise classification is rarely just about model accuracy.</p> <p>It is also about:</p> <ul> <li>latency</li> <li>cost</li> <li>governance</li> <li>data sensitivity</li> <li>operational fit</li> <li>fallback behavior</li> </ul> <p>That is where the architecture becomes more important than the model itself.</p> <p>In this post, I want to share a practical way to think about classification systems in enterprise environments:</p> <ul> <li>when <str
Single-Cluster Duality View 🃏
<p>In DynamoDB, a <em><a href="https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/" rel="noopener noreferrer">single-table design</a></em> stores one-to-many relationships in a single physical block while still following relational-like normal form decomposition. In MongoDB, the <em><a href="https://www.mongodb.com/docs/manual/data-modeling/design-patterns/single-collection/" rel="noopener noreferrer">Single Collection Pattern</a></em> unnests relationships from a single document, but goes against the general recommendation as it sacrifices one of MongoDB’s key advantages—keeping a document in a single block. In Oracle Database and MySQL, JSON-relational <a href="https://oracle-base.com/articles/23/json-relational-duality-views-23" rel="noopener nor
Knowledge Map
Connected Articles — Knowledge Graph
This article is connected to other articles through shared AI topics and tags.
More in Products
From Direct Classification to Agentic Routing: When to Use Local Models vs Azure AI
<p>In many enterprise workflows, classification sounds simple.</p> <p>An email arrives.<br><br> A ticket is created.<br><br> A request needs to be routed.</p> <p>At first glance, it feels like a straightforward model problem:</p> <ul> <li>classify the input</li> <li>assign a category</li> <li>trigger the next step</li> </ul> <p>But in practice, enterprise classification is rarely just about model accuracy.</p> <p>It is also about:</p> <ul> <li>latency</li> <li>cost</li> <li>governance</li> <li>data sensitivity</li> <li>operational fit</li> <li>fallback behavior</li> </ul> <p>That is where the architecture becomes more important than the model itself.</p> <p>In this post, I want to share a practical way to think about classification systems in enterprise environments:</p> <ul> <li>when <str
Single-Cluster Duality View 🃏
<p>In DynamoDB, a <em><a href="https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/" rel="noopener noreferrer">single-table design</a></em> stores one-to-many relationships in a single physical block while still following relational-like normal form decomposition. In MongoDB, the <em><a href="https://www.mongodb.com/docs/manual/data-modeling/design-patterns/single-collection/" rel="noopener noreferrer">Single Collection Pattern</a></em> unnests relationships from a single document, but goes against the general recommendation as it sacrifices one of MongoDB’s key advantages—keeping a document in a single block. In Oracle Database and MySQL, JSON-relational <a href="https://oracle-base.com/articles/23/json-relational-duality-views-23" rel="noopener nor
Rewriting a FIX Engine in C++23: What Got Simpler (and What Didn't)
<p>QuickFIX has been around forever. If you've touched FIX protocol in the last 15 years, you've probably used it. It works. It also carries a lot of code that made sense in C++98 but feels heavy now.</p> <p>I wanted to see how far C++23 could take a FIX engine from scratch. Not a full QuickFIX replacement (not yet anyway), but a parser and session layer where I could actually use modern tools. The project ended up at about 5K lines of headers, covers 9 message types, parses an ExecutionReport in ~246 ns. QuickFIX does the same parse in ~730 ns on identical synthetic input.</p> <p>Microbenchmark numbers, so grain of salt. Single core, pinned affinity, RDTSCP timing, warmed cache, 100K iterations. But the code changes that got there were more interesting to me than the final numbers.</p> <h
Discussion
Sign in to join the discussion
No comments yet — be the first to share your thoughts!