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 },
});
}
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,
},
});
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 });
}
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.idand 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' }
});
}
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');
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
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
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_idper org) - PaymentIntents with
transfer_data.destinationandapplication_fee_amount - Single Connect webhook endpoint with
event.accountrouting - Idempotent handlers keyed on
event.id - RLS for data isolation at the database level
-
stripe listen --forward-connect-tofor 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)