Back to blogs
Fintech / Payments
8 min read
Mar 15, 2025

Building Payment Infrastructure for a SaaS: One Gateway Done Right with Stripe Connect

How Trulipay uses Stripe Connect to power per-business payment accounts, handle KYC identity verification, and verify bank accounts through Plaid—building a multi-tenant payment platform where every merchant gets isolated payment flows.

Stripe ConnectPlaidKYCSaaS PaymentsMulti-tenantFintechNode.js
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.updated webhooks 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_amount on 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.