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
Find the parent account
The currency balance the virtual account belongs to.
Create the account
One call returns a payable account number.
Read it back
Confirm the details you give the customer.
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