Virtual Accounts in Fintech: Provisioning, Routing, and Reconciliation

Published: January 15, 2025
Updated: June 6, 2026
10 min read

🎯

How virtual accounts solve payment attribution in fintech: provisioning unique bank-registered account numbers, routing incoming transfers into a ledger idempotently, and reconciling against the bank as source of truth.

Introduction

Imagine you run a platform that receives bank transfers from thousands of users. Every day money lands in your company's bank account, and for each transfer you have to answer one deceptively hard question: who sent this, and what is it for? Bank transfers carry a memo field, so you ask users to type a reference code β€” ORDER12345 β€” and you hope they get it right. They don't. They typo it, omit it, or write "rent money lol," and now you have a pile of unattributed payments and a human manually matching them to orders.

This is the problem virtual accounts solve, and it's one of the most useful patterns in fintech that most engineers have never had to think about. I spent a chunk of my time at a personal-finance startup deep in virtual-account infrastructure β€” provisioning them, routing transactions through them, and reconciling the results β€” and it reshaped how I think about money movement. This post is what I learned: what a virtual account actually is, how you provision them at scale, how transactions route through them, and why reconciliation is the whole point.

The Problem Virtual Accounts Solve

The core issue is attribution. A traditional company bank account is a single destination. Every incoming transfer lands in the same place, and the only thing distinguishing one payment from another is whatever the sender typed in the memo. Free-text memos are a catastrophe for automation:

  • Users mistype reference codes, so your matching logic fails.
  • Two users can coincidentally use the same memo.
  • Refunds, partial payments, and overpayments have no clean linkage.
  • Reconciliation becomes a manual, error-prone, end-of-day chore.

What you actually want is for the destination account number itself to identify the payer β€” so attribution happens automatically the instant money arrives, with no memo parsing and no guessing. That's exactly what a virtual account gives you.

What a Virtual Account Actually Is

A virtual account (VA) is a unique account number that maps to a single real "master" settlement account at a partner bank. The bank issues you a range of account numbers; each one looks and behaves like a normal account number to the sender, but all the money actually pools into one physical account you control.

                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  User A β†’ VA 9988001 β†’β”‚                            β”‚
  User B β†’ VA 9988002 β†’β”‚   Master settlement acct   β”‚ β†’ your ledger
  User C β†’ VA 9988003 β†’β”‚        (one real acct)     β”‚
                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The magic is the 1:1 mapping between a VA number and a user (or an invoice, or an order). When a transfer arrives at 9988002, you don't need to read the memo β€” the destination number is the identity. Attribution is structural, not textual.

There are two common provisioning models:

  • Per-user VAs β€” each user gets one permanent VA. Good for wallets, recurring deposits, personal finance.
  • Per-transaction VAs β€” a fresh VA is minted for each invoice or order, often with an expiry. Good for one-off checkouts where you want a payment to map to exactly one order.

Provisioning: Minting Accounts at Scale

Provisioning is where the engineering starts. You're typically issued a BIN-prefixed number range by the bank β€” say, every account starting 9988 followed by your suffix. Your job is to allocate suffixes uniquely and durably.

async function provisionVirtualAccount(userId: string): Promise<VirtualAccount> {
  // Reuse if this user already has one (per-user model)
  const existing = await db.virtualAccount.findUnique({ where: { userId } })
  if (existing) return existing

  // Allocate the next suffix atomically β€” no two users may collide
  const suffix = await allocateNextSuffix() // DB sequence or counter, transactional
  const accountNumber = `${BANK_BIN_PREFIX}${suffix.padStart(7, '0')}`

  // Register it with the partner bank so transfers to it are accepted
  await bankApi.registerVirtualAccount({ accountNumber, userId })

  return db.virtualAccount.create({
    data: { userId, accountNumber, status: 'ACTIVE' },
  })
}

The non-negotiables here:

  • Uniqueness must be guaranteed at the database level β€” a sequence or a unique constraint, never an application-side "find max + 1" that races under load. A duplicated VA means two users' money becomes indistinguishable, which is the exact failure mode you set out to prevent.
  • Registration with the bank is a side effect that can fail β€” wrap provisioning so a half-created VA (allocated locally but not registered) is retried or rolled back, not left dangling.

Transaction Routing: From VA to Ledger

When money hits a VA, the bank notifies you β€” almost always by webhook. The payload says "account 9988002 received 500,000 VND." Routing is the act of turning that raw event into a ledger entry attributed to the right user:

async function handleIncomingTransfer(event: BankWebhookEvent) {
  const va = await db.virtualAccount.findUnique({
    where: { accountNumber: event.accountNumber },
  })
  if (!va) {
    // Money arrived at a VA we don't recognise β€” quarantine, don't drop
    return quarantineUnmatchedPayment(event)
  }

  await db.ledgerEntry.create({
    data: {
      userId: va.userId,
      type: 'CREDIT',
      amount: event.amount,
      reference: event.bankTransactionId, // idempotency key
    },
  })
}

Two things make or break this:

  • Never drop an unmatched payment. If a transfer arrives for an account you can't resolve, quarantine it for investigation. Money must never silently vanish β€” that's a regulatory and trust failure, not just a bug.
  • The bank's transaction ID is your idempotency key. Banks retry webhooks. Without idempotency you'll double-credit a user every time a webhook is redelivered.

Reconciliation: The Real Payoff

Here's the part people underestimate. Your system has its own record of what should have happened (your ledger). The bank has its own record of what actually happened (the settlement account statement). Reconciliation is proving those two agree β€” every credit in your ledger corresponds to a real transfer in the bank's statement, and vice versa.

VAs make this tractable because every payment is pre-attributed. A daily reconciliation job pulls the bank statement and matches each line against your ledger by transaction ID:

async function reconcileDay(date: string) {
  const statement = await bankApi.getStatement(date)   // source of truth: the bank
  const ledger = await db.ledgerEntry.findMany({ where: { date } })

  const byBankId = new Map(ledger.map(e => [e.reference, e]))

  for (const line of statement.lines) {
    const entry = byBankId.get(line.transactionId)
    if (!entry) flagMissingInLedger(line)          // bank has it, we don't
    else if (entry.amount !== line.amount) flagMismatch(entry, line)
    byBankId.delete(line.transactionId)
  }
  for (const orphan of byBankId.values()) flagMissingAtBank(orphan) // we have it, bank doesn't
}

Three failure classes fall out of this, and naming them is half the job:

  • Missing in ledger β€” the bank received money you never recorded (a dropped webhook). Recover it.
  • Missing at bank β€” you recorded a credit the bank has no record of. A bug or a fraudulent event; investigate hard.
  • Amount mismatch β€” same transaction, different number. Almost always a parsing or currency-unit error on your side.

A clean reconciliation run β€” zero discrepancies β€” is the daily proof that your money movement is correct. That report is what lets finance, auditors, and regulators trust the system.

Webhooks, Idempotency, and Eventual Consistency

Money systems are distributed systems, and the bank is a partner you don't control. Internalise three things:

  • Webhooks are at-least-once. Design every handler to be idempotent on the bank's transaction ID. Re-processing the same event must produce the same ledger, not a second credit.
  • The bank is the source of truth, your ledger is a replica. When they disagree, the statement wins. Your reconciliation job exists precisely to detect and resolve that drift.
  • Embrace eventual consistency. A transfer may show in a webhook seconds after it lands, or minutes. Build for the gap β€” pending states, retries, and a reconciliation backstop that catches anything the real-time path missed.

Edge Cases That Bite

The happy path is easy; the money is in the edges:

  • Overpayment / underpayment β€” for per-transaction VAs, decide up front whether a wrong amount is accepted, refunded, or held.
  • Late payment after expiry β€” money can arrive at an expired per-transaction VA. You still received it; you still must handle it.
  • Refunds and reversals β€” a credit can be clawed back. Your ledger needs the inverse entry, reconciled like any other.
  • Currency and minor units β€” store amounts in the smallest unit (e.g. integer VND/cents). Floating-point money is a bug waiting to happen.

Conclusion

Virtual accounts are a small idea with an outsized payoff: make the destination account number carry identity, and attribution stops being a memo-parsing guessing game. Provisioning gives every user or invoice a unique, bank-registered number; routing turns incoming transfers into attributed ledger entries idempotently; and reconciliation proves, every single day, that your records and the bank's records tell the same story.

What stuck with me most is the mindset shift. In most software a dropped event is an inconvenience; in money movement it's an unreconciled discrepancy that someone will notice. Building with virtual accounts taught me to treat the bank as the source of truth, my ledger as a replica to keep honest, and reconciliation as the non-negotiable safety net underneath it all.