Sell data and airtime from your own app.
One REST API for Nigerian data bundles and airtime across MTN, Airtel, Glo and 9mobile. Prepaid wallet, idempotent purchases that survive a retry, and signed webhooks when an order settles.
Authorization: Bearer bb_live_…
Idempotency-Key: 8f14e45f-ea2b
Content-Type: application/json
{
"plan_id": 173,
"phone": "08031234567"
}
Quickstart
Base URL — every path ends in a slash:
https://billbytes.ng/api/v1/
Our server redirects a slash-less URL, and an HTTP redirect discards the body of
a POST. Send /api/v1/airtime/purchase/, not
/api/v1/airtime/purchase. If you forget, you will get a clear
empty_request_body error rather than a silent failure.
Check you can reach us — no key needed:
# 1. are we up?curl https://billbytes.ng/api/v1/ping/ # 2. what can I sell?curl https://billbytes.ng/api/v1/bundles/?network=MTN \-H "Authorization: Bearer bb_live_your_key" # 3. sell somethingcurl -X POST https://billbytes.ng/api/v1/bundles/purchase/ \-H "Authorization: Bearer bb_live_your_key" \-H "Content-Type: application/json" \-H "Idempotency-Key: $(uuidgen)" \-d '{"plan_id": 173, "phone": "08031234567"}'
Need a key? Email support@billbytes.ng with your business name and what you are building. Test keys are issued the same day; live keys after a short review.
Authentication
Send your key as a bearer token on every request:
Authorization: Bearer bb_live_9f3c1a2b4d5e6f708192a3b4c5d6e7f8a9b0c1d2
Keys look like bb_live_… in production and bb_test_… in the
sandbox. We store only a hash of your key, so we cannot recover it for you — if you lose it,
we rotate it and the old one is revoked.
Anyone holding it can spend your wallet. Never ship it in a mobile app, a browser bundle or a public repository. You can also lock a key to specific IP addresses — ask support and we will set an allowlist on it.
| Header | Required | Notes |
|---|---|---|
| Authorization | Yes | Bearer <key>. X-Api-Key: <key> also works if your stack strips Authorization. |
| Content-Type | On POST | application/json. Form-encoded bodies are accepted too. |
| Idempotency-Key | On purchases | Unique per purchase attempt. See Idempotency. |
Wallet & pricing
The API is prepaid. Every purchase debits your wallet; a rejected order is refunded automatically. Top up by bank transfer to your Billbytes account and support credits the wallet against your transfer reference.
The price returned by GET /api/v1/bundles/ is exactly what your
wallet is debited — quote it to your own customers and keep the difference. Prices move when
the networks move theirs, so read the catalogue rather than hard-coding it.
Sample live prices
| plan_id | Network | Bundle | Validity | Price (NGN) |
|---|---|---|---|---|
| 102 | 9MOBILE | 1GB | 30 days | 490.00 |
| 101 | 9MOBILE | 2GB | 7 days | 980.00 |
| 98 | AIRTEL | 2GB | 2 days | 551.00 |
| 141 | GLO | 1GB | 3 days | 294.00 |
| 152 | GLO | 2GB | 7 days | 441.00 |
| 70 | MTN | 1GB | 7 days | 297.00 |
| 71 | MTN | 2GB | 7 days | 396.00 |
Read live from the catalogue at 3 Nov 2026, 16:23 WAT.
Catalogue coverage
| Network | Plans | Cheapest (NGN) | Largest (NGN) |
|---|---|---|---|
| 9MOBILE | 15 | 245.00 | 19,584.00 |
| AIRTEL | 10 | 62.00 | 4,998.00 |
| GLO | 63 | 49.00 | 19,584.00 |
| MTN | 80 | 25.00 | 123,675.00 |
Ask for NGN 500 of airtime and the recipient gets NGN 500. Your wallet is debited face
value less your airtime discount, so the amount in the response is what you
actually paid — check it rather than assuming it equals the face value you requested.
Contact support about your discount rate if you are doing volume.
Idempotency
Every purchase requires an Idempotency-Key header. Any unique string works —
a UUID is ideal. It is the difference between a safe retry and a double charge.
If your request times out you genuinely do not know whether the bundle was delivered.
Retry with the same key and you get the original result back, marked
"replayed": true. No second purchase, no second debit.
// first attempt — connection drops, you never see a response
POST /api/v1/bundles/purchase/ Idempotency-Key: 8f14e45f-ea2b
// safe retry, same key
POST /api/v1/bundles/purchase/ Idempotency-Key: 8f14e45f-ea2b
{
"status": true,
"transaction": {
"reference": "bb_tx_6a7b5ffa48948afd1431",
"status": "success",
"replayed": true
}
}
Reusing a key with different parameters returns 409 idempotency_key_reused
rather than quietly doing the wrong thing. Generate a fresh key for each distinct order.
Order lifecycle
| Status | HTTP | Meaning | Your wallet |
|---|---|---|---|
| success | 200 | Delivered by the network. | Debited |
| pending | 202 | Accepted, not yet confirmed by the operator. | Debited |
| refunded | 200 | Rejected or never confirmed; money returned. | Refunded in full |
| failed | 200 | Terminal failure. | Refunded in full |
A pending order is still with the network operator and may yet be delivered.
Retrying it under a fresh idempotency key buys the bundle a second time. Poll
GET /api/v1/transactions/?reference=… or wait for the webhook. Anything still
unconfirmed after our reconciliation window is refunded automatically.
Sandbox
A bb_test_ key never reaches the network and never touches your
wallet. Everything else is real: the same endpoints, the same validation, the same ledger
entries and the same webhooks, so a sandbox order records a debit and a refund exactly as a
live one does — against the key's own journal rather than your balance. You can leave a
sandbox loop running on an empty wallet.
The outcome is chosen by the recipient number, so you can exercise every branch without spending anything:
| Phone | Order ends as | Key journal | Webhook |
|---|---|---|---|
| 08000000001 | refunded | Debited, then refunded in full | transaction.failed |
| 08000000002 | pending | Debited, stays debited | None — the order is left open |
| any other valid number | success | Debited | transaction.success |
It simulates a provider timeout, so no webhook fires at purchase time and the order sits
at pending — exactly what an unconfirmed live order does. It is then closed
out by the same reconciliation as production, which refunds an order the provider never
confirmed and sends transaction.refunded. Poll
GET /api/v1/transactions/?reference=… against it, and never retry it
under a fresh idempotency key.
Endpoints
Liveness and whether purchases are currently enabled.
Supported networks and how many data plans each currently has.
The full sellable data catalogue. Optional ?network=MTN|AIRTEL|GLO|9MOBILE.
{
"status": true,
"count": 89,
"bundles": [
{
"plan_id": 173,
"network": "MTN",
"name": "1GB Share",
"label": "1GB Share · 30 days", // ready to drop in a <select>
"volume": "1GB",
"validity": "30 days",
"type": "Share",
"price": 578,
"currency": "NGN"
}
]
}
plan_id is ours and stable. Pass it straight to the purchase endpoint.
Buy a data bundle. Requires Idempotency-Key.
| Field | Type | Required | Notes |
|---|---|---|---|
| plan_id | integer | Yes | From GET /bundles/. |
| phone | string | Yes | 08031234567, 2348031234567 and +234… all accepted. |
| network | string | No | If sent, must match the plan's network — we reject a mismatch instead of sending to the wrong line. |
{
"status": true,
"transaction": {
"reference": "bb_tx_6a7b5ffa48948afd1431",
"status": "success",
"service": "data",
"network": "MTN",
"phone": "08031234567",
"amount": 578,
"plan": "1GB Share",
"completed_at": "2026-07-30T10:18:18+01:00"
},
"request_id": "req_341b85b47355ec3cdba007f3"
}
Buy airtime. Requires Idempotency-Key.
| Field | Type | Required | Notes |
|---|---|---|---|
| network | string | Yes | MTN, AIRTEL, GLO, 9MOBILE. |
| phone | string | Yes | Nigerian mobile number. |
| amount | integer | Yes | Face value in naira, NGN 50–2,000. Whole naira only. |
Wallet balance and the limits on your key.
{
"status": true,
"balance": 2000,
"currency": "NGN",
"limits": {
"requests_per_minute": 60,
"daily_spend_cap": 200000,
"spent_today": 853
}
}
One order by reference, or your recent history.
| Query | Notes |
|---|---|
| reference | Return a single transaction. This is how you resolve a pending order. |
| status | pending, success, failed, refunded. |
| service | data or airtime. |
| limit, offset | Paging. limit maxes out at 100, default 25. |
The reference is a query parameter, not a path segment — /transactions/?reference=bb_tx_…
Webhooks
Give support an HTTPS endpoint and we will POST to it when an order settles. Events:
transaction.success, transaction.failed,
transaction.refunded. The body is the same transaction object the API returns.
Verifying a delivery
Each delivery carries a timestamp and an HMAC-SHA256 signature over
"<timestamp>.<raw body>", keyed with your webhook secret:
X-Billbytes-Event: transaction.success
X-Billbytes-Timestamp: 1753876800
X-Billbytes-Signature: sha256=4f2c…
// PHP — verify before trusting anything in the body
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_BILLBYTES_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_BILLBYTES_SIGNATURE'] ?? '';
// reject replays of an old, validly-signed delivery
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400); exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $yourWebhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(401); exit;
}
http_response_code(200); // ack quickly, then process
Anything else is retried with backoff — roughly 1, 4, 9, 16 and 25 minutes, then we stop.
Do your slow work after acknowledging. Compare signatures with a constant-time function
such as hash_equals, never ==.
Errors
Failures are always shaped the same way, with a stable machine-readable code.
Branch on the code, not the message — messages get reworded.
{
"status": false,
"error": {
"code": "insufficient_funds",
"message": "Insufficient API wallet balance. This purchase costs NGN 578.00, your balance is NGN 120.00.",
"details": { "required": 578, "balance": 120 }
},
"request_id": "req_341b85b47355ec3cdba007f3"
}
| HTTP | Code | What to do |
|---|---|---|
| 400 | empty_request_body | Add the trailing slash to your URL. |
| 400 | idempotency_key_required | Send an Idempotency-Key header. |
| 401 | missing_api_key / invalid_api_key | Check the Authorization header. |
| 402 | insufficient_funds | Top up. Do not retry until you do. |
| 403 | key_revoked / key_suspended | Contact support. |
| 403 | ip_not_allowed | Your key is IP-locked; call from an allowed address. |
| 403 | daily_limit_exceeded | Daily cap reached. Ask support to raise it. |
| 404 | plan_not_found | Re-read the catalogue; plan ids change availability. |
| 409 | idempotency_key_reused | Use a fresh key for a different order. |
| 422 | invalid_phone, invalid_network, missing_field, amount_too_low | Fix the request; retrying unchanged will not help. |
| 429 | rate_limit_exceeded | Back off for the seconds in Retry-After. |
| 502 | provider_error | Upstream problem. Safe to retry with the same idempotency key. |
| 503 | maintenance_mode / purchases_disabled | Retry shortly. |
| 500 | internal_error | Send us the request_id. |
Rate limits
Default 60 requests per minute per key; every response carries
X-RateLimit-Limit and X-RateLimit-Remaining. Need more throughput?
Ask — the limit is per key and we can raise it.
Machine-readable index: /api/v1/