Table of contents
Building payment infrastructure for a multi-tenant SaaS platform isn't the same as adding Stripe Checkout to a single product. When your platform powers payments for hundreds of independent businesses, you need each business to have its own payment account, its own payout flow, and its own compliance verification. Stripe Connect was built for exactly this.
Here's how we architected Trulipay's payment layer around it.
Why Stripe Connect, Not Standard Stripe
Standard Stripe works when your platform is the merchant of record—you take payments, you pay your own costs, you manage everything centrally. It breaks down when:
- Businesses need their own payouts to their own bank accounts
- Each business has different pricing and fee structures
- Regulators require per-business KYC and identity verification
- You need to deduct platform fees from each transaction
Stripe Connect solves these by giving each business a Connected Account: a full Stripe account linked to your platform. You collect payments on their behalf, Stripe routes the funds to their account after deducting your platform fee.
Connected Account Setup
When a new merchant signs up on Trulipay, we create a Connected Account for them immediately:
async function createMerchantAccount(merchant: MerchantOnboarding) {
const account = await stripe.accounts.create({
type: "express",
country: merchant.country,
email: merchant.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: merchant.businessType, // 'individual' or 'company'
business_profile: {
name: merchant.businessName,
product_description: merchant.description,
url: merchant.website,
},
metadata: {
trulipay_merchant_id: merchant.id,
},
});
await db.query(
`UPDATE merchants SET stripe_account_id = $1 WHERE id = $2`,
[account.id, merchant.id]
);
return account;
}We use Express accounts rather than Custom accounts. Express gives us a Stripe-hosted onboarding flow for collecting the merchant's business details and banking information, which means we don't have to build and maintain that UI ourselves.
KYC Onboarding Flow
Stripe handles the KYC verification for most markets, but we needed to surface onboarding status to the merchant so they know what's blocking their account. We use the AccountLinks API to generate a temporary onboarding URL:
async function getOnboardingLink(merchantId: string): Promise<string> {
const merchant = await getMerchant(merchantId);
const accountLink = await stripe.accountLinks.create({
account: merchant.stripe_account_id,
refresh_url: `${process.env.APP_URL}/onboarding/refresh`,
return_url: `${process.env.APP_URL}/onboarding/complete`,
type: "account_onboarding",
});
return accountLink.url;
}The merchant clicks this link, completes Stripe's hosted KYC form (identity documents, tax IDs, business verification), and returns to our platform. We listen for the account.updated webhook to detect when their account moves from restricted to enabled:
stripe.on("account.updated", async (account) => {
const merchant = await getMerchantByStripeId(account.id);
const isEnabled =
account.charges_enabled &&
account.payouts_enabled &&
account.details_submitted;
await db.query(
`UPDATE merchants
SET kyc_status = $1, kyc_completed_at = $2
WHERE id = $3`,
[
isEnabled ? "verified" : "pending",
isEnabled ? new Date() : null,
merchant.id,
]
);
if (isEnabled) {
await notifyMerchant(merchant.id, "kyc_approved");
}
});Bank Account Verification via Plaid
For markets where Stripe's instant verification isn't available, or when merchants request faster payouts (which requires verified external bank accounts), we integrate Plaid for bank verification.
The flow uses Plaid Link to let the merchant log in to their bank and grant us read access to verify the account details:
// Step 1: Create Link token
async function createPlaidLinkToken(merchantId: string): Promise<string> {
const response = await plaidClient.linkTokenCreate({
user: { client_user_id: merchantId },
client_name: "Trulipay",
products: [Products.Auth],
country_codes: [CountryCode.Us, CountryCode.Gb, CountryCode.In],
language: "en",
});
return response.data.link_token;
}
// Step 2: Exchange public token after Plaid Link completes
async function exchangePlaidToken(
merchantId: string,
publicToken: string,
accountId: string
): Promise<void> {
const { access_token } = (
await plaidClient.itemPublicTokenExchange({ public_token: publicToken })
).data;
// Get account and routing numbers
const { numbers } = (
await plaidClient.authGet({ access_token })
).data;
const ach = numbers.ach.find((n) => n.account_id === accountId);
if (!ach) throw new Error("Account not found in Plaid response");
// Add verified bank account to Stripe Connected Account
const merchant = await getMerchant(merchantId);
await stripe.accounts.createExternalAccount(merchant.stripe_account_id, {
external_account: {
object: "bank_account",
country: "US",
currency: "usd",
routing_number: ach.routing,
account_number: ach.account,
},
});
await db.query(
`INSERT INTO merchant_bank_accounts (merchant_id, last_four, bank_name, verified_at)
VALUES ($1, $2, $3, NOW())`,
[merchantId, ach.account.slice(-4), numbers.ach[0].account_id]
);
}We never store the full account number or routing number in our database—only the last four digits for display and a flag indicating Plaid-verified status.
Payment Charge Flow with Fee Deduction
When a customer pays a merchant through Trulipay, we create a PaymentIntent on the merchant's Connected Account and deduct our platform fee:
async function createPaymentIntent(
merchantId: string,
amount: number,
currency: string,
customerId?: string
): Promise<Stripe.PaymentIntent> {
const merchant = await getMerchant(merchantId);
const platformFeePercent = await getPlatformFeeRate(merchant);
const platformFee = Math.round(amount * platformFeePercent);
return stripe.paymentIntents.create({
amount,
currency,
application_fee_amount: platformFee,
transfer_data: {
destination: merchant.stripe_account_id,
},
customer: customerId,
metadata: {
merchant_id: merchantId,
trulipay_fee: platformFee,
},
});
}Stripe handles the fee split automatically—the application_fee_amount flows to our platform account, and the remainder transfers to the merchant's Connected Account at settlement time.
Payout Scheduling
Merchants control their own payout schedule. By default, Stripe pays out daily, but we let merchants choose:
async function updatePayoutSchedule(
merchantId: string,
schedule: "daily" | "weekly" | "monthly"
) {
const merchant = await getMerchant(merchantId);
const scheduleConfig = {
daily: { delay_days: 2, interval: "daily" },
weekly: { delay_days: 7, interval: "weekly", weekly_anchor: "friday" },
monthly: { delay_days: 14, interval: "monthly", monthly_anchor: 15 },
};
await stripe.accounts.update(merchant.stripe_account_id, {
settings: {
payouts: {
schedule: scheduleConfig[schedule],
},
},
});
}The delay allows time for disputes and chargebacks to surface before funds transfer to the merchant's bank.
Key Takeaways
- Express accounts hit the right balance between control and maintenance cost—Stripe hosts KYC and banking UX, you own the product experience around it.
- Listen to
account.updatedwebhooks for KYC completion, not the return URL—the return URL fires after redirect, but the account isn't necessarily verified yet. - Never store raw bank details—Plaid's token exchange gives you everything you need to create a Stripe external account without touching sensitive numbers.
application_fee_amounton PaymentIntents is the cleanest way to split platform fees—Stripe handles the accounting automatically.- Separate payment creation from charge execution—create the PaymentIntent server-side, confirm it on the client, never on the server. Stripe's fraud tools run during client-side confirmation.