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.

HeaderDescription
X-RateLimit-LimitRequests allowed per minute
X-RateLimit-RemainingRequests left in current window
Retry-AfterSeconds to wait (returned with HTTP 429)

Exceeding the limit returns HTTP 429 Too Many Requests.

Error Codes

HTTPCodeMeaning
401UNAUTHENTICATEDMissing or invalid API key / secret
402INSUFFICIENT_BALANCEWallet balance too low — nothing debited
422VALIDATION_ERRORRequest body failed validation — check errors field
422PLAN_NOT_FOUNDThe plan_code does not exist or is inactive
429TOO_MANY_REQUESTSRate limit exceeded — slow down
500SERVER_ERRORInternal 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
StatusMeaningWallet
pendingAccepted, not yet picked up by workerDebited
queuedWorker picked it up, finding a SIMDebited
processingMTN API call in progressDebited
successDelivered to recipientDebited (final)
failedDelivery failed after all retriesAuto-refunded
refundedManually reversed by adminRefunded
timeoutJob timed out mid-call — under manual reviewHeld 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
ParamTypeDescription
networkstringOptionalmtn · airtel · glo · 9mobile
typestringOptionaldata · 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
FieldTypeDescription
networkstringRequiredmtn · airtel · glo · 9mobile
phonestringRequiredRecipient e.g. 08012345678
amountnumberRequiredNaira amount. Min: 50 · Max: 50,000
external_referencestringOptionalYour 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
FieldTypeDescription
networkstringRequiredmtn · airtel · glo · 9mobile
phonestringRequiredRecipient e.g. 08012345678
plan_codestringRequiredFrom GET /plans — e.g. mtn-weekly-1gb-7d
external_referencestringOptionalYour 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
ParamTypeDescription
statusstringOptionalpending · success · failed · processing
networkstringOptionalmtn · airtel etc.
typestringOptionaldata · airtime
fromdateOptional2026-06-01
todateOptional2026-06-30
per_pageintOptionalDefault 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
FieldTypeDescription
networkstringRequiredNetwork for all recipients
product_codestringRequired*Plan code from GET /plans. Required unless per-recipient format is used.
recipientsarrayRequiredPhone strings or per-recipient objects (see below)
sender_sim_idintOptionalSpecific 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: processingcompleted (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
EventFired when
transaction.successData or airtime successfully delivered to recipient
transaction.failedDelivery 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.