Skip to main content
A virtual account is a real, payable bank account number that belongs to one of your customers. They pay into it by normal bank transfer, and the money lands in your balance for that currency. This guide creates one, reads it back, and lists what you already have.

What you’ll need

  • A dashboard JWT.
  • The accountId of the currency balance the account should sit under. Get it from Read Your Balances.
These endpoints authenticate with a JWT, not a secret key. A secret key returns 401 with a body that does not look like the other errors on this API:
Note there is no errorCode and no traceId. That flat shape is the signature of wrong-credentials on this route — it is easy to mistake for a wrong path, because a genuinely missing path returns a different, richer body:
If you get invalid_credentials, the path is right and the credential is wrong. Swap the key for a JWT before you go hunting for a different endpoint.

The shape of the flow

1

Find the parent account

The currency balance the virtual account belongs to.
2

Create the account

One call returns a payable account number.
3

Read it back

Confirm the details you give the customer.
4

List what you have

Find an existing account instead of making another.

Step 1: Find the parent account

Every virtual account hangs off a currency balance. Get that balance’s ID:
That accountId is the parentAccountId for the next step.

Step 2: Create the account

201 Created:
Give the customer accountDetails.accountNumber and accountDetails.bankName. Keep id — it is how you look the account up later.
Omitting parentAccountId fails with a misleading 404. The field is not declared as required, but leaving it out returns:
A 404 Resource not found on a POST reads like a wrong URL. It is not — the path is fine and the missing parent is the problem. Always send parentAccountId.
Genuine field validation returns a helpful 400 instead, naming the field:
Read additionalDetails — it names exactly what is wrong.
An unsupported currency returns 500, not 400. Sending "currency": "ZZZ" produces:
Do not retry on this — it will fail the same way every time. Validate the currency against /balances/currency-list before you call. Treat a 500 here as a bad request you sent, not a transient outage.
Creating is not idempotent. Every call makes a new account with a new number, even with identical input. There is no deduplication on accountName. Check Step 4 for an existing account before creating one, and store the id you get back — otherwise you will hand one customer several account numbers.
In test mode the provider is MOCK and the bank is Jollof Bank. These are not real banks and no real transfer will arrive. Live mode returns a real provider and a real bank name.

Step 3: Read it back

accountDetails is the entry from virtualAccounts with default: true. With one account they are the same thing. Prefer accountDetails for display and treat virtualAccounts as the full set.

Step 4: List what you have

Newest first, and paged — the live account had 72 accounts across 24 pages.
The list and detail responses are not the same shape. On the same account:The provider casing flips between the two. Compare it case-insensitively, and do not expect virtualAccountId from the detail call.
Filter by currency:
currency and accountTag both genuinely narrow the results.
An unrecognised query parameter is silently ignored, not rejected. Asking for ?zzzz=nonsense returns the full unfiltered list with a 200. A filter that looks like it works may be doing nothing — confirm the results actually narrowed before trusting a parameter you have not seen documented.

When things go wrong

What to do next