Live
Black Hat USADark ReadingBlack Hat AsiaAI BusinessThe Great Claude Code Leak of 2026: Accident, Incompetence, or the Best PR Stunt in AI History?DEV CommunityAI 週報:2026/3/27–4/1 Anthropic 一週三震、Arm 首顆自研晶片、Oracle 裁三萬人押注 AIDEV CommunityTutorials vs. Transformations: What Beauty Content Wins in 2026Dev.to AIAnthropic employee error exposes Claude Code source - InfoWorldGoogle News: ClaudeMulti-Factor Strategies Aren't Exclusive to Big Firms: A Research Framework for Independent QuantsDev.to AISystem Instead of Team: Rethinking How Businesses Are BuiltDev.to AI10 лучших системных промптов ChatGPT: секреты успеха без опыта!Dev.to AIAI Post 4: When AI Gets It Wrong: Why AI Fails (And What That Teaches Us)Medium AIGoogle AI Overviews Are Reshaping Search — Here’s How to Get Your Business CitedDev.to AIThe $500/Month “Tool Trap” (And How Beginners Are Escaping It for Just $1)Medium AIAnthropic Accidentally Exposes Source Code for Claude Code - CNETGoogle News: ClaudeThe 4,500 Micro-Adjustment Question: Why the Best AI Still Needs a “Commander” in the Control Room.Medium AIBlack Hat USADark ReadingBlack Hat AsiaAI BusinessThe Great Claude Code Leak of 2026: Accident, Incompetence, or the Best PR Stunt in AI History?DEV CommunityAI 週報:2026/3/27–4/1 Anthropic 一週三震、Arm 首顆自研晶片、Oracle 裁三萬人押注 AIDEV CommunityTutorials vs. Transformations: What Beauty Content Wins in 2026Dev.to AIAnthropic employee error exposes Claude Code source - InfoWorldGoogle News: ClaudeMulti-Factor Strategies Aren't Exclusive to Big Firms: A Research Framework for Independent QuantsDev.to AISystem Instead of Team: Rethinking How Businesses Are BuiltDev.to AI10 лучших системных промптов ChatGPT: секреты успеха без опыта!Dev.to AIAI Post 4: When AI Gets It Wrong: Why AI Fails (And What That Teaches Us)Medium AIGoogle AI Overviews Are Reshaping Search — Here’s How to Get Your Business CitedDev.to AIThe $500/Month “Tool Trap” (And How Beginners Are Escaping It for Just $1)Medium AIAnthropic Accidentally Exposes Source Code for Claude Code - CNETGoogle News: ClaudeThe 4,500 Micro-Adjustment Question: Why the Best AI Still Needs a “Commander” in the Control Room.Medium AI

Building a Multi-Tenant SaaS with Stripe Connect in 2026

DEV Communityby Diven RastdusApril 1, 20269 min read0 views
Source Quiz

<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 = 
https://connect.stripe.com/oauth/authorize?` + 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,  }, });

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');

// 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 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;

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

Enter 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.

Was this article helpful?

Sign in to highlight and annotate this article

AI
Ask AI about this article
Powered by AI News Hub · full article context loaded
Ready

Conversation starters

Ask anything about this article…

Daily AI Digest

Get the top 5 AI stories delivered to your inbox every morning.

More about

modelversionupdate

Knowledge Map

Knowledge Map
TopicsEntitiesSource
Building a …modelversionupdateproductapplicationplatformDEV Communi…

Connected Articles — Knowledge Graph

This article is connected to other articles through shared AI topics and tags.

Knowledge Graph100 articles · 192 connections
Scroll to zoom · drag to pan · click to open

Discussion

Sign in to join the discussion

No comments yet — be the first to share your thoughts!

More in Products