Sell data bundles from your own website
The PBM HUB API v1 lets you buy Ghana mobile data bundles and SMS-verification numbers programmatically, from your own site, app, or script, paid from your PBM HUB wallet. Read the guide, then rehearse every call in the Practice Lab below without spending a cedi.
Overview
Base URL
https://api.pbmhub.pro
All endpoints live under /api/v1. Requests and responses are JSON over HTTPS,
and every response uses the same envelope:
{
"success": true, // false on any error
"message": "Human-readable status",
"data": { /* payload */ }
}All amounts are in GHS (Ghana cedis). Purchases are asynchronous: a successful
POST /purchase returns PROCESSING, and you poll the order until it is
DELIVERED or FAILED.
GET /balanceGET /packages/mtnup2uPOST /purchaseGET /orders/:referenceAuthentication
Every API call needs your API key as a Bearer token:
Authorization: Bearer YOUR_API_KEY
Keys are managed from your PBM HUB dashboard → API page:
- You can hold one key at a time, delete the old one to mint a new one.
- The full key (format
qn_XXXX_XXXX…) is shown once at creation. Store it securely. - Keys expire after 1 year by default and can be deactivated by an admin at any time.
Missing or invalid keys return 401 with a hint. An expired or deactivated key
returns 401, "Invalid or inactive API key".
How billing works
API purchases are paid from your personal PBM HUB wallet, the same balance you top up from the dashboard. The flow for every purchase:
- Your wallet is debited upfront, before the order is dispatched to the network.
- If the network definitively refuses the order, a refund is raised and released by our team, it is not credited back automatically. Do not poll your balance expecting it to bounce back within seconds. Retrying the order itself is safe.
- If the network times out, the debit is held, not reversed. A timeout is not a refusal, the order may already be live, so refunding automatically could deliver it and give you your
money back. Those responses come back as
202: do not retry them, poll instead. new_balancein the purchase response reflects your wallet after the debit.
storefront_payment_reference are reserved for PBM HUB's own
storefront checkout, which pre-verifies a Paystack payment on a trusted internal channel.
Sending that field from your own integration is rejected with
403, STOREFRONT_CHANNEL_REQUIRED. The shared storefront float
(balanceType: "apibalance") is likewise never used for direct API calls, your personal wallet always pays. Try it in the Practice Lab.Idempotency & duplicate protection
Purchases are protected against accidental double-charges at three layers:
| Layer | How it works |
|---|---|
| Your idempotency key | Send a unique string per order in the X-Idempotency-Key header (or
idempotency_key in the body, ≤ 512 chars). Retrying with the same key returns the
original result, flagged "Duplicate request - no new charge", instead of charging again. |
| Automatic window | Without a key, identical requests (same network + size + phone) inside a ~20-second window resolve to the same purchase. |
| Duplicate-order guard | Repeating a recently completed purchase for the same recipient returns
429, DUPLICATE_PURCHASE_WINDOW with data.wait_seconds telling you when to retry. |
X-Idempotency-Key,
and reuse it for every retry of that order. Never reuse it for a new order.Rate limits
| Category | Endpoints | Default limit |
|---|---|---|
| Read | balance, networks, packages, orders, verifications (GET) | 120 requests / minute |
| Write | pricing quotes, cancellations | 30 requests / minute |
| Transaction | purchase, create verification | 10 requests / minute |
Exceeding a limit returns 429. Poll order status every ~3 seconds, not in a tight loop.
Data bundle endpoints
/api/v1/balancereadReturns your current wallet balance, the funds available for API purchases.
{
"success": true,
"message": "Balance retrieved successfully",
"data": {
"userId": "uid_abc123",
"balance": 150.50, // spendable now
"available_balance": 150.50,
"held_balance": 0, // reserved by in-flight orders
"total_balance": 150.50,
"balanceType": "personal", // always your own wallet on API calls
"currency": "GHS",
"timestamp": "2026-07-03T12:00:00.000Z"
}
}/api/v1/networksreadLists the networks currently available to your account. Use the returned id values everywhere a network is required.
{
"success": true,
"data": {
"networks": [
{ "id": "mtnup2u", "name": "MTNUP2U", "description": "MTN data bundles" },
{ "id": "airteltigo", "name": "AirtelTigo", "description": "Airtel/Tigo data bundles" },
{ "id": "telecel", "name": "Telecel", "description": "Telecel data bundles" }
],
"provider": "regular"
}
}503, NETWORK_UNAVAILABLE, so treat this list as live rather than caching it for days./api/v1/packages/:networkreadLists packages and your prices (agent/reseller discounts already applied) for one network.
| Path param | Type | Description |
|---|---|---|
network | string | mtnup2u · airteltigo · telecel |
{
"success": true,
"data": {
"network": "mtnup2u",
"packages": [
{ "size": "1GB", "agentPrice": 6.00 },
{ "size": "2GB", "agentPrice": 11.00 },
{ "size": "5GB", "agentPrice": 26.50 }
],
"total": 3,
"currency": "GHS",
"provider": "regular"
}
}Pass size back to /purchase exactly as returned (e.g. "1GB").
Unknown networks return 404 with available_networks.
/api/v1/purchasetransactionBuys a data bundle for a Ghanaian phone number, debiting your wallet upfront.
| Field | Type | Required | Description |
|---|---|---|---|
network | string | Yes | Network id from /networks |
size | string | Yes | Package size exactly as returned by /packages |
beneficiary_phone | string | Yes | Recipient number, 0XXXXXXXXX or +233… |
idempotency_key | string | Recommended | Unique per order; or send the X-Idempotency-Key header |
POST /api/v1/purchase Authorization: Bearer YOUR_API_KEY Content-Type: application/json X-Idempotency-Key: order-8481-attempt { "network": "mtnup2u", "size": "1GB", "beneficiary_phone": "0540000000" }
{
"success": true,
"message": "Purchase initiated successfully",
"data": {
"purchase_id": "API_1F3A9C27B54D8E60A1B2",
"order_id": "ORD_20260703_XYZ789",
"reference": "ORD-K4T7Q2M9PX", // ← use this to poll /orders
"network": "mtnup2u",
"size": "1GB",
"beneficiary_phone": "0540000000",
"price": 6.00,
"status": "PROCESSING",
"new_balance": 144.50,
"provider": "regular",
"provider_backend": "primary"
}
}Purchase-specific errors
| HTTP | error_code | Meaning |
|---|---|---|
| 400 | INSUFFICIENT_BALANCE | Wallet can't cover the price, data.required vs data.available |
| 400 | CACHED_FAILURE | Same idempotent request already failed; response replays the original failure |
| 403 | WALLET_FROZEN | Purchases disabled on your account, contact support |
| 403 | STOREFRONT_CHANNEL_REQUIRED | storefront_payment_reference is storefront-only; remove it to pay from your wallet |
| 429 | DUPLICATE_PURCHASE_WINDOW | Same bundle to the same phone too soon, wait data.wait_seconds |
| 503 | NETWORK_UNAVAILABLE | Network temporarily disabled by admin |
| 503/504 | PROVIDER_PURCHASE_FAILED | Upstream provider failed, your debit was auto-reversed; retry later |
/api/v1/orders/:referencereadLooks up one of your orders by the reference returned from /purchase.
Add ?sync=1 to force a live status refresh from the provider (use sparingly).
{
"success": true,
"data": {
"reference": "ORD-K4T7Q2M9PX",
"order_id": "ORD_20260703_XYZ789",
"network": "MTN_PRO",
"size": "1GB",
"beneficiary": "0540000000",
"price": 6.00,
"status": "DELIVERED",
"provider": "regular",
"api_purchase": true,
"created_at": "2026-07-03T12:00:05.000Z",
"updated_at": "2026-07-03T12:01:40.000Z"
}
}Status lifecycle: PROCESSING → DELIVERED or FAILED.
Unknown references return 404 (references only resolve for your own account).
/api/v1/profit-balance/syncreadReturns your aggregated sales statistics, useful for dashboards on top of your integration.
{
"totalProfit": 312.40,
"totalOrders": 1284,
"totalRevenue": 9640.00,
"todayOrders": 37,
"todayRevenue": 288.50,
"currency": "GHS",
"lastOrderAt": 1751541600000
}Result checker endpoints
Sell WASSCE (WAEC) and BECE result checker cards. A purchase returns the card serial, PIN
and code straight back in the response, and also SMSes them if you pass a recipient.
Paid from your personal wallet, exactly like a data bundle.
GET /result-checkers/:purchase_id can re-issue them, but treat that as a safety net,
not your only copy./api/v1/result-checkers/catalogreadTypes on sale and their prices. When available is false the service is off or
nothing is priced, don't attempt a purchase.
{
"available": true,
"max_quantity": 10,
"checkers": [
{ "type": "WASSCE", "display_name": "WASSCE Checker", "price": 20.00, "currency": "GHS" },
{ "type": "BECE", "display_name": "BECE Checker", "price": 18.00, "currency": "GHS" }
],
"accepted_types": ["WASSCE", "WAEC", "BECE"]
}/api/v1/result-checkers/purchasetransactionBuys 1-10 cards. checker_type is required (WASSCE, WAEC or
BECE); recipient and quantity are optional, omit
recipient to get the cards only in the response and skip the SMS.
Send X-Idempotency-Key: a retry with the same key never charges twice, it returns the
original cards.
POST /api/v1/result-checkers/purchase Authorization: Bearer YOUR_API_KEY X-Idempotency-Key: order-8842 { "checker_type": "WASSCE", "recipient": "0540000000", "quantity": 1 }
{
"purchase_id": "CHK_9F2A…", // keep this, it re-reads the cards
"checker_type": "WASSCE",
"total_price": 20.00,
"checkers": [
{ "serial": "1234567890", "pin": "987654321", "code": "ABC123" }
],
"status": "COMPLETED",
"retrievable": true, // false = this response is your ONLY copy
"new_balance": 130.00
}| HTTP | error_code | Your money |
|---|---|---|
| 400 | CHECKER_PURCHASE_FAILED | Refunded automatically. Safe to retry |
| 400 | INSUFFICIENT_BALANCE | Never debited |
| 202 | CHECKER_PROVIDER_UNCERTAIN | Held, not refunded, do not retry |
| 202 | CHECKER_NO_CARDS_RETURNED | Held, not refunded, do not retry |
| 503 | CHECKER_SERVICE_DISABLED | Never debited |
GET /result-checkers/:purchase_id: it settles to
COMPLETED (with your cards) or is refunded. Retrying just places a second order./api/v1/result-checkers/:purchase_idreadRe-reads a purchase you already paid for, including the cards. Only your own purchases are visible.
| status | Meaning |
|---|---|
COMPLETED | Cards issued, they're in checkers[] |
PENDING | Paid, provider hasn't answered yet |
UNCERTAIN | Provider never confirmed. Under review, do not re-order |
FAILED | Provider refused. You were refunded |
SMS verification endpoints
Rent a temporary phone number to receive a verification SMS from services like WhatsApp or Google. The flow: quote → reserve → poll → (cancel). You're only charged when a number is reserved; expired or cancelled reservations are refunded.
/api/v1/verifications/servicesreadBrowse the service catalog. Query params: capability (sms, default) and
available=true to show only services with live stock.
{
"capability": "sms",
"records": [
{ "service_name": "whatsapp", "name": "WhatsApp", "price": 4.50, "available": true },
{ "service_name": "google", "name": "Google", "price": 3.80, "available": true }
]
}/api/v1/verifications/pricingwriteLocks a live price and returns a quoteToken you must present when reserving. Body:
{ "serviceName": "whatsapp", "capability": "sms" }.
{
"serviceName": "whatsapp",
"serviceDisplayName": "WhatsApp",
"price": 4.50,
"quoteToken": "QT_CONF_abc123xyz",
"quoteExpiresAt": "2026-07-03T12:10:00.000Z" // ~5 minutes
}/api/v1/verificationstransactionReserves a number using your quote. Body:
{ "serviceName": "whatsapp", "capability": "sms", "quoteToken": "QT_CONF_…" }.
A wallet hold for the quoted amount is placed when the number is assigned.
| HTTP | error_code | Meaning |
|---|---|---|
| 400 | QUOTE_TOKEN_REQUIRED | Missing quoteToken |
| 409 | VERIFICATION_PRICE_CONFIRMATION_REQUIRED | Quote expired, fetch pricing again |
| 409 | VERIFICATION_PRICE_UPDATED | Live price moved, review the new price and re-quote |
| 400 | INSUFFICIENT_BALANCE | Wallet can't cover the quoted amount |
| 409 | NO_NUMBERS_AVAILABLE | No stock right now, try again shortly |
/api/v1/verifications/:purchaseIdreadPolls the reservation. When the SMS arrives, data.code holds the extracted code and
data.messages[] the full texts. Poll every ~3 seconds until the state is terminal.
| uiState | Meaning |
|---|---|
number_assigned | Number ready, waiting for the SMS |
received | Code arrived, you're charged |
expired | Window closed with no SMS, hold released |
cancelled | You cancelled, hold released |
failed | Provider error, hold released |
/api/v1/verifications/:purchaseId/cancelwriteCancels an active reservation before the SMS arrives; the wallet hold is released immediately.
Error codes
| HTTP | When you'll see it |
|---|---|
| 400 | Missing/invalid fields, bad phone format, unknown package size, insufficient balance |
| 401 | Missing, malformed, expired, or deactivated API key |
| 403 | Frozen wallet · storefront-only field used (STOREFRONT_CHANNEL_REQUIRED) · blacklisted recipient |
| 404 | Unknown network, order reference, or verification id |
| 409 | Verification quote expired / price moved |
| 429 | Rate limit hit, duplicate purchase window, or risk-control velocity check |
| 500 / 502 | Unexpected server or provider error, safe to retry with the same idempotency key |
| 503 / 504 | Network disabled, provider offline, or provider timeout (debit auto-reversed) |
Machine-readable error_code strings ride along in the body wherever a specific
action is expected of you, match on those, not on message text.
Quickstart code
The full buy-and-track loop in three languages. Replace YOUR_API_KEY.
# 1) check balance curl -s https://api.pbmhub.pro/api/v1/balance \ -H "Authorization: Bearer YOUR_API_KEY" # 2) purchase 1GB MTN curl -s -X POST https://api.pbmhub.pro/api/v1/purchase \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "X-Idempotency-Key: order-1001" \ -d '{"network":"mtnup2u","size":"1GB","beneficiary_phone":"0540000000"}' # 3) poll the order curl -s https://api.pbmhub.pro/api/v1/orders/ORD-K4T7Q2M9PX \ -H "Authorization: Bearer YOUR_API_KEY"
const BASE = "https://api.pbmhub.pro"; const KEY = process.env.PBM_API_KEY; async function pbm(path, options = {}) { const res = await fetch(BASE + path, { ...options, headers: { "Authorization": `Bearer ${KEY}`, "Content-Type": "application/json", ...(options.headers || {}) } }); return res.json(); } const order = await pbm("/api/v1/purchase", { method: "POST", headers: { "X-Idempotency-Key": "order-1001" }, body: JSON.stringify({ network: "mtnup2u", size: "1GB", beneficiary_phone: "0540000000" }) }); // poll until terminal let status = order.data.status; while (status === "PROCESSING") { await new Promise(r => setTimeout(r, 3000)); const check = await pbm(`/api/v1/orders/${order.data.reference}`); status = check.data.status; } console.log("final:", status);
import os, time, requests BASE = "https://api.pbmhub.pro" H = {"Authorization": f"Bearer {os.environ['PBM_API_KEY']}"} order = requests.post( f"{BASE}/api/v1/purchase", headers={**H, "X-Idempotency-Key": "order-1001"}, json={"network": "mtnup2u", "size": "1GB", "beneficiary_phone": "0540000000"}, timeout=30, ).json() ref = order["data"]["reference"] while True: check = requests.get(f"{BASE}/api/v1/orders/{ref}", headers=H, timeout=15).json() if check["data"]["status"] != "PROCESSING": break time.sleep(3) print("final:", check["data"]["status"])
Best practices
- One idempotency key per order, reused on every retry. This is your strongest protection against double-charging.
- Poll gently. 3-second intervals; use
?sync=1only when a customer is actively waiting. - Check balance before batches. A pre-flight
/balancecall gives customers a clean “top up first” message. - Match on
error_code, not message text. Messages can be reworded; codes are stable. - Handle
FAILEDexplicitly. Failed dispatches auto-refund your wallet, reflect that in your own records. - Never send storefront fields.
storefront_payment_referencebelongs to PBM HUB's internal checkout only.
Practice Lab
A faithful simulator of the live API, same validation order, same error codes, same idempotency behavior, running entirely on this page. Nothing you do here touches the real API or spends real money. You start with a sandbox wallet of GHS 50.00.
| Reference | Bundle | Phone | Price | Status |
|---|---|---|---|---|
| No orders yet, make a purchase above. | ||||