> ## Documentation Index
> Fetch the complete documentation index at: https://www.bitnob.dev/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Bitnob has two APIs. The Business API (payouts, cards, trading, virtual accounts, stablecoin and bitcoin transfers) is at https://api.bitnob.com. The Enterprise API (non-custodial wallets) is at https://api.platform.bitnob.com.
> Business API auth: sign every request with HMAC-SHA256 and send four headers: `X-Auth-Client` (your client ID), `X-Auth-Timestamp` (Unix seconds), `X-Auth-Nonce` (16 random bytes, hex), and `X-Auth-Signature`, the hex HMAC-SHA256 of `CLIENT_ID:TIMESTAMP:NONCE:BODY` keyed with your client secret. BODY is the exact request body string you send, or an empty string. A missing header, a bad signature, or a timestamp more than about 5 minutes off returns `401`.
> Enterprise API auth: send your key in the `X-API-Key` header. Write requests such as creating a transaction also need an `Idempotency-Key` header, or they fail with `idempotency_key_required` before anything happens.
> Sandbox and production share the Business API base URL. The credentials you sign with select the environment, and `GET /api/whoami` returns it in `data.environment`. Use sandbox credentials in sample code and tests, and never put live credentials in code.
> Amounts are strings unless stated otherwise. Never parse them into floats. Balances, transactions, withdrawals (`POST /api/withdrawals`) and virtual accounts use integer strings in the smallest unit: satoshis for BTC, 6 decimals for USDT and USDC, kobo for NGN, and cents for KES and UGX (100 is 1 KES or 1 UGX). A mobile money collection takes `amount` as a JSON integer in cents.
> Payouts and trading use decimal strings in major units: a payout quote `amount` of `"1"` is 1 USDT, and a trading `quantity` of `"0.0001"` is 0.0001 BTC. Payout responses also carry `cent_amount`, the settlement amount in minor units.
> Card amounts are micro-units (1 USD = 1,000,000). Send them as JSON integers. Card responses return them as integer strings, and card webhooks as integers.
> Generate a unique `reference` for each money movement before the first attempt, and reuse it on retries. A reused reference returns `409` on payout quotes (`ALREADY_EXISTS`), withdrawals (`DUPLICATE_KEY_ERROR`) and card creation (`ALREADY_EXISTS`). It returns the existing record on NGN-pair trading orders, NGN virtual accounts and active mobile money collections. On a mobile money collection that has closed or failed it returns `409` `DUPLICATE_REFERENCE`. It isn't checked on crypto-pair trading orders, so look the order up with `GET /api/trading/orders/{reference}` before resending.
> Quotes expire at `expires_at`; read it from the response instead of assuming a window. An expired trading quote returns `422` `PRECONDITION_FAILED`, and an expired payout quote returns `400` `VALIDATION_ERROR`. Create a new quote rather than retrying. A payout quote can be initialized once, and a trading quote is consumed by the order that fills it.
> Webhooks: verify the `x-bitnob-signature` header, the hex HMAC-SHA512 of the raw request body keyed with your webhook secret, before parsing JSON. Compare in constant time and de-duplicate on `event_id`. A successful API response means accepted: confirm the outcome from the webhook, such as `payouts.withdrawal.success`.
> Errors return `status`, `code` and `detail`. Branch on `code`, never on `detail`. Since the November 5, 2026 response format change, each company gets either the current or the new error codes until its cutoff date, so handle both: `PROVIDER_ERROR` or `PROCESSING_ERROR`, and `BVN_PROVIDER_UNAVAILABLE` or `BVN_VERIFICATION_UNAVAILABLE`. A body that fails to parse returns `{"error": {"code": "BAD_REQUEST"}}`.

# Collect KES and UGX by mobile money

> Prompt a payer's phone in Kenya or Uganda for an exact amount, and have it credited to your KES or UGX balance once they approve.

A mobile money collection asks one payer for one payment. You send the amount and the payer's phone number. The payer gets a prompt on their phone, in Kenya on M-Pesa or in Uganda on MTN or Airtel, and once they approve it, Bitnob credits **your company's** KES or UGX [balance](https://www.bitnob.dev/api-reference/balances/list-balances.md). The collection then closes.

Collections don't have their own endpoints. A collection is a virtual account created with `rail: "mobile_money"`, so you create, fetch, list and test it with the [virtual account endpoints](https://www.bitnob.dev/api-reference/virtual-accounts/create-virtual-account.md).

## How it works

**1. Create the collection**

Send the amount, network and payer's phone number to [Create virtual account](https://www.bitnob.dev/api-reference/virtual-accounts/create-virtual-account.md) with `rail: "mobile_money"`. No customer or KYC is needed.

**2. The payer approves the prompt**

The payer's phone shows a prompt for the exact amount. Nothing more is needed from you while the collection waits.

**3. Your balance is credited**

When the payer approves, Bitnob credits your KES or UGX balance, closes the collection and sends [`virtual_account.deposit.success`](https://www.bitnob.dev/docs/virtual-accounts/deposit-webhook).

**4. Or the collection ends unpaid**

If the payer doesn't complete the payment, or doesn't respond within about an hour, the collection closes unpaid and Bitnob sends [`virtual_account.collection.failed`](https://www.bitnob.dev/docs/virtual-accounts/deposit-webhook).

## Networks and limits

| Country | Currency | `network` | Payer phone number | Amount per collection |
| - | - | - | - | - |
| Kenya | KES | `MPESA` | `2547…` or `2541…`, 12 digits | KES 20 to KES 145,000 |
| Uganda | UGX | `MTN` or `AIRTEL` | `2567…`, 12 digits | UGX 500 to UGX 4,000,000 |

[List supported currencies](https://www.bitnob.dev/api-reference/virtual-accounts/supported-currencies.md) returns the same details for each currency, including `networks`, `phone_pattern`, `min_amount` and `max_amount`, so you can validate input before you call the API.

`amount` is an integer in base units, where 100 is 1 KES or 1 UGX. To collect KES 1,500, send `150000`. UGX uses the same rule even though it has no minor unit in everyday use: UGX 20,000 is `2000000`.

## Create a collection

```http theme={null}
POST /api/virtual-accounts
```

```json theme={null}
{
  "reference": "col_ref_0001",
  "currency": "KES",
  "rail": "mobile_money",
  "amount": 150000,
  "network": "MPESA",
  "account_number": "254700000000",
  "account_name": "John Doe",
  "description": "Invoice 1042"
}
```

| Field | Required | Notes |
| - | - | - |
| `reference` | Yes | Your unique reference for this collection. See [Retries and references](#retries-and-references). |
| `currency` | Yes | `KES` or `UGX`. |
| `rail` | Yes | `mobile_money`. Leave it out and the request is treated as an NGN bank account. |
| `amount` | Yes | The exact amount to collect, in base units. |
| `network` | Yes | `MPESA` for KES. `MTN` or `AIRTEL` for UGX. Not case-sensitive. |
| `account_number` | Yes | The payer's phone number, with or without the country code. It's stored as digits with the country code. |
| `account_name` | No | The payer's name. |
| `description` | No | A note for your own records, up to 255 characters. Returned as `metadata.description`. |
| `customer_id` | No | Links the collection to one of your [customers](https://www.bitnob.dev/api-reference/customers/create-customer.md), if you keep a record of the payer. |
| `country` | No | `KE` for KES or `UG` for UGX. Leave it out and it's taken from the currency. |
| `lifetime` | No | Always `temporary` for a collection. Leave it out. |

The response returns the collection with `status: "active"` while it waits for the payer:

```json theme={null}
{
  "success": true,
  "message": "Virtual account created successfully",
  "data": {
    "virtual_account": {
      "id": "00000000-0000-0000-0000-000000000001",
      "company_id": "00000000-0000-0000-0000-000000000002",
      "account_number": "254700000000",
      "account_name": "John Doe",
      "bank_name": "MPESA",
      "reference": "col_ref_0001",
      "currency": "KES",
      "status": "active",
      "account_alias": "John Doe",
      "lifetime": "temporary",
      "rail": "mobile_money",
      "country": "KE",
      "network": "MPESA",
      "expected_amount": "150000",
      "expires_at": "2026-10-30T10:00:00Z",
      "metadata": {
        "description": "Invoice 1042",
        "fees": "0"
      },
      "created_at": "2026-10-30T09:00:00Z",
      "updated_at": "2026-10-30T09:00:00Z"
    }
  },
  "timestamp": "2026-10-30T09:00:00.481Z"
}
```

Store `id` against your order. It's the `virtual_account_id` on the deposit webhook, and the ID you pass to [Get virtual account](https://www.bitnob.dev/api-reference/virtual-accounts/get-virtual-account.md) to check the collection.

On a collection, some fields mean something different from an NGN bank account:

| Field | On a collection |
| - | - |
| `account_number` | The payer's phone number, as digits with the country code. |
| `bank_name` | The mobile money network. |
| `expected_amount` | The amount the payer is prompted for, in base units, as a string. |
| `expires_at` | When an unpaid collection stops waiting for the payer. |
| `failure_reason` | Why the collection ended unpaid. Only set when it did. |

## Rules for a collection

* **One payment.** A collection takes one payment and then closes. Create a new collection for the next payment.
* **The exact amount.** The payer must pay exactly the amount requested.
* **About an hour to pay.** A collection that isn't paid by `expires_at` closes unpaid, with `failure_reason` set and a `virtual_account.collection.failed` webhook.
* **No cancel.** There is no endpoint to cancel a collection. An unpaid one ends on its own when it expires.

## Collection statuses

| `status` | Meaning |
| - | - |
| `active` | Waiting for the payer to approve the prompt. |
| `closed` | Finished. If `failure_reason` is empty the payer paid and your balance was credited. If it's set, the collection ended unpaid. |
| `failed` | The prompt couldn't be sent to the payer. Nothing was collected. |

## Retries and references

`reference` is your idempotency key. Sending the same reference again while the collection is still `active` returns the original collection with `201`, and the payer is not prompted again. Retry a timed-out create with the same reference, never a new one, or the payer can get two prompts.

Once a collection has closed or failed, its reference can't be used again. Sending it returns `409` `DUPLICATE_REFERENCE`. Use a new reference to ask the same payer again.

## Test in sandbox

In sandbox no prompt reaches a phone. Finish the collection yourself with [Simulate deposit](https://www.bitnob.dev/api-reference/virtual-accounts/simulate-deposit-sandbox.md), choosing how the payer answers with `outcome`:

```http theme={null}
POST /api/virtual-accounts/{id}/simulate-deposit
```

```json theme={null}
{
  "outcome": "success"
}
```

| `outcome` | What happens |
| - | - |
| `success`, or no body | Credits the full `expected_amount` to your balance, closes the collection and sends `virtual_account.deposit.success`. |
| `failed` | Credits nothing, closes the collection with `failure_reason` set and sends `virtual_account.collection.failed`. The response has `transaction: null` and an empty `new_balance`. |

A collection can only be simulated once, while it's `active`. Simulating one that has already finished returns `400`. Collections have no hourly simulation cap.

## Getting access

Collections are switched on per company, and per currency. Until they're enabled for you, a create returns `403` `FEATURE_NOT_ENABLED`. Contact Bitnob to have KES or UGX collections enabled.

## When something fails

> [accordion] 403 FEATURE\_NOT\_ENABLED
>
> Collections, or collections in that currency, aren't enabled on your account. The `detail` names which, for example `The UGX mobile money collections feature is currently not available.`

> [accordion] 400 with amount, network or account\_number in details
>
> A required collection field is missing or empty. `amount`, `network` and `account_number` are all required when `rail` is `mobile_money`.

> [accordion] amount must be between …
>
> `amount` is outside the limits for the currency. The message gives the range in major and base units, for example `amount must be between 20 and 145000 KES (base units 2000 to 14500000)`.

> [accordion] network must be one of …
>
> The network isn't one the currency takes. Use `MPESA` for KES, and `MTN` or `AIRTEL` for UGX.

> [accordion] account\_number must be a Kenya (or Uganda) mobile number
>
> The phone number doesn't match the country's format. Check it against `phone_pattern` from [List supported currencies](https://www.bitnob.dev/api-reference/virtual-accounts/supported-currencies.md).

> [accordion] Currency must be KES or UGX for mobile\_money
>
> NGN is only available as a bank account. Leave `rail` out to create an [NGN virtual account](https://www.bitnob.dev/docs/virtual-accounts/set-up-virtual-accounts.md).

> [accordion] 409 DUPLICATE\_REFERENCE
>
> The reference belongs to a collection that has already closed or failed, or to an NGN bank account. Use a new reference.

> [accordion] 422 PROCESSING\_ERROR
>
> The prompt couldn't be sent and the collection is left `failed`. Retry with a new reference.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.