PBM HUB · Developer Platform

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

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:

response 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.

1GET /balance
2GET /packages/mtnup2u
3POST /purchase
4GET /orders/:reference

Authentication

Every API call needs your API key as a Bearer token:

header
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.
Keep it server-side Never ship your key in browser JavaScript, mobile apps, or public repos. Anyone holding the key can spend your wallet. Call the API from your backend only.

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_balance in the purchase response reflects your wallet after the debit.
Storefront-only fields Requests that include 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:

LayerHow 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.
Rule of thumb Generate one UUID per order in your system, send it as X-Idempotency-Key, and reuse it for every retry of that order. Never reuse it for a new order.

Rate limits

CategoryEndpointsDefault limit
Readbalance, networks, packages, orders, verifications (GET)120 requests / minute
Writepricing quotes, cancellations30 requests / minute
Transactionpurchase, create verification10 requests / minute

Exceeding a limit returns 429. Poll order status every ~3 seconds, not in a tight loop.

Data bundle endpoints

GET/api/v1/balanceread

Returns your current wallet balance, the funds available for API purchases.

200 response
{
  "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"
  }
}
GET/api/v1/networksread

Lists the networks currently available to your account. Use the returned id values everywhere a network is required.

200 response
{
  "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"
  }
}
Availability can change An admin can temporarily disable a network. Purchases against a disabled network return 503, NETWORK_UNAVAILABLE, so treat this list as live rather than caching it for days.
GET/api/v1/packages/:networkread

Lists packages and your prices (agent/reseller discounts already applied) for one network.

Path paramTypeDescription
networkstringmtnup2u · airteltigo · telecel
200 response
{
  "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.

POST/api/v1/purchasetransaction

Buys a data bundle for a Ghanaian phone number, debiting your wallet upfront.

FieldTypeRequiredDescription
networkstringYesNetwork id from /networks
sizestringYesPackage size exactly as returned by /packages
beneficiary_phonestringYesRecipient number, 0XXXXXXXXX or +233…
idempotency_keystringRecommendedUnique per order; or send the X-Idempotency-Key header
request
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"
}
200 response
{
  "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

HTTPerror_codeMeaning
400INSUFFICIENT_BALANCEWallet can't cover the price, data.required vs data.available
400CACHED_FAILURESame idempotent request already failed; response replays the original failure
403WALLET_FROZENPurchases disabled on your account, contact support
403STOREFRONT_CHANNEL_REQUIREDstorefront_payment_reference is storefront-only; remove it to pay from your wallet
429DUPLICATE_PURCHASE_WINDOWSame bundle to the same phone too soon, wait data.wait_seconds
503NETWORK_UNAVAILABLENetwork temporarily disabled by admin
503/504PROVIDER_PURCHASE_FAILEDUpstream provider failed, your debit was auto-reversed; retry later
GET/api/v1/orders/:referenceread

Looks 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).

200 response
{
  "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).

GET/api/v1/profit-balance/syncread

Returns your aggregated sales statistics, useful for dashboards on top of your integration.

200 response (data)
{
  "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.

The cards are the goods, store them. Save them on your side the moment you receive them. GET /result-checkers/:purchase_id can re-issue them, but treat that as a safety net, not your only copy.
GET/api/v1/result-checkers/catalogread

Types on sale and their prices. When available is false the service is off or nothing is priced, don't attempt a purchase.

200 response (data)
{
  "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"]
}
POST/api/v1/result-checkers/purchasetransaction

Buys 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.

Request
POST /api/v1/result-checkers/purchase
Authorization: Bearer YOUR_API_KEY
X-Idempotency-Key: order-8842

{
  "checker_type": "WASSCE",
  "recipient": "0540000000",
  "quantity": 1
}
200 response (data)
{
  "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
}
HTTPerror_codeYour money
400CHECKER_PURCHASE_FAILEDRefunded automatically. Safe to retry
400INSUFFICIENT_BALANCENever debited
202CHECKER_PROVIDER_UNCERTAINHeld, not refunded, do not retry
202CHECKER_NO_CARDS_RETURNEDHeld, not refunded, do not retry
503CHECKER_SERVICE_DISABLEDNever debited
On a 202, do not retry. A timeout is not a refusal, the cards may already have been issued upstream. Auto-refunding there would hand you both the cards and your money, so the order is held and reviewed by a human. Poll GET /result-checkers/:purchase_id: it settles to COMPLETED (with your cards) or is refunded. Retrying just places a second order.
GET/api/v1/result-checkers/:purchase_idread

Re-reads a purchase you already paid for, including the cards. Only your own purchases are visible.

statusMeaning
COMPLETEDCards issued, they're in checkers[]
PENDINGPaid, provider hasn't answered yet
UNCERTAINProvider never confirmed. Under review, do not re-order
FAILEDProvider 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.

GET/api/v1/verifications/servicesread

Browse the service catalog. Query params: capability (sms, default) and available=true to show only services with live stock.

200 response (data)
{
  "capability": "sms",
  "records": [
    { "service_name": "whatsapp", "name": "WhatsApp", "price": 4.50, "available": true },
    { "service_name": "google",   "name": "Google",   "price": 3.80, "available": true }
  ]
}
POST/api/v1/verifications/pricingwrite

Locks a live price and returns a quoteToken you must present when reserving. Body: { "serviceName": "whatsapp", "capability": "sms" }.

200 response (data)
{
  "serviceName": "whatsapp",
  "serviceDisplayName": "WhatsApp",
  "price": 4.50,
  "quoteToken": "QT_CONF_abc123xyz",
  "quoteExpiresAt": "2026-07-03T12:10:00.000Z"  // ~5 minutes
}
POST/api/v1/verificationstransaction

Reserves 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.

HTTPerror_codeMeaning
400QUOTE_TOKEN_REQUIREDMissing quoteToken
409VERIFICATION_PRICE_CONFIRMATION_REQUIREDQuote expired, fetch pricing again
409VERIFICATION_PRICE_UPDATEDLive price moved, review the new price and re-quote
400INSUFFICIENT_BALANCEWallet can't cover the quoted amount
409NO_NUMBERS_AVAILABLENo stock right now, try again shortly
GET/api/v1/verifications/:purchaseIdread

Polls 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.

uiStateMeaning
number_assignedNumber ready, waiting for the SMS
receivedCode arrived, you're charged
expiredWindow closed with no SMS, hold released
cancelledYou cancelled, hold released
failedProvider error, hold released
POST/api/v1/verifications/:purchaseId/cancelwrite

Cancels an active reservation before the SMS arrives; the wallet hold is released immediately.

Error codes

HTTPWhen you'll see it
400Missing/invalid fields, bad phone format, unknown package size, insufficient balance
401Missing, malformed, expired, or deactivated API key
403Frozen wallet · storefront-only field used (STOREFRONT_CHANNEL_REQUIRED) · blacklisted recipient
404Unknown network, order reference, or verification id
409Verification quote expired / price moved
429Rate limit hit, duplicate purchase window, or risk-control velocity check
500 / 502Unexpected server or provider error, safe to retry with the same idempotency key
503 / 504Network 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.

curl
# 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"
node.js
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);
python
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=1 only when a customer is actively waiting.
  • Check balance before batches. A pre-flight /balance call gives customers a clean “top up first” message.
  • Match on error_code, not message text. Messages can be reworded; codes are stable.
  • Handle FAILED explicitly. Failed dispatches auto-refund your wallet, reflect that in your own records.
  • Never send storefront fields. storefront_payment_reference belongs 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.

0 / 8 missions
Sandbox console simulated · no real requests leave this page wallet GHS 50.00
,
Response will appear here. Pick an endpoint, press “Send request”.

          
ReferenceBundlePhonePriceStatus
No orders yet, make a purchase above.