Skip to main content
A virtual account is a permanent NGN bank account number that belongs to your account, or to a connected account you manage. Anyone can send money to it from any Nigerian bank, at any time, and each deposit becomes a payment in your Bachs balance. In this guide you’ll get your number, share it, test your integration, and match each deposit to the customer who sent it. By the end you’ll have a working way to take repeat bank transfers without a checkout.

Is a virtual account right for you?

A virtual account is one number for your whole business. It is the right tool when the same people pay you again and again. It is the wrong tool when you need each payment tied to one order. You get one virtual account per account, per currency. There is no number per customer. If you need a separate number for each seller or client, give each one a connected account and issue the number there. NGN is the only currency available today.

Get your account number

Before you start

Bachs requests the virtual_accounts capability for every Nigerian account automatically. To turn it on, the account needs:
  • Its product information
  • Identity verification
  • The representative’s BVN
Once those are in, Bachs reviews the account. This review is not automatic, so allow time for it. We email you when you can create your number.
Virtual accounts are rolling out account by account. If your capability is active but you cannot create a number, your account is not enabled yet. Contact support with your account ID.
1

Open your NGN balance

Go to Balance and find your NGN balance card. Click Get virtual account on the card. You may also see a Get your virtual account banner above your balances.
2

Create the number

Click Request virtual account. This creates your number straight away; there is no second approval at this point.You need to be an owner, or have permission to manage payins. If the panel asks for details instead, or says your request is with us, your account isn’t approved yet. It tells you what, if anything, it needs from you.
3

Copy the details

Click Account details on your NGN balance card. You’ll see the Account number, Bank name and Account name, each with a copy button.

Share the number

Give your customers the account number, bank name and account name together.
  • Account name: your business name. Some issuing banks add a prefix, so a sender may see something like Bachs Checkout - Your Business. Tell regular customers what to expect so they don’t think they have the wrong account.
  • Connected accounts: the sender sees the connected account’s business name, not your platform’s.
  • No reference needed: customers don’t need a reference to pay. If they add your invoice number in their bank’s narration or description field, matching is much easier (see Match deposits to customers).

Test your integration in sandbox

A sandbox number looks real but can never receive money. It starts with 90 and its bank is Sandbox Test Bank. There is no way to simulate a deposit into it today. In sandbox you can test:
  • Creating and reading the number, including the idempotent retry
  • Your handler’s signature check, by sending a sample event:
The sample event is shaped like a checkout payment, not a deposit. It has a checkout_id, no payment_method_details, and a lowercase status. Test your deposit-parsing code against the deposit example below instead.
Refunds are not blocked in sandbox, but a live deposit cannot be refunded. Don’t build a refund path for deposits on the strength of a sandbox test.

Receive a deposit

When a deposit arrives, you get collection.succeeded. A deposit is easy to tell apart from a checkout payment because checkout_id is null and virtual_account.type is permanent.
collection.succeeded for a deposit
  • Fields can be null. Every sender field is always present, but the sending bank can leave any of them out. narration in particular is often blank. Code for null on each one.
  • Deduplicate on the event id. A redelivered event has the same id.
  • The money is available immediately. NGN deposits move to your available balance as soon as they are confirmed, unless the deposit is over your limit (see Large deposits are held for review).
  • The fee comes off first. Each deposit costs 1% of the amount, capped at NGN 300, so a NGN 250,000 deposit settles NGN 249,700. See Fees.

Match deposits to customers

A deposit tells you who sent money and how much. It does not tell you what the money is for. Matching is your job, so decide your rules before you go live. A reliable order to try:
  1. A known payer. Save each customer’s sender_account_number the first time you confirm one of their payments. Later deposits from that account are almost certainly theirs.
  2. An invoice number in the narration. Ask customers to put your invoice number in their transfer description. Look for it in narration.
  3. Amount and timing. An exact amount from a customer with one open invoice is a strong hint, but treat it as a suggestion.
  4. Everything else goes to a review queue. Don’t guess. Show unmatched deposits to someone who can check them.
Match a deposit in your collection.succeeded handler
Amounts are decimal strings. Compare them as strings, or with a decimal library, never as floats.

Catch up on missed deposits

If your endpoint was down, you can find the deposits you missed in two ways. Replay the events. Redeliver a deposit’s collection.succeeded with POST /v1/webhooks/replay and its charge_id, or by event ID with bachs events replay. See Replay events. List your payments. GET /v1/payments has no filter for deposits, so filter the results yourself. A deposit has:
  • payment_method.type: "ngn_bank_transfer"
  • no source.checkout_id
The list does not include sender details. Call GET /v1/payments/{payment_id} for each deposit to get payment_method_details.
List recent payments

What can go wrong

Each account has a limit on a single deposit and on a day’s deposits added together. By default both are NGN 5,000,000, with the day counted in UTC. Your account may have its own limits.A deposit over either limit is not refused, because a bank transfer cannot be stopped. It is held for a manual review before it reaches your balance:
  • You still receive collection.succeeded straight away, with nothing in it to say the deposit is held.
  • The amount appears in neither your pending nor your available balance until we release it.
  • No event is sent on release.
If a large deposit has not reached your balance, contact support with the charge_id. To raise your limits ahead of time, see Deposit limits.
The sending bank decides what it passes on. Any of sender_name, sender_bank, sender_bank_code, sender_account_number and narration can be null. When they are, the deposit goes to your review queue (see Match deposits to customers). The session_id is the interbank reference your customer’s bank can use to trace the transfer.
A deposit cannot be refunded through the API. The payment has is_refundable: false, and a refund request returns 400. Contact support with the charge_id to return it.
If the issuing bank refuses to create the number, POST returns 400 with VIRTUAL_ACCOUNT_REFUSED and the bank’s reason. Fix what the reason names, then try again. If the reason isn’t clear, contact support. See Virtual account errors for every code.
A number can be marked inactive. It then no longer appears when you read it, but money sent to it is still received and credited. If you see this, contact support before you give out a different number.

Go-live checklist

  • Your live API key has virtual_accounts:read and virtual_accounts:write.
  • Your webhook endpoint verifies signatures and deduplicates on the event id.
  • Your handler copes with null in every sender field.
  • You have matching rules and a review queue for deposits you can’t match.
  • You know your deposit limits, and who to contact when a large deposit is held.
  • You’ve told regular customers the account name they’ll see in their bank app.