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

Building Money Infrastructure for a SaaS: Plaid + Sila for Multi-Tenant Payments

How Trulipay built a multi-tenant money platform on Plaid and Sila—per-business wallets, KYC/KYB onboarding, bank linking through Plaid, and ACH money movement (issue, transfer, redeem) backed by a built-in ledger.

PlaidSilaKYCACHDigital WalletsMulti-tenantFintechNode.js
Table of contents

Building money infrastructure for a multi-tenant SaaS platform isn't the same as dropping a checkout button on a single product. When your platform moves money for hundreds of independent businesses, each one needs its own wallet, its own bank link, its own KYC, and money has to move cleanly between them—customer to merchant to platform.

We didn't want Trulipay to become a money transmitter. Holding funds and moving money over ACH is heavily regulated. So instead of building those rails ourselves, we built Trulipay's money layer on two APIs: Plaid for secure bank connectivity and Sila for regulated money movement—KYC/KYB, per-user wallets, ACH transfers, and a built-in ledger.

Here's how the pieces fit together.

Why Plaid + Sila, Not a Card Processor

A card processor is the right tool when you're charging cards. Trulipay moves money bank-to-bank over ACH between businesses, and that comes with a different set of requirements:

  • Each business needs to hold a balance and get paid out to its own bank account
  • Regulators require per-business KYC/KYB identity verification
  • We need to deduct a platform fee from each transaction without touching the money ourselves
  • We must never become a money transmitter or store raw bank credentials

Sila solves the regulated half: every business and customer becomes a Sila user with a wallet, balances are tracked as Sila's 1:1 USD-backed tokens, and money moves over ACH through three primitives—issue (bank → wallet), transfer (wallet → wallet), and redeem (wallet → bank). Plaid solves the connectivity half: it handles the bank login and hands Sila a token to provision ACH—so we never see account or routing numbers.

A Wallet Per Business

Every entity on the platform—merchants and their customers—maps to one Sila user. Sila signs every money movement with that user's wallet keypair, so the model is: a handle identifies the user, a keypair authorizes their transactions, and a ledger balance tracks their money.

Onboarding · signup to an active wallet

SilaPlaidready
MERCHANT_SIGNUP
Trulipay onboarding form
sila.register
handle + wallet keypair created
sila.requestKyc
KYC / KYB · identity + business verification
plaid.linkBank
Plaid Link → public token (bank login stays with Plaid)
sila.linkAccount
Plaid token → ACH-ready account on the wallet
WALLET_ACTIVE
can issue · transfer · redeem

Sila owns the regulated KYC and rails — we never see SSNs, account numbers, or bank credentials.

Registering Users and Wallets

When a new merchant signs up, we register them with Sila and generate a wallet keypair used to sign their future transactions:

async function registerMerchant(merchant: MerchantOnboarding) {
  // Each Sila user gets a handle plus a wallet keypair that signs their transactions
  const wallet = generateWallet(); // { address, privateKey }
  const handle = `trulipay_${merchant.id}`;
 
  await sila.register({
    handle,
    address: wallet.address,
    firstName: merchant.firstName,
    lastName: merchant.lastName,
    email: merchant.email,
    phone: merchant.phone,
    ssn: merchant.ssn, // KYC data — sent over TLS to Sila, never persisted by us
    dateOfBirth: merchant.dob,
    addressLine: merchant.addressLine,
    city: merchant.city,
    state: merchant.state,
    postalCode: merchant.postalCode,
  });
 
  // Store the handle + the wallet key (encrypted). Never store the raw KYC fields.
  await db.query(
    `UPDATE merchants
     SET sila_handle = $1, wallet_address = $2, wallet_key = $3
     WHERE id = $4`,
    [handle, wallet.address, encrypt(wallet.privateKey), merchant.id]
  );
}

The wallet key is the merchant's signing authority. We store it encrypted at rest and decrypt it only when we need to authorize a transaction on their behalf.

KYC / KYB Verification

Registration creates the user, but they can't move money until Sila clears their identity. We kick off KYC (or KYB for businesses) and wait for the result:

async function submitForKyc(merchantId: string) {
  const m = await getMerchant(merchantId);
 
  await sila.requestKyc({
    handle: m.sila_handle,
    walletKey: decrypt(m.wallet_key),
  });
}

KYC isn't instant, so we don't block on it. Sila posts the outcome to our webhook, and we flip the merchant's status when verification passes:

// Sila → our webhook when verification finishes
sila.on("kyc_status", async (event) => {
  const m = await getMerchantBySilaHandle(event.user_handle);
 
  const verified = event.kyc_status === "passed";
 
  await db.query(
    `UPDATE merchants
     SET kyc_status = $1, kyc_completed_at = $2
     WHERE id = $3`,
    [verified ? "verified" : "pending", verified ? new Date() : null, m.id]
  );
 
  if (verified) await notifyMerchant(m.id, "kyc_approved");
});

We rely on the webhook—not the API response from requestKyc—as the source of truth for verification status. The initial call only enqueues the check.

Linking Bank Accounts with Plaid

A verified wallet still has no way in or out until it's linked to a real bank account. Plaid Link handles the bank login on the client; we hand the resulting public token straight to Sila, which exchanges it and provisions the account for ACH:

// Step 1: create a Plaid Link token — the client opens Plaid Link with this
async function createPlaidLinkToken(merchantId: string): Promise<string> {
  const res = await plaidClient.linkTokenCreate({
    user: { client_user_id: merchantId },
    client_name: "Trulipay",
    products: [Products.Auth],
    country_codes: [CountryCode.Us],
    language: "en",
  });
  return res.data.link_token;
}
 
// Step 2: pass Plaid's public token to Sila — it sets up the ACH account
async function linkBankAccount(
  merchantId: string,
  publicToken: string,
  accountId: string
): Promise<void> {
  const m = await getMerchant(merchantId);
 
  await sila.linkAccount({
    handle: m.sila_handle,
    walletKey: decrypt(m.wallet_key),
    plaidToken: publicToken, // Sila exchanges this with Plaid itself
    accountId,
    accountName: "primary",
  });
 
  await db.query(
    `UPDATE merchants SET bank_linked = true WHERE id = $1`,
    [merchantId]
  );
}

Trulipay never touches account or routing numbers. Plaid holds the bank credentials, Sila holds the ACH details, and we hold neither—just a bank_linked flag for display.

Moving Money: Issue, Transfer, Redeem

With a verified wallet and a linked bank, money movement is three primitives. A customer paying a merchant $100, with a 3% Trulipay fee, looks like this:

Money flow · issue → transfer → redeem

Plaid-linked bank
Customer bank · $100
source of funds
issue_sila · ACH debit · mints 1:1 USD
Sila wallet
Customer wallet · $100
funds now on the ledger
transfer_sila
Merchant wallet · $97
net to the business
transfer_sila
Trulipay wallet · $3
platform fee
redeem_sila · ACH credit (1–4 business days)
Plaid-linked bank
Merchant bank · $97
payout settles to the business

Bank in (issue), ledger move (transfer), bank out (redeem) — the whole money lifecycle, three calls.

async function processPayment(opts: {
  customerHandle: string;
  customerKey: string;
  merchantHandle: string;
  amount: number; // cents
  feeBps: number; // e.g. 300 = 3%
}) {
  const fee = Math.round((opts.amount * opts.feeBps) / 10_000);
  const net = opts.amount - fee;
 
  // 1. Pull funds from the customer's bank into their wallet (ACH debit)
  const issued = await sila.issueSila({
    handle: opts.customerHandle,
    walletKey: opts.customerKey,
    amount: opts.amount,
    accountName: "primary",
  });
 
  // 2. Move the net to the merchant and the fee to Trulipay — instant ledger transfers
  await sila.transferSila({
    handle: opts.customerHandle,
    walletKey: opts.customerKey,
    destinationHandle: opts.merchantHandle,
    amount: net,
  });
 
  await sila.transferSila({
    handle: opts.customerHandle,
    walletKey: opts.customerKey,
    destinationHandle: "trulipay_platform",
    amount: fee,
  });
 
  return { reference: issued.reference };
}

issueSila is the only step that touches the bank—transferSila moves balances on Sila's ledger and settles instantly, which is what makes the fee split clean: there's no separate accounting to reconcile, the platform fee simply lands in our wallet.

Paying a merchant out is the reverse—a redeem, which pushes wallet balance back to their bank over ACH:

async function payoutMerchant(merchantId: string, amount: number) {
  const m = await getMerchant(merchantId);
 
  await sila.redeemSila({
    handle: m.sila_handle,
    walletKey: decrypt(m.wallet_key),
    amount,
    accountName: "primary",
  });
}

ACH Is Asynchronous: Status via Webhooks

The catch with ACH is that issue and redeem aren't instant—they settle over 1–4 business days and can fail or be returned after the fact (insufficient funds, closed account). Transfers between wallets are instant, but anything touching a bank is eventually-consistent. We track that on Sila's transaction webhook:

sila.on("transaction", async (event) => {
  // pending → success | failed | returned
  await db.query(
    `UPDATE ledger_transactions
     SET status = $1, updated_at = NOW()
     WHERE sila_reference = $2`,
    [event.status, event.transaction_id]
  );
 
  if (event.status === "returned") {
    // An ACH return after we already credited a wallet — reverse it
    await reverseLedgerEntry(event.transaction_id);
    await alertTeam(`ACH return on ${event.transaction_id}`);
  }
});

Because an issue can be returned days later, we don't treat funds as final on the API response—a payment is only durable once the issuing transaction reaches success. For high-trust merchants we optimistically release; for the rest, payouts wait for settlement.

Key Takeaways

  • Don't become a money transmitter—Sila holds the regulated rails, the ledger, and KYC/KYB; you orchestrate, you don't custody.
  • Hand Plaid's public token straight to Sila—you never see or store account and routing numbers, which keeps you out of PCI/bank-credential scope.
  • A wallet keypair per user—Sila signs every money movement with the user's key; store it encrypted, and never persist the raw KYC fields you pass through.
  • issue → transfer → redeem is the whole lifecycle—bank in, instant ledger move, bank out; the fee split is just an extra transfer.
  • ACH is asynchronousissue/redeem settle in days and can be returned; reconcile on the transaction webhook and have a reversal path before you treat money as final.