Virtual Account Transfers
Send NGN payouts from the virtual-account rail, including amounts above the provider limit.
Virtual account transfers send NGN payouts from the virtual-account rail. The flow mirrors Transfers — banks, name enquiry, beneficiary, send — on its own set of endpoints under /v1/va/transfer.
It adds one capability the standard rail does not have: sending an amount larger than the provider will accept in a single payout.
| Use instead | If |
|---|---|
| Transfers | You are paying out from the standard payout rail. |
The Flow
sequenceDiagram
participant Y as Your backend
participant K as Kyshi
participant B as Beneficiary bank
Y->>K: GET /v1/va/transfer/banks
Y->>K: GET /v1/va/transfer/name-enquiry
K-->>Y: Account holder name
Note over Y: Confirm it matches
Y->>K: POST /v1/va/transfer/beneficiaries
K-->>Y: beneficiaryId
Y->>K: POST /v1/va/transfer
K-->>Y: Accepted (pending)
K->>B: Payout
K-->>Y: transfer.success / failed / reversed
Build It
1. Bank list, then name enquiry
GET {{host}}/v1/va/transfer/banks
GET {{host}}/v1/va/transfer/name-enquiry?bankCode=000013&accountNumber=1234567890Show the resolved name to whoever authorises the payout and make them confirm it. Bank transfers settle on account number alone — the name is not checked by the rail, so one wrong digit sends real money to a real stranger. See Transfers for why this step is not optional.
2. Create a beneficiary
POST {{host}}/v1/va/transfer/beneficiariesStore the beneficiaryId. VA beneficiaries are separate from standard transfer beneficiaries — an ID from one rail is not valid on the other.
3. Send
-H "x-api-key: your_secret_key"POST {{host}}/v1/va/transfer{
"beneficiaryId": "8495ec4e-ce21-405f-b0cf-982702881f4d",
"currency": "NGN",
"amount": 50000,
"narration": "Payout for ORDER-10001"
}You can pass an inline beneficiary object instead of a beneficiaryId for one-offs. Kyshi finds or creates the beneficiary during initiation.
Large Payouts
Providers cap how much can move in a single payout. On this rail the limit is 5,000,000 NGN. Sending more is not a matter of retrying — the payout has to be split.
POST /v1/va/transfer/bulk does that for you.
-H "x-api-key: your_secret_key"
-H "idempotency-key: unique-bulk-transfer-key"{
"beneficiaryId": "8495ec4e-ce21-405f-b0cf-982702881f4d",
"currency": "NGN",
"amount": 100000000,
"narration": "Bulk supplier payout",
"splitCount": 20
}Despite the name, this is one payout to one beneficiary, split into multiple provider payouts. It is not a way to pay many different people in one request — for that, call the single-transfer endpoint once per recipient.
What Kyshi does:
- calculates the fee once, on the full amount
- reserves the total debit once
- creates a batch and processes each child payout in the background
Omit splitCount and Kyshi splits by the provider limit automatically. Supply it (1–200) only if you have a reason to control the split.
Track the batch:
GET {{host}}/v1/va/transfer/bulk/{id}Send the idempotency-key header. A duplicate bulk request without one could reserve and send the whole amount twice.
When It Fails
Insufficient funds
The balance must cover the amount plus fees. For a bulk payout that is the full amount plus its single fee, reserved up front — not per split. See Balances.
A batch partially completes
Children are processed independently, so a batch can end with some payouts delivered and others failed. The beneficiary receives part of what you sent.
Check the batch and its children rather than assuming the batch status covers everything. Reconcile what actually landed before re-sending anything, or you will double-pay the successful portion.
Beneficiary ID from the wrong rail
VA beneficiaries and standard transfer beneficiaries live in separate sets. Passing a standard beneficiaryId here returns not-found.
Duplicate sends
A timeout does not mean the payout did not happen. Look it up by your reference before retrying:
GET {{host}}/v1/va/transfer?query=YOUR-REFERENCEReversal after success
transfer.reversed can arrive after transfer.success. Your ledger must be able to unwind a payout it already marked final.
Reconcile
async function reconcileVaPayouts() {
for (const p of await db.vaPayouts.pendingLongerThan({ minutes: 15 })) {
const remote = p.isBulk
? await kyshi.virtualAccountTransfers.retrieveBulk(p.batchId)
: await kyshi.virtualAccountTransfers.retrieve(p.transferId);
if (p.isBulk) {
// A batch can be partially successful — reconcile children individually.
for (const child of remote.items) await applyOutcome(p, child);
} else {
await applyOutcome(p, remote);
}
}
}Test It
- A single payout succeeds and the ledger updates once.
- Name enquiry mismatch blocks the payout in your UI.
- A bulk payout above the provider limit splits and completes.
- A partially failed batch is reconciled per child, not per batch.
- A duplicate request with the same idempotency key does not send twice.
Next
- Initiate VA Transfer · Initiate Bulk VA Transfer
- Transfers — the standard payout rail
- Balances — what you can send
Updated 5 days ago
