Virtual Accounts
Collect by bank transfer into an account Kyshi creates for your customer.
A virtual account is a set of bank account details Kyshi creates so your customer can pay you by bank transfer. When money arrives, Kyshi records it as a transaction and notifies you.
Use virtual accounts when your customer prefers to pay from their banking app rather than entering card details — the dominant way people pay in several of Kyshi's markets.
| Use instead | If |
|---|---|
| Transactions | You want a hosted checkout page with multiple payment methods. |
| Payment Links | You want to send a customer a link rather than account details. |
The thing to understand before you build: your customer types the amount themselves, in their own banking app. You cannot constrain what they send. Underpayment and overpayment are normal outcomes, not edge cases, and this guide is largely about handling them.
The Flow
sequenceDiagram
participant C as Customer
participant Y as Your backend
participant K as Kyshi
participant B as Customer's bank
Y->>K: POST /v1/wallets
K-->>Y: account number, payableAmount
Y->>C: Display details + exact amount
C->>B: Transfer
B->>K: Credit
K-->>Y: Webhook charge.success
Y->>K: GET /v1/wallets/verify
K-->>Y: collectionStatus
Y->>C: Fulfil (only if COMPLETED)
Build It
1. Create the account
-H "x-api-key: your_secret_key"POST {{host}}/v1/wallets{
"customer": { "email": "[email protected]", "firstName": "Ada", "lastName": "Okafor" },
"currency": "NGN",
"accountCategory": "VIRTUAL_ACCOUNT",
"amount": 5000,
"reference": "ORDER-10001",
"feeBearer": "CUSTOMER",
"expiresAt": "60"
}Pass your own order ID as reference. It is how you will match the credit back later.
2. Show the customer the payable amount
The response carries two different amounts, and showing the wrong one is the single most common cause of failed collections.
| Field | Meaning |
|---|---|
requestedAmount | What you asked for. |
payableAmount | What the customer must transfer. |
When feeBearer is CUSTOMER, Kyshi adds the fee on top and solves for a payable amount that nets you the full requested amount. Your 5000 becomes something like 5070.
The payable amount is then rounded, so the customer is not asked to type an awkward figure. Rounding is configured on your business — a rounding step of 1, 10, 50, or 100, applied by rounding up (the default) or to the nearest step.
requestedAmount 5000
+ fees 67
= payable 5067
rounded up to 10 5070 ← show thisDisplay payableAmount, the account number, the account name, and the bank name. Display the expiry too, if you set one.
3. Wait for the credit
Do not poll in a loop. Kyshi sends charge.success and virtual_account.credit when money arrives. See Webhook Events.
4. Verify before fulfilling
The webhook tells you something happened. Verification tells you what.
GET {{host}}/v1/wallets/verifyCheck collectionStatus before releasing anything.
Request Fields
| Field | Required | Notes |
|---|---|---|
customer | Yes | At least email. firstName, lastName, phoneNumber recommended. |
currency | Yes | NGN, KES, ZAR, GHS, XOF, USD, subject to your enabled integrations. |
accountCategory | Yes | VIRTUAL_ACCOUNT (temporary) or DEDICATED_VIRTUAL_ACCOUNT (reusable). |
accountType | Yes | INDIVIDUAL or COOPERATE. |
amount | No | Expected amount, in major units. |
reference | No | Yours. Generated if omitted. |
feeBearer | No | CUSTOMER (default) or MERCHANT. |
expiresAt | No | Minutes, 30–4320. Temporary individual accounts only. |
Full schemas are on Create Virtual Account.
Temporary or dedicated
| Category | Use for |
|---|---|
VIRTUAL_ACCOUNT | One payment. Expires. Create a new one per order. |
DEDICATED_VIRTUAL_ACCOUNT | A reusable account tied to one customer, for repeat top-ups. |
A dedicated account has no expected amount, so every credit into it is unsolicited by definition. Match on the customer, not on an amount.
Statuses
collectionStatus is what you branch on.
| Status | Meaning | Fulfil? |
|---|---|---|
AWAITING | No payment yet. | No |
PARTIAL | Customer paid less than payableAmount. | No |
COMPLETED | Paid in full. | Yes |
OVERPAID | Customer paid more. | Yes, then handle the excess |
EXPIRED | Window lapsed before payment. | No |
REVIEW | Needs manual review. | No |
See Statuses And Lifecycles for the full model.
When It Fails
The customer underpays
You get PARTIAL. The money is real and it is yours, but it is not what you asked for.
Do not fulfil. Tell the customer what is outstanding, and give them a way to pay the balance or request a refund. Underpayment usually means you displayed requestedAmount instead of payableAmount — if PARTIAL is common, check that first.
The customer overpays
You get OVERPAID. Fulfil the order, then deal with the excess. The surplus is held aside pending refund rather than added to your spendable balance, so your available balance will not increase by the full amount the customer sent. See Balances.
The customer pays late
You get EXPIRED or REVIEW. The money still arrived. Create a fresh account if the customer still wants the goods, or refund.
Setting an aggressive expiry increases this. expiresAt accepts up to 4320 minutes — three days — and for bank transfers a longer window usually costs less in support than a short one.
The payment needs review
REVIEW means Kyshi cannot safely auto-apply the credit. It happens when payment arrives after expiry, when a second payment lands on an account that already succeeded, or when the account had no expected amount.
Do not fulfil and do not retry. It resolves on Kyshi's side.
You miss a webhook
Assume you will. Endpoints go down and deploys drop requests.
Run a scheduled job that lists virtual accounts still AWAITING beyond a sensible age, verifies each one, and repairs anything the webhook missed. Without it, a customer who paid may never be fulfilled.
Reconcile
async function reconcileStaleAccounts() {
const stale = await db.virtualAccounts.awaitingLongerThan({ minutes: 30 });
for (const account of stale) {
const result = await kyshi.virtualAccounts.verifyPayment({
reference: account.reference,
});
switch (result.collectionStatus) {
case 'COMPLETED':
await fulfilOnce(account.orderId); // idempotent
break;
case 'OVERPAID':
await fulfilOnce(account.orderId);
await flagExcessForRefund(account);
break;
case 'PARTIAL':
await notifyShortfall(account);
break;
case 'EXPIRED':
await closeUnpaid(account);
break;
// AWAITING, REVIEW: leave for the next pass.
}
}
}fulfilOnce must be idempotent. The webhook and this job will both fire for the same payment, and a customer should not receive the goods twice.
Test It
In test mode you can simulate the customer's transfer:
POST {{host}}/v1/wallets/credit{ "accountNumber": "1234567890", "amount": "5070" }Exercise all three outcomes before going live:
| Send | Expect |
|---|---|
Exactly payableAmount | COMPLETED |
| Less | PARTIAL |
| More | OVERPAID |
Next
- Create Virtual Account — full request and response schema
- Verify Wallet Transaction — confirming a credit
- Fees And Who Pays Them — how
payableAmountis derived - Balances — where the money goes next
Updated 7 days ago
