API Documentation
Integrate airtime top-ups, data bundles, and bulk gifting into your application.
All responses are JSON. Wallet is debited immediately; delivery is async.
Authentication
Every request must include your API Key. Generate one from Account Settings. Two formats are accepted — use whichever suits your stack:
Option A — Header (recommended)
X-Api-Key: sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Option B — Bearer token
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
Content-Type: application/json
cURL — Header
curl https://sim.portaltopup.ng/api/v1/balance \
-H "X-Api-Key: sk_live_..."
cURL — Bearer
curl https://sim.portaltopup.ng/api/v1/balance \
-H "Authorization: Bearer sk_live_..."
Keep your API key secret — treat it like a password. If compromised, regenerate it instantly from Account Settings. Never expose it in frontend JavaScript or mobile app source code.
Base URL & Response Format
Base URL
https://sim.portaltopup.ng/api/v1
Success Response
{
"success": true,
"message": "...",
"data": { // object or array },
"meta": { // pagination (when applicable)
"current_page": 1,
"last_page": 5,
"per_page": 20,
"total": 98
}
}
Error Response
{
"success": false,
"error": "Human-readable message",
"code": "SNAKE_CASE_CODE",
"errors": { // validation errors only
"phone": ["The phone field is required."]
}
}
Rate Limits
Rate limits are enforced per API key. Response headers show your current allowance.
| Header | Description |
X-RateLimit-Limit | Requests allowed per minute |
X-RateLimit-Remaining | Requests left in current window |
Retry-After | Seconds to wait (returned with HTTP 429) |
Exceeding the limit returns HTTP 429 Too Many Requests.
Error Codes
| HTTP | Code | Meaning |
401 | UNAUTHENTICATED | Missing or invalid API key / secret |
402 | INSUFFICIENT_BALANCE | Wallet balance too low — nothing debited |
422 | VALIDATION_ERROR | Request body failed validation — check errors field |
422 | PLAN_NOT_FOUND | The plan_code does not exist or is inactive |
429 | TOO_MANY_REQUESTS | Rate limit exceeded — slow down |
500 | SERVER_ERROR | Internal error — contact support with the reference |
Transaction Lifecycle
All orders return HTTP 202 Accepted immediately after the wallet is debited. MTN delivery happens in the background — poll GET /orders/{ref} to check the result.
pending
queued
processing
success
processing
failed
— wallet auto-refunded
| Status | Meaning | Wallet |
| pending | Accepted, not yet picked up by worker | Debited |
| queued | Worker picked it up, finding a SIM | Debited |
| processing | MTN API call in progress | Debited |
| success | Delivered to recipient | Debited (final) |
| failed | Delivery failed after all retries | Auto-refunded |
| refunded | Manually reversed by admin | Refunded |
| timeout | Job timed out mid-call — under manual review | Held pending review |
Polling tip: Most transactions complete within 30 seconds. Poll every 5–10s. If still processing after 2 minutes, contact support with the reference.
Wallet
GET
/api/v1/balance
Check your wallet balance
Request
// No body required
GET /api/v1/balance
Response 200
{
"success": true,
"data": {
"balance": 12500.00,
"currency": "NGN",
"is_frozen": false
}
}
Plans
GET
/api/v1/plans
List available data & airtime plans
Query Parameters
| Param | Type | | Description |
network | string | Optional | mtn · airtel · glo · 9mobile |
type | string | Optional | data · data_transfer · airtime |
Request
GET /api/v1/plans?network=mtn
Response 200
{
"success": true,
"data": [
{
"plan_id": "MTN_WEEKLY_1GB_7D",
"plan_code": "mtn-weekly-1gb-7d",
"network": "mtn",
"name": "1GB Weekly Plan",
"service_type": "data",
"data_mb": 1024,
"validity_days": 7,
"price": 840.00
}
]
}
Both plan_id (e.g. MTN_WEEKLY_1GB_7D) and plan_code (e.g. mtn-weekly-1gb-7d) are accepted in POST /buy-data and POST /bulk/data.
Transactions
POST
/api/v1/buy-airtime
Send airtime to a phone number
Request Body
| Field | Type | | Description |
network | string | Required | mtn · airtel · glo · 9mobile |
phone | string | Required | Recipient e.g. 08012345678 |
amount | number | Required | Naira amount. Min: 50 · Max: 50,000 |
external_reference | string | Optional | Your own order/reference ID. Echoed back in the response and in every webhook. Also used for idempotency — re-sending the same reference returns the original result. Alias: customer_ref, client_ref, order_id, ref. |
Request
{
"network": "mtn",
"phone": "08012345678",
"amount": 500,
"external_reference": "YOUR-ORDER-001"
}
Response 200 / 422
{
"success": true,
"message": "Airtime sent successfully.",
"status": "success",
"reference": "TXN-20260619-ABCDE",
"external_reference": "YOUR-ORDER-001",
"network": "mtn",
"phone": "08012345678",
"amount": 500.00,
"total_charged": 500.00,
"wallet_balance_after": 12000.00,
"data": {
"reference": "TXN-20260619-ABCDE",
"external_reference": "YOUR-ORDER-001",
"network": "mtn",
"phone": "08012345678",
"amount": 500.00,
"total_charged": 500.00,
"balance": 12000.00,
"api_response": "Airtime sent successfully."
}
}
Returns 200 on success, 422 on failure — check status or success field.
Root-level fields (reference, wallet_balance_after) are kept for backward compatibility.
Platforms using field-mapping config should read data.reference and data.balance.
POST
/api/v1/buy-data
Send a data bundle to a phone number
Request Body
| Field | Type | | Description |
network | string | Required | mtn · airtel · glo · 9mobile |
phone | string | Required | Recipient e.g. 08012345678 |
plan_code | string | Required | From GET /plans — e.g. mtn-weekly-1gb-7d |
external_reference | string | Optional | Your own order/reference ID. Echoed back in the response and in every webhook. Also used for idempotency — sending the same reference twice returns the original result without creating a duplicate. Accepted aliases: customer_ref, client_ref, order_id, ref. |
Timeout: The API processes data orders synchronously and responds within ~60 seconds. Set your HTTP client timeout to at least 65 seconds. If your client times out before the response arrives, the transaction may still complete — check the webhook or use GET /orders?reference=TXN-xxx to confirm. Always send external_reference so the webhook event can be matched back to your pending order.
Request
{
"network": "mtn",
"phone": "08012345678",
"plan_code": "mtn-weekly-1gb-7d",
"external_reference": "YOUR-ORDER-001"
}
Response 200 / 422
{
"success": true,
"message": "Your request for 1GB data has been completed.",
"status": "success",
"reference": "TXN-20260619-ABCDE",
"external_reference": "YOUR-ORDER-001",
"network": "mtn",
"phone": "08012345678",
"plan_id": "MTN_WEEKLY_1GB_7D",
"plan_code": "mtn-weekly-1gb-7d",
"total_charged": 350.00,
"wallet_balance_after": 12150.00,
"data": {
"reference": "TXN-20260619-ABCDE",
"external_reference": "YOUR-ORDER-001",
"network": "mtn",
"phone": "08012345678",
"plan_id": "MTN_WEEKLY_1GB_7D",
"plan_code": "mtn-weekly-1gb-7d",
"plan_name": "1GB 7 Days",
"data_mb": 1024,
"validity_days": 7,
"total_charged": 350.00,
"balance": 12150.00,
"api_response": "Your request for 1GB data has been completed."
}
}
HTTP 402 — insufficient balance. Nothing is debited, no transaction created.
HTTP 422 — transaction failed (bad plan, DT pool exhausted, etc.).
Root-level fields kept for backward compatibility. Platforms using field-mapping config read data.reference and data.balance.
GET
/api/v1/orders
List your transactions
Query Parameters
| Param | Type | | Description |
status | string | Optional | pending · success · failed · processing |
network | string | Optional | mtn · airtel etc. |
type | string | Optional | data · airtime |
from | date | Optional | 2026-06-01 |
to | date | Optional | 2026-06-30 |
per_page | int | Optional | Default 20, max 100 |
Response 200
{
"success": true,
"data": [
{
"reference": "TXN-20260619-ABCDE",
"type": "data",
"network": "mtn",
"recipient_phone": "08012345678",
"total_charged": 350.00,
"status": "success",
"wallet_balance_before": 12500.00,
"wallet_balance_after": 12150.00,
"created_at": "2026-06-19T10:00:00Z",
"completed_at": "2026-06-19T10:00:28Z"
}
],
"meta": {
"current_page": 1, "last_page": 5,
"per_page": 20, "total": 98
}
}
GET
/api/v1/orders/{reference}
Get a single transaction
Request
GET /api/v1/orders/TXN-20260619-ABCDE
Response 200
{
"success": true,
"data": {
"reference": "TXN-20260619-ABCDE",
"type": "data",
"network": "mtn",
"recipient_phone": "08012345678",
"total_charged": 350.00,
"status": "success",
"response_message": "Transaction Successful",
"wallet_balance_before": 12500.00,
"wallet_balance_after": 12150.00,
"plan": {
"code": "mtn-weekly-1gb-7d",
"name": "1GB — 7 Days",
"data": 1024
},
"created_at": "2026-06-19T10:00:00Z",
"completed_at": "2026-06-19T10:00:28Z"
}
}
Bulk Operations
POST
/api/v1/bulk/data
Send data to multiple recipients at once (max 200)
Wallet is debited for all recipients in one atomic debit immediately. Processing is async — returns a batch_id to poll for progress.
Request Body
| Field | Type | | Description |
network | string | Required | Network for all recipients |
product_code | string | Required* | Plan code from GET /plans. Required unless per-recipient format is used. |
recipients | array | Required | Phone strings or per-recipient objects (see below) |
sender_sim_id | int | Optional | Specific SIM to send from. Auto-routed if omitted. |
Same plan for all recipients
{
"network": "mtn",
"product_code": "mtn-weekly-1gb-7d",
"recipients": [
"08012345678",
"08023456789",
"08034567890"
]
}
Different plan per recipient
{
"network": "mtn",
"recipients": [
{
"phone": "08012345678",
"product_code": "mtn-weekly-1gb-7d"
},
{
"phone": "08023456789",
"product_code": "mtn-monthly-5gb-30d"
}
]
}
Response 202 Accepted
{
"success": true,
"message": "Bulk batch of 3 queued. Poll batch_id for results.",
"batch_id": "550e8400-e29b-41d4-a716-446655440000",
"total": 3,
"status": "processing",
"total_charged": 1050.00
}
GET
/api/v1/bulk
List your bulk batches
Response 200
{
"success": true,
"data": [
{
"uuid": "550e8400-...",
"network": "mtn",
"total_count": 100,
"success_count": 97,
"failed_count": 3,
"pending_count": 0,
"status": "partial",
"created_at": "2026-06-19T10:00:00Z"
}
],
"meta": { "current_page": 1, "total": 5 }
}
GET
/api/v1/bulk/{batch_id}
Get full batch result with per-recipient breakdown
Request
GET /api/v1/bulk/550e8400-...
Response 200
{
"success": true,
"data": {
"uuid": "550e8400-...",
"status": "completed",
"total_count": 3,
"success_count": 3,
"failed_count": 0,
"transactions": [
{
"reference": "TXN-20260619-AAA",
"recipient_phone": "08012345678",
"status": "success",
"total_charged": 350.00
}
]
}
}
Poll every 5–10s. Batch status: processing → completed (all ok), partial (some failed), or failed (all failed — all refunded).
Account
POST
/api/v1/api-keys/regenerate
Rotate your API key pair
Invalidates the current key immediately and returns a new key + secret. Update all integrations with the new pair before your next request.
Request
// No body required
POST /api/v1/api-keys/regenerate
Response 200
{
"success": true,
"data": {
"key": "sk_live_...",
"secret": "..."
},
"message": "Key regenerated. Save it — shown once."
}
Webhooks
Configure a webhook URL in Account Settings to receive real-time push notifications instead of polling.
Events
| Event | Fired when |
transaction.success | Data or airtime successfully delivered to recipient |
transaction.failed | Delivery failed — wallet has been automatically refunded |
Payload
{
"event": "transaction.success",
"data": {
"reference": "TXN-20260619-ABCDE",
"type": "data",
"network": "mtn",
"recipient_phone": "08012345678",
"status": "success",
"total_charged": 350.00,
"wallet_balance_before": 12500.00,
"wallet_balance_after": 12150.00,
"completed_at": "2026-06-19T10:00:28Z"
}
}
Signature verification: Every delivery includes an X-Signature header (HMAC-SHA256 of the raw body signed with your webhook secret). Always verify this before trusting the payload.
Code Examples
PHP (cURL / Laravel)
$BASE = 'https://sim.portaltopup.ng/api/v1';
$KEY = 'sk_live_...';
// 1. Fetch your balance
$balance = Http::withHeaders(['X-Api-Key' => $KEY])
->get($BASE.'/balance')
->json('data.balance');
// 2. Buy 1GB data
$res = Http::withHeaders(['X-Api-Key' => $KEY])
->post($BASE.'/buy-data', [
'network' => 'mtn',
'phone' => '08012345678',
'plan_code' => 'mtn-weekly-1gb-7d',
]);
if ($res->status() === 202) {
$ref = $res->json('reference');
// poll GET /orders/{$ref} until status = success|failed
}
JavaScript (fetch)
const BASE = 'https://sim.portaltopup.ng/api/v1';
const headers = {
'X-Api-Key': 'sk_live_...',
'Content-Type': 'application/json',
};
// Buy airtime
const res = await fetch(`${BASE}/buy-airtime`, {
method: 'POST', headers,
body: JSON.stringify({ network: 'mtn', phone: '08012345678', amount: 500 })
});
const { reference } = await res.json();
// Poll for result every 5 seconds
let done = false;
while (!done) {
await new Promise(r => setTimeout(r, 5000));
const order = await fetch(`${BASE}/orders/${reference}`, { headers })
.then(r => r.json());
if (['success', 'failed'].includes(order.data.status)) done = true;
}
Python (requests)
import requests
BASE = 'https://sim.portaltopup.ng/api/v1'
headers = {'X-Api-Key': 'sk_live_...', 'Content-Type': 'application/json'}
# Buy data
res = requests.post(f'{BASE}/buy-data', headers=headers, json={
'network': 'mtn', 'phone': '08012345678', 'plan_code': 'mtn-weekly-1gb-7d'
})
reference = res.json()['reference'] # or res.json()['data']['reference']
cURL — Bulk Data
curl https://sim.portaltopup.ng/api/v1/bulk/data \
-X POST \
-H "X-Api-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"network": "mtn",
"product_code": "mtn-weekly-1gb-7d",
"recipients": ["08011111111","08022222222","08033333333"]
}'
TopupSim REST API v1 ·
Generate API Keys
· Questions? Contact support.