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 insteadIf
TransfersYou 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=1234567890

Show 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/beneficiaries

Store 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-REFERENCE

Reversal 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

  1. A single payout succeeds and the ledger updates once.
  2. Name enquiry mismatch blocks the payout in your UI.
  3. A bulk payout above the provider limit splits and completes.
  4. A partially failed batch is reconciled per child, not per batch.
  5. A duplicate request with the same idempotency key does not send twice.

Next


Did this page help you?