Skip to content
API v1 · REST · JSON

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.

Base https://billbytes.ng/api/v1/
Auth Authorization: Bearer bb_live_…
Body application/json · UTF-8
POST /api/v1/bundles/purchase/
Authorization: Bearer bb_live_…
Idempotency-Key: 8f14e45f-ea2b
Content-Type: application/json

{
  "plan_id": 173,
  "phone": "08031234567"
}
168 live data plans
4 networks
60 requests / minute
NGN settlement

Quickstart

Base URL — every path ends in a slash:

base url
https://billbytes.ng/api/v1/
The trailing slash is not optional

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:

quickstart.sh
# 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:

request header
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.

Keep the key server-side

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.

HeaderRequiredNotes
AuthorizationYesBearer <key>. X-Api-Key: <key> also works if your stack strips Authorization.
Content-TypeOn POSTapplication/json. Form-encoded bodies are accepted too.
Idempotency-KeyOn purchasesUnique 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_idNetworkBundleValidityPrice (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:17 WAT.

Catalogue coverage

NetworkPlansCheapest (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
Airtime is priced off face value

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.

POST /api/v1/bundles/purchase/ · replayed
// 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
  }
}
One key, one purchase

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

StatusHTTPMeaningYour wallet
success200Delivered by the network.Debited
pending202Accepted, not yet confirmed by the operator.Debited
refunded200Rejected or never confirmed; money returned.Refunded in full
failed200Terminal failure.Refunded in full
Never retry a pending order with a new key

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:

PhoneOrder ends asKey journalWebhook
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
08000000002 is how you test a pending order

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

GET /api/v1/ping/ no auth

Liveness and whether purchases are currently enabled.

GET /api/v1/networks/ no auth

Supported networks and how many data plans each currently has.

GET /api/v1/bundles/

The full sellable data catalogue. Optional ?network=MTN|AIRTEL|GLO|9MOBILE.

200 · application/json
{
  "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.

POST /api/v1/bundles/purchase/ Idempotency-Key

Buy a data bundle. Requires Idempotency-Key.

FieldTypeRequiredNotes
plan_idintegerYesFrom GET /bundles/.
phonestringYes08031234567, 2348031234567 and +234… all accepted.
networkstringNoIf sent, must match the plan's network — we reject a mismatch instead of sending to the wrong line.
200 · application/json
{
  "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"
}
POST /api/v1/airtime/purchase/ Idempotency-Key

Buy airtime. Requires Idempotency-Key.

FieldTypeRequiredNotes
networkstringYesMTN, AIRTEL, GLO, 9MOBILE.
phonestringYesNigerian mobile number.
amountintegerYesFace value in naira, NGN 50–2,000. Whole naira only.
GET /api/v1/balance/

Wallet balance and the limits on your key.

200 · application/json
{
  "status": true,
  "balance": 2000,
  "currency": "NGN",
  "limits": {
    "requests_per_minute": 60,
    "daily_spend_cap": 200000,
    "spent_today": 853
  }
}
GET /api/v1/transactions/

One order by reference, or your recent history.

QueryNotes
referenceReturn a single transaction. This is how you resolve a pending order.
statuspending, success, failed, refunded.
servicedata or airtime.
limit, offsetPaging. 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:

delivery headers
X-Billbytes-Event: transaction.success
X-Billbytes-Timestamp: 1753876800
X-Billbytes-Signature: sha256=4f2c…
verify.php
// 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
Reply 2xx quickly

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.

402 · application/json
{
  "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"
}
HTTPCodeWhat to do
400empty_request_bodyAdd the trailing slash to your URL.
400idempotency_key_requiredSend an Idempotency-Key header.
401missing_api_key / invalid_api_keyCheck the Authorization header.
402insufficient_fundsTop up. Do not retry until you do.
403key_revoked / key_suspendedContact support.
403ip_not_allowedYour key is IP-locked; call from an allowed address.
403daily_limit_exceededDaily cap reached. Ask support to raise it.
404plan_not_foundRe-read the catalogue; plan ids change availability.
409idempotency_key_reusedUse a fresh key for a different order.
422invalid_phone, invalid_network, missing_field, amount_too_lowFix the request; retrying unchanged will not help.
429rate_limit_exceededBack off for the seconds in Retry-After.
502provider_errorUpstream problem. Safe to retry with the same idempotency key.
503maintenance_mode / purchases_disabledRetry shortly.
500internal_errorSend 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/