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
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
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 asynchronous—
issue/redeemsettle in days and can be returned; reconcile on the transaction webhook and have a reversal path before you treat money as final.