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

To turn on the virtual_accounts capability, the account needs a representative with a first name, a last name and a BVN. In live mode, Bachs requests the capability for every Nigerian business automatically. Once the BVN is in, Bachs checks it and the representative’s name against the national BVN register. This usually takes a few minutes. If the name does not match, or the BVN is not found, the field comes back as due with a reason you can show. When the capability turns on, Bachs emails the account’s contact email. If the account asked for virtual accounts itself, Bachs also creates the number then and the email includes it. Either way, you can create or read it yourself, as shown below.
1

Open your NGN balance

Go to Balance and click Get virtual account on your NGN balance card.
2

Create the number

Click Request virtual account. If the panel asks for details instead, your account isn’t ready yet. It tells you what it needs.You need to be an owner, or have permission to manage payins.
3

Copy the details

Click Account details on the card to see the Account number, Bank name and Account name.

Choose the bank

You can choose which bank holds the number. Send bank_code with the currency when you create it:
Create the number at Nombank MFB
  • You get the bank you asked for, or an error. If that bank is down, POST returns 400 with VIRTUAL_ACCOUNT_REFUSED and creates nothing. If we have switched it off for now, you get VIRTUAL_ACCOUNT_BANK_UNAVAILABLE. We never give you a number at a different bank. Try again later, pick another bank, or leave bank_code out.
  • Leave it out and we pick. Leave bank_code out, or send an empty string, and Bachs picks the bank. If that bank is down, we try the next one.
  • It only counts the first time. Once you have a number, it keeps its bank. Calling POST again returns the same number, whatever bank_code you send. You can’t move a number to another bank.
  • The response tells you the bank. bank_code on the response is the code from the table above, and bank_name is the name senders see.

Share the number

Give your customers the account number, bank name and account name together.
  • Account name: read it from account_name. It is your business name.
  • Connected accounts: the sender sees the connected account’s 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_name is always Sandbox Test Bank. Its bank_code is still a real code: the one you sent, or the bank we picked. 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 webhook 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.If the reason is “The bank could not make an account number right now. Please try again in a minute.”, nothing is wrong with the account. The bank is down or slow. Wait a minute and call POST again, or send a different bank_code. It never creates a second number.If you chose a bank we have switched off for now, POST returns 400 with VIRTUAL_ACCOUNT_BANK_UNAVAILABLE. Pick another bank, or leave bank_code out and we pick one.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.