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 insteadIf
TransactionsYou want a hosted checkout page with multiple payment methods.
Payment LinksYou 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.

FieldMeaning
requestedAmountWhat you asked for.
payableAmountWhat 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 this

Display 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/verify

Check collectionStatus before releasing anything.

Request Fields

FieldRequiredNotes
customerYesAt least email. firstName, lastName, phoneNumber recommended.
currencyYesNGN, KES, ZAR, GHS, XOF, USD, subject to your enabled integrations.
accountCategoryYesVIRTUAL_ACCOUNT (temporary) or DEDICATED_VIRTUAL_ACCOUNT (reusable).
accountTypeYesINDIVIDUAL or COOPERATE.
amountNoExpected amount, in major units.
referenceNoYours. Generated if omitted.
feeBearerNoCUSTOMER (default) or MERCHANT.
expiresAtNoMinutes, 30–4320. Temporary individual accounts only.

Full schemas are on Create Virtual Account.

Temporary or dedicated

CategoryUse for
VIRTUAL_ACCOUNTOne payment. Expires. Create a new one per order.
DEDICATED_VIRTUAL_ACCOUNTA 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.

StatusMeaningFulfil?
AWAITINGNo payment yet.No
PARTIALCustomer paid less than payableAmount.No
COMPLETEDPaid in full.Yes
OVERPAIDCustomer paid more.Yes, then handle the excess
EXPIREDWindow lapsed before payment.No
REVIEWNeeds 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:

SendExpect
Exactly payableAmountCOMPLETED
LessPARTIAL
MoreOVERPAID

See Test Mode And Sandbox.

Next


Did this page help you?