OnLink
Concepts

Balances

The accounts you hold with us, what each number means, how fresh it is, and why it is not the number to build a spending check on.

GET /v1/balances returns the accounts you hold with us and what is available in each. Every credential gets your KES account under kes. A Collections credential also gets your USD account under usd.

Response for a USDT Rails credential — 200 OK:

{
  "kes": {
    "available": "150000.50",
    "accountNumber": "00000000123456",
    "currency": "KES",
    "asOf": "2026-09-08T12:00:00.000Z"
  }
}

Response for a Collections credential — 200 OK:

{
  "kes": {
    "available": "150000.50",
    "accountNumber": "00000000123456",
    "currency": "KES",
    "asOf": "2026-09-08T12:00:00.000Z"
  },
  "usd": {
    "available": "98.50",
    "accountNumber": "00000000000000",
    "currency": "USD",
    "asOf": "2026-09-08T12:00:00.000Z",
    "unavailableReason": null
  }
}

It needs the balances:read scope. See Products and scopes.

What each field is for

available is a decimal string, in shillings and cents on kes and in dollars and cents on usd, like every other amount on this API — never a JSON number. Convert it to integer minor units at your boundary and do arithmetic there. See Money.

accountNumber is the same account your inbound instructions point at, so a credit you see arrive and the balance you read afterwards are the same account. Receive money in Kenya is how money gets into your KES account; a credited collection is paid into your USD account.

asOf is the instant the figure was read. It can be up to thirty seconds behind, because the balance is cached for that long rather than fetched on every call. Show it wherever you show the number: a balance with no timestamp invites a reader to treat it as live.

usd.unavailableReason is null whenever usd.available is a figure. It is set only when your USD account cannot be read (below).

It is a reading, not a fact about now

Do not treat a balance you read as live

Reading a balance tells you what was available when it was read, up to thirty seconds before the call returned. It holds nothing and reserves nothing, and a credit can land between the read and whatever you do with the figure.

Use it for what a reading is good for: displaying a figure beside its asOf, and reconciling at the end of a period against the credit.received and collection.received events we sent you. Transfers and buy orders are paid from your KES balance, and USD payouts and conversions from your USD balance. Each is checked against its balance by us when you create it — refused with TRANSFER_INSUFFICIENT_BALANCE, INSUFFICIENT_KES_BALANCE or CONVERSION_INSUFFICIENT_BALANCE when it does not cover the amount — so a figure you read is not a spending check of your own, and it should not be the number a decision waits on. In sandbox the same holds, against the simulated figures read here (see below).

Simulated money in sandbox

In sandbox, both figures are simulated unless we have enabled real money for your account, and both are read the same way: either both simulated or both real, never one of each. The KES figure is the balance your buy orders, transfers and batch transfers are paid from, and a conversion pays into; it starts at zero, and we add to it on request. The USD figure is the balance a simulated collection credits, and that USD payouts and conversions draw on.

If real money is enabled for your account, both figures are the real ones. Collections stay simulated in sandbox even then, so a collection's credit goes to a simulated USD balance that this read no longer shows.

accountNumber is a real account at the bank, with a real sandbox balance beside the simulated one. A payment to it from outside, and an M-PESA funding request, add to the real balance, and they do not change available. A flow keeps the mode it started in, so an order, transfer, payout or conversion created while your balance was simulated stays simulated to its end. See Sandbox.

When there is no account to report

KES. 503 with PARTNER_VA_NOT_ALLOCATED, PARTNER_VA_ACTIVATING or PARTNER_VA_SUSPENDED means there is no active KES account behind the balance yet, and nothing else is returned. None of the three is self-serve and none is worth retrying in a tight loop — see Errors for what each one means and what to do.

USD. A USD account that cannot be read does not fail the call. Your KES figure is still returned, and usd says the USD figure is absent and why:

{
  "kes": {
    "available": "150000.50",
    "accountNumber": "00000000123456",
    "currency": "KES",
    "asOf": "2026-09-08T12:00:00.000Z"
  },
  "usd": {
    "available": null,
    "accountNumber": null,
    "currency": "USD",
    "asOf": "2026-09-08T12:00:00.000Z",
    "unavailableReason": "PARTNER_VA_NOT_ALLOCATED"
  }
}
unavailableReasonMeaning
PARTNER_VA_NOT_ALLOCATEDNo USD account has been opened for your business yet.
PARTNER_VA_ACTIVATINGYour USD account exists and is still being activated.
PARTNER_VA_SUSPENDEDYour USD account is frozen or dormant.

An absent USD figure is never 0: 0 would mean you hold nothing, and an absent figure means we could not read one. None of the three is self-serve; contact us. If the bank cannot be reached at all, the call fails rather than reporting the account as absent.

Your USDT balance

GET /v1/balances never carries a USDT figure, and a USDT Rails credential's response carries no usd key. A USDT Rails credential reads its USDT balance from its own endpoint, GET /v1/usdt/balance. It needs the usdt:read scope, and a Collections credential is refused, because that product holds no USDT.

When we hold your USDT for you (custody: "onlink_managed"), available is your USDT balance with us:

{
  "custody": "onlink_managed",
  "asset": "USDT",
  "chain": "tron",
  "available": "125.500000",
  "source": "ledger",
  "asOf": "2026-10-30T14:00:00.000Z",
  "unavailableReason": null
}

When your USDT is in your own wallet (custody: "partner_customer"), available is what that wallet holds, read from the wallet itself:

{
  "custody": "partner_customer",
  "asset": "USDT",
  "chain": "tron",
  "available": "42.000001",
  "source": "chain",
  "asOf": "2026-10-30T14:00:00.000Z",
  "unavailableReason": null
}

available is a decimal string with six decimal places, like every other USDT amount on this API — never a JSON number. See Money.

source says where the figure was read. ledger is your USDT balance with us. It is what you can use now: it already leaves out USDT committed to a sell order, or to a send that has started, and a buy order counts once it has been delivered. A send you have created that has not started has taken nothing yet. chain is your own wallet, which can trail a transfer that has only just settled.

asOf is the instant the figure was read. A figure read from your wallet can be up to thirty seconds old, because it is cached for that long. The same rule as above applies: it is a reading, not a spending check. Orders and sends are checked against your balance by us when they run.

When your wallet cannot be read, the call still succeeds and says so:

{
  "custody": "partner_customer",
  "asset": "USDT",
  "chain": "tron",
  "available": null,
  "source": "chain",
  "asOf": "2026-10-30T14:00:00.000Z",
  "unavailableReason": "provider_unavailable"
}
unavailableReasonMeaning
holding_wallet_not_allocatedNo wallet has been set up for your business yet. Contact us.
provider_unavailableYour wallet could not be read just now. Try again shortly; nothing is lost.

An absent figure is never 0: 0 would mean your wallet holds nothing, and an absent figure means we could not read one. Only a partner_customer response can carry a reason, because a balance we hold for you does not depend on reading a wallet.

Where your USDT is held

GET /v1/usdt/holding-wallet answers the other half: the address of the holding wallet, the wallet that holds your USDT, and what USDT sent to it does. It needs the usdt:read scope, takes no parameters, and a Collections credential is refused.

When we hold your USDT for you (custody: "onlink_managed"), the holding wallet is your own deposit address with us, and USDT sent to it is credited to your USDT balance:

{
  "custody": "onlink_managed",
  "asset": "USDT",
  "chain": "tron",
  "address": "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb",
  "receives": "USDT sent to this address is credited to your USDT balance."
}

When your USDT is in your own wallet (custody: "partner_customer"), the holding wallet is that wallet. Your sells and sends draw from it. There is no balance with us to credit:

{
  "custody": "partner_customer",
  "asset": "USDT",
  "chain": "tron",
  "address": "T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb",
  "receives": "This is your own wallet: your sells and sends draw from it."
}

address is returned in full: compare the whole string when you verify it, since a lookalike that differs only in the middle is an irrecoverable loss. receives is one sentence saying what a deposit to this address does for your custody, so you do not have to work it out from custody. It is the same address GET /v1/funding returns for USDT, whose instructions say the same thing in the same terms.

The holding wallet is not one of the withdrawal wallets you register under /v1/wallets. Those are external destinations you send USDT to, each with its own confirmation. The holding wallet is where your USDT already is.

If no address has been allocated for your account yet, the call fails with 503 and errorCode HOLDING_WALLET_NOT_ALLOCATED. It is not self-serve; contact us. The response never carries an empty address.

On this page