---
title: "How virtual accounts automate payment reconciliation (with a worked example)"
seoTitle: "How virtual accounts automate payment reconciliation"
description: "Virtual accounts match each deposit to a customer by account number, not a free-text reference. A worked example with partial payments and micro-deposits."
date: "2026-09-20"
category: "payments"
author: "BlindPay Team"
faq:
  - q: "Do virtual accounts remove all manual reconciliation?"
    a: "No. They remove the hardest part: figuring out which customer a deposit belongs to. That match happens on the receiving account number. What remains is matching a deposit to a specific invoice within one customer, and handling exceptions like partial payments, overpayments, and short payments caused by fees taken in transit. Those can mostly be automated with rules."
  - q: "How are partial payments and overpayments handled with virtual accounts?"
    a: "The virtual account attributes the deposit to the right customer either way. Your ledger decides what happens next. A common rule set is to apply a partial payment to the matching or oldest open invoice and keep the balance open, and to hold an overpayment as customer credit rather than sending it back automatically. Decide these rules before launch, not per ticket."
  - q: "How should I store virtual account IDs?"
    a: "Store the provider's account id against your customer record, one row per account, plus every deposit's id linked to both. At BlindPay that means the virtual account id (va_...), the customer id (re_...), and each payin id (pi_...), with all amount fields as integers in minor units. Never key a deposit on its amount or date alone."
  - q: "What about refunds on virtual account deposits?"
    a: "There are two cases. If the provider returns a deposit before converting it, the deposit record ends in a refunded status, which at BlindPay means the funds went back to the sender, and you reverse the ledger entry. Once a deposit has completed and settled as stablecoins, a refund is a new outgoing payment to the payer, recorded separately."
  - q: "Why do $0.01 deposits show up on my virtual accounts?"
    a: "They are usually account-verification micro-deposits, sent by services that confirm a bank account works before using it. At BlindPay, every deposit creates its own payin, including $0.01 ones, and at least $0.01 of stablecoin is always delivered. Record them as verification deposits, not revenue, and don't flag them as fraud."
---

Virtual accounts automate payment reconciliation by giving each customer a unique account number, so every deposit arrives already tied to the customer it belongs to. Matching happens on the receiving account, not on a free-text reference the payer may truncate, misspell, or skip. What's left for your ledger is matching deposits to invoices within one customer.

That second step is small. The first one is where finance teams lose their afternoons.

## Key takeaways

- Reference-based matching fails in predictable ways: missing, truncated, or wrong references, batched payments, and amounts that don't match the invoice.
- A virtual account moves the match from the reference field to the account number, which has to be right for the money to arrive at all.
- Reconciliation still has two levels: deposit to customer (automatic) and deposit to invoice (rules you write).
- Store ids, not amounts. Keep every amount field in minor units so the numbers add up.

## Why does reference-based reconciliation fail?

Reference-based reconciliation fails because it depends on the payer typing the right text into a field that the banking system may shorten, drop, or never show. When a hundred customers pay into one shared account, the reference is the only thing that says who paid. It breaks in five common ways:

- **Missing references.** The payer forgets, or their bank app hides the field.
- **Truncated references.** Rails limit remittance text. ACH carries it in an addenda record of up to 80 characters. A SWIFT MT103 allows up to 140 characters in field 70, but not every bank in the chain passes all of it on. On BlindPay's own outbound SWIFT, only the first 30 characters reach the beneficiary ([POBO and COBO](/docs/kb/pobo-cobo)).
- **Batched payments.** One wire pays three invoices, with one reference that names one of them.
- **Wrong amounts.** Short payments, overpayments, and wires that lost a fee to an intermediary bank in transit.
- **Wrong names.** The money comes from a parent company, an accounts payable service, or a payroll provider, not the customer on the invoice.

Every one of these lands in a suspense queue and waits for a human.

## What changes with virtual accounts?

With a virtual account per customer, the receiving account number identifies the customer. The payer can leave the reference blank, pay from a parent company, or send an odd amount, and the deposit still lands on the right customer.

| | Manual, reference-based | Virtual account-based |
| --- | --- | --- |
| Matching key | Free-text reference, plus amount and date | Receiving account number |
| What drives errors | Missing or truncated references, name mismatches, duplicate amounts | Only invoice-level ambiguity within one customer |
| Time to match a deposit | Minutes to days, depending on the exception queue | At deposit time, when the webhook fires |
| Exceptions | Unknown payer: somebody has to find out who paid | Known payer, unknown invoice: rules decide |
| Audit trail | Spreadsheet notes and emails | Account id, deposit id, and amounts on every record |

A missing reference used to mean "who paid?" Now it means "which invoice?" That's a much easier question.

## What does the worked example look like?

**Illustrative example.** A B2B platform issues one virtual account per customer. Three customers have four open invoices, and five deposits arrive in one week. All names and amounts are illustrative.

Open invoices:

| Customer | Virtual account | Invoice | Amount due |
| --- | --- | --- | --- |
| Acme Logistics | `va_acme` | INV-101 | $12,000.00 |
| Acme Logistics | `va_acme` | INV-102 | $3,500.00 |
| Borealis Studio | `va_borealis` | INV-201 | $8,000.00 |
| Cobalt Health | `va_cobalt` | INV-301 | $20,000.00 |

Deposits, and how each one maps:

1. **$12,000.00 by ACH to `va_acme`, reference "INV-101".** Account says Acme, reference and amount say INV-101. Paid in full.
2. **$3,500.00 by wire to `va_acme`, no reference.** Account says Acme. Acme has exactly one open invoice for $3,500.00, so the amount rule matches INV-102. Paid in full.
3. **$5,000.00 by SWIFT to `va_borealis`, reference "INV-201 PART".** Account says Borealis. The amount is short of $8,000.00, so it's a partial payment: INV-201 stays open with $3,000.00 due. This is the one exception of the week, and it's a known customer with a known invoice.
4. **$0.01 by ACH to `va_cobalt`, from a bank verification service.** Account says Cobalt. It's a micro-deposit, recorded as account verification, applied to nothing.
5. **$20,000.00 by wire to `va_cobalt`, reference "HEALTH GRP PAYMT".** The reference names Cobalt's parent company and no invoice. Doesn't matter: account says Cobalt, amount matches INV-301. Paid in full.

With one shared account and reference matching, deposits 2 and 5 go to suspense (no usable reference), deposit 3 risks being marked as a full payment, and deposit 4 looks like fraud. With virtual accounts, all five land on the right customer the moment they arrive, and one needs a rule.

## Which webhook events should you listen for?

Listen for the events that create, update, and finish each deposit, plus the event that tells you an account is live. At BlindPay, from the [event catalog](/docs/learn/webhooks-events):

| Event | What to do with it |
| --- | --- |
| `virtualAccount.complete` | The account is approved. Store the routing and account numbers and show them to the customer. |
| `payin.new` | A deposit was detected. Create the ledger entry against the customer, status pending. |
| `payin.update` | The deposit moved, for example into `on_hold` review. Update the status, don't mark it failed. |
| `payin.complete` | The deposit finished. Read the `status` field: `completed`, `failed`, or `refunded`. |

Two rules keep an event-driven ledger honest. Deduplicate on the event id (`svix-id` at BlindPay), which stays the same across retries, because the same event can arrive twice. And treat final statuses as sticky, so a late `payin.update` can't move a completed deposit backwards. Signature checks, dedupe, and the daily sweep that catches missed events are covered in [stablecoin API webhooks and reconciliation](/resources/more/stablecoin-api-webhooks-reconciliation).

## How should you store virtual account IDs and deposit data?

Store ids and every amount field, never just the amount you expected. A deposit record at BlindPay carries enough to reconcile both the invoice side and the treasury side.

| Store | Where it comes from | Why |
| --- | --- | --- |
| Virtual account id (`va_...`) | Account creation | Links the account to your customer record |
| Customer id (`re_...`) | Customer creation | The owner of every deposit on the account |
| Payin id (`pi_...`) | `payin.new` | The deposit itself. Your idempotency key for ledger writes |
| `sender_amount` | The payin | What arrived from the payer. Match invoices against this |
| `receiver_amount` | The payin | The stablecoin delivered to the wallet, after fees |
| `billing_fee_amount`, `transaction_fee_amount`, `partner_fee` | The payin | Where the fee landed: invoiced monthly or deducted at transaction time |
| `sender_name`, `sender_bank_name`, `transaction_reference` | Wire payins | Who paid, from which bank, with which reference |
| Transaction hash | `tracking_complete` on the payin | The on-chain receipt for the stablecoin delivery |

The fee split matters more than it looks. At BlindPay, fees on deposits below $100.00 accrue to your monthly invoice, and at $100.00 or more they're deducted from the delivered stablecoin ([virtual accounts](/docs/virtual-accounts)). So `sender_amount` minus `receiver_amount` isn't always the fee. Read both fee fields rather than assuming one.

Match invoices on `sender_amount`, the amount that arrived from the payer. Book treasury on `receiver_amount`. Mixing them up makes every invoice look slightly short.

## How do you handle partial payments, overpayments, and refunds?

Write the rules before launch. A virtual account tells you who paid. It doesn't tell you what they meant.

- **Partial payment.** Apply it to the invoice named in the reference, or to the oldest open invoice if there's no reference. Keep the remainder open.
- **Overpayment.** Hold the difference as customer credit. Don't auto-return it; the next invoice usually absorbs it.
- **Short by a small amount on a SWIFT wire.** Often an intermediary fee taken in transit. Decide a tolerance, and who absorbs it, in advance.
- **Deposit under review.** An `on_hold` payin isn't failed. Keep it pending and tell the customer it's under review.
- **Refund before conversion.** A payin that ends `refunded` means the deposit went back to the sender. Reverse the ledger entry.
- **Refund after completion.** Once the stablecoins have settled to the wallet, a refund is a new outgoing payout to the payer, not a reversal. [Are stablecoin payments reversible?](/resources/more/are-stablecoin-payments-reversible) explains why.

## How does BlindPay track deposits on virtual accounts?

BlindPay tracks every deposit into a virtual account as its own payin, owned by the customer and tied to the exact account it landed on. A customer with several accounts, for example one for payroll funding and one for client invoices, keeps each flow separate: the payin's bank details identify which account received the money ([payins](/docs/payins)).

Every deposit of any size becomes a payin, including $0.01 micro-deposits, and at least $0.01 of stablecoin always reaches the wallet. Each account settles to one wallet, so the on-chain side reconciles the same way the bank side does.

One design constraint: accounts belong to the customer they were issued to, for that customer's own money. One account per customer, or a few per customer split by purpose, works. One account per invoice doesn't, since each named account goes through compliance and bank review. And accounts that collect for your customer's own clients, who were never onboarded, are nesting, which BlindPay doesn't allow ([nested payments](/docs/kb/nested-payments)).

## Where does BlindPay fit?

[BlindPay virtual accounts](/virtual-accounts) give each customer a named US bank account that receives ACH, wire, and SWIFT and settles to USDC or USDT in their wallet. Every deposit is a payin with its own id, amounts, fees, sender details, and on-chain hash, and every state change fires a signed webhook.

## What to do next

Pull last month's unmatched deposits and sort them by cause: missing reference, wrong name, wrong amount, batched. Everything in the first two buckets disappears with one virtual account per customer. Then write the three rules for the rest (partial, overpayment, short SWIFT) and test them on a free development instance with [create a virtual account](/docs/virtual-accounts-create).

*This article is for general information only and is not legal, tax, or financial advice.*
