Buy airtime or data
/airtime/v1/purchasesBuys airtime or a data bundle for one number. No added fees: $1.00 of airtime costs $1.00, and a bundle costs its listed price. The response shows whether the purchase succeeded, failed or is pending.
Your key needs the Airtime API turned on, and in live your team must be approved. See Buying airtime and data bundles.
Request body
Content-Type: application/json
Send amount for airtime or a bundle for data. A request with both or neither is refused with 400.
Name a bundle by productId (its id), by productCode (its code), or by both. If you send both, they must name the same bundle. Otherwise the request is refused with 400 and nothing is charged. Store the id, because it never changes.
- The number to buy for. International (231 + 9 digits) or local (0 + 9 digits). Orange numbers start with 2317. Lonestar Cell MTN numbers start with 23188 or 23155. Other networks are refused.Example:
231770000000 - For airtime. The airtime the number receives, in US dollars. At least 1 and at most your team's limit per recipient (50 unless agreed otherwise). Your account is charged this amount. Leave it out when you buy a bundle.Example:
5 - For a data bundle. A bundle id (prod_…) from GET /products. The bundle must be on sale, for the number's network, and priced within your team's limit per recipient. You pay its price. Leave it out when you send amount.Example:
prod_p4nzx9xp335h - For a data bundle, instead of or with productId. A bundle code from GET /products. With productId, it must name the same bundle. Leave it out when you send amount.Example:
mtn-data-15gb-30d - For a data bundle, the price you showed your customer, in US dollars, with up to 2 decimal places. If the bundle's price is different now, the purchase is refused with
409(E03004) and nothing is charged. Leave it out when you send amount.Example:9 - The handle of the workspace to buy for and charge, such as
gbarnga-branch. Leave it out for Main. See Workspaces.Example:gbarnga-branch - Your own reference for the purchase, such as an order number. 1 to 30 letters, digits, "_", "." or "-", unique within your team. Reusing one returns the original purchase without charging again.Example:
order-8842
If a request times out, send it again with the same externalId. If the first request went through, you get it back with 200. You're charged once. See Retries and externalId.
Example request
- curl
- Node.js
- Python
- PHP
curl -X POST "https://sandbox-api.ponitor.com/airtime/v1/purchases" \
-H "apiKey: $PONITOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"msisdn": "231880000000",
"productId": "prod_p4nzx9xp335h",
"externalId": "order-8843"
}'
const response = await fetch('https://sandbox-api.ponitor.com/airtime/v1/purchases', {
method: 'POST',
headers: {
apiKey: process.env.PONITOR_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
msisdn: '231880000000',
productId: 'prod_p4nzx9xp335h',
externalId: 'order-8843',
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://sandbox-api.ponitor.com/airtime/v1/purchases",
headers={"apiKey": os.environ["PONITOR_API_KEY"]},
json={
"msisdn": "231880000000",
"productId": "prod_p4nzx9xp335h",
"externalId": "order-8843",
},
timeout=30,
)
print(response.status_code, response.json())
<?php
$ch = curl_init('https://sandbox-api.ponitor.com/airtime/v1/purchases');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'apiKey: ' . getenv('PONITOR_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'msisdn' => '231880000000',
'productId' => 'prod_p4nzx9xp335h',
'externalId' => 'order-8843',
]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
echo $status, ' ', $body, PHP_EOL;
To buy by code, send "productCode": "mtn-data-15gb-30d" instead of productId. To buy airtime, send "amount": 5 instead.
Responses
- 201
- 200
- 400
- 401
- 403
- 409
- 429
- 500
Created. Ponitor accepted the purchase, which can still be FAILED. The body is the purchase. status is usually SUCCESS. PENDING means the network hasn't confirmed yet. A webhook tells you when it settles, or you can call Get a purchase. An airtime purchase has the same shape, with type AIRTIME and product null.
{
"id": "6f1c2a3e-8b4d-4c1a-9e2f-3a4b5c6d7e8f",
"externalId": "order-8843",
"type": "DATA",
"msisdn": "231880000000",
"carrier": "LONESTAR",
"product": {
"id": "prod_p4nzx9xp335h",
"code": "mtn-data-15gb-30d",
"name": "Lonestar Cell MTN 15GB, 30 days",
"data": "15GB",
"validity": "30 days",
"price": 9,
"currency": "USD"
},
"amount": 9,
"cost": 9,
"status": "SUCCESS",
"failureReason": null,
"instructions": null,
"createdAt": "2026-10-20T19:40:00.000Z"
}
A FAILED purchase also returns 201. The request was valid, but the network declined it. The cost is refunded, and failureReason says why. See Why a purchase failed.
OK. The externalId was already used. The body is the original purchase. No new charge was made, even if the amount, productId or productCode differ.
Bad Request. The request was refused. Nothing was charged and no purchase was made. A field is invalid (E01001), the bundle can't be bought (E12001, E12002), the number isn't on a network Ponitor sells to (E12003), or the balance is too low (E03001). See Refused purchases for each message.
{
"error": {
"type": "validation_error",
"code": "E01001",
"name": "ValidationError",
"message": "The request has 1 problem.",
"doc_url": "https://docs.ponitor.com/api/errors#E01001",
"request_id": "b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e",
"param": "msisdn",
"details": [
{ "param": "msisdn", "message": "msisdn must be 12 digits starting with 231 or 10 digits starting with 0" }
]
}
}
{
"error": {
"type": "bad_request_error",
"code": "E12001",
"name": "ProductUnavailable",
"message": "Unknown or unavailable product: mtn-data-15gb-30d",
"doc_url": "https://docs.ponitor.com/api/errors#E12001",
"request_id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b"
}
}
Every error has this error object. The body also still carries fields from before codes, which are deprecated. See The error body.
Unauthorized. The API key is missing (E02001), wrong or revoked (E02002), or sent to the other base URL (E02003). See API keys and base URLs.
Forbidden. Your team hasn't turned on the Airtime API, or in live isn't approved yet. See API keys and base URLs.
Conflict. The bundle's price isn't the expectedPrice you sent (E03004). Nothing was charged. Read the price again with Get a data bundle, then send it.
{
"error": {
"type": "conflict_error",
"code": "E03004",
"name": "PriceChanged",
"message": "Product prod_p4nzx9xp335h now costs $9.50, not $9.00. Nothing was charged. Check the price and try again.",
"doc_url": "https://docs.ponitor.com/api/errors#E03004",
"request_id": "f6a7b8c9-d0e1-4f2a-8b3c-4d5e6f7a8b9c"
}
}
Too Many Requests. Your team went over its rate limit (E01011). Nothing was charged. Wait, then send the same request again with the same externalId.
Server Error. Ponitor couldn't complete the purchase (E01000). Nothing was charged. Retry with the same externalId.
The purchase object
| Field | Description |
|---|---|
id | Ponitor's ID for the purchase. Use it with GET /purchases/{id}. |
externalId | The reference you sent. |
type | What was bought, AIRTIME or DATA. |
msisdn | The number, normalised to international format. |
carrier | ORANGE or LONESTAR (Lonestar Cell MTN), detected from the number. |
product | For DATA, the bundle as it was when bought, with id, code, name, data, validity, price and currency. It doesn't change if the bundle later changes. null for airtime. |
amount | For airtime, the airtime delivered in USD. For data, the bundle's price. |
cost | What your account was charged, in USD. No added fees: for airtime it equals amount, and for data it's the bundle's price. Refunded if the purchase fails. |
status | SUCCESS, PENDING or FAILED. See What happens to a purchase. |
failureReason | Why a FAILED purchase failed. See Why a purchase failed. null otherwise. |
instructions | Text from the network for the subscriber, such as how to use a bundle. Pass it on when it's set. Usually null. |
createdAt | When the purchase was made. |
Every purchase has type, product and instructions, including airtime purchases.
Refused purchases
A refused purchase answers 400, and nothing is charged. Fix the request and send it again.
| Problem | Code | Message |
|---|---|---|
A field is missing or invalid, such as msisdn | E01001 | Each field's message is in details, for example msisdn must be 12 digits starting with 231 or 10 digits starting with 0 |
productId isn't a bundle id, for example a code sent as productId | E01001 | productId must be a product id from GET /products (prod_…); send a code as productCode |
productCode isn't a bundle code | E01001 | productCode must be a product code from GET /products |
amount under $1 | E01001 | amount must be at least 1.00 |
amount with productId or productCode | E01002 | Send either amount (Airtime) or a product (productId or productCode), not both |
Neither amount nor a bundle | E01002 | amount (Airtime) or a product (productId or productCode) is required |
productId and productCode name different bundles | E01002 | productId prod_p4nzx9xp335h and productCode mtn-data-500mb-3d are different products; send one, or both for the same product |
amount or the bundle's price is over your team's limit per recipient | E01002 | $99.00 exceeds your team's $50.00 per-recipient limit |
| The bundle is unknown or withdrawn | E12001 | Unknown or unavailable product: prod_p4nzx9xp335h (the id or code you sent) |
| The bundle is for the other network | E12002 | Product mtn-data-15gb-30d is for Lonestar Cell MTN numbers (or Orange numbers) |
| The number isn't on Orange or Lonestar Cell MTN | E12003 | msisdn is not on a supported network (Orange or Lonestar Cell MTN) |
Your account can't cover the cost | E03001 | Insufficient account balance. Top up under Billing to continue. |
Your team's limit per recipient is $50 unless agreed otherwise. It applies to airtime and to a bundle's price. Contact us to raise it.