DEV Community

Diven Rastdus
Diven Rastdus

Posted on

Building a Multi-Tenant SaaS with Stripe Connect in 2026

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:

  1. Let each customer connect their own Stripe account
  2. Process payments from their end-users into their Stripe account
  3. Take a platform fee from each transaction
  4. Handle webhooks for all connected accounts
  5. 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:

  1. 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").

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

  1. Standard accounts via OAuth (store stripe_user_id per org)
  2. PaymentIntents with transfer_data.destination and application_fee_amount
  3. Single Connect webhook endpoint with event.account routing
  4. Idempotent handlers keyed on event.id
  5. RLS for data isolation at the database level
  6. 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.

Top comments (0)