WilCybertek SMS Gateway
A direct, low-latency SMS platform connecting your applications to real mobile networks — any carrier, any country — through physical, on-premise Android GSM hardware. No international routing, no hidden relay fees. This page documents everything the platform can do: the developer REST API, the Android gateway app, the web dashboard, device management, the two-way messaging inbox, the built-in AI assistant, wallet top-up, and the new Airtel airtime & bundles platform.
Creating an Account
Every feature on this page — the API, the dashboard, the AI assistant — sits behind a free developer account.
| Field | Rule |
|---|---|
| name | 2–80 characters |
| Must contain "@" and be at least 5 characters; must be unique | |
| password | At least 8 characters, with at least one uppercase letter and one digit |
Registering with an email that already has an account redirects you to /login instead of failing silently. Forgot your password? /forgot-password emails a recovery link (valid for 1 hour) to reset it — the response message is intentionally identical whether or not the email exists, so the flow can't be used to find out which emails are registered.
Android Gateway App
The WilCybertek Gateway app turns a spare Android phone into a physical SMS relay on whatever mobile network its SIM card belongs to — anywhere in the world. It's the hardware behind every message this platform sends — install it before your first API call or the message never leaves QUEUED status.
Setup
sms.wilcybertek.com) is built into the app and cannot be changed. Grant the SMS and notification permissions requested, and set battery to No restrictions.0 = SIM 1, 1 = SIM 2).What's New & Features
Everything the platform does today, in one place.
Gateway app
Sending & reliability
WhatsApp, AI & commerce
Airtel airtime & bundles NEW
Quickstart
Send your first SMS in under 5 minutes.
curl -X POST https://sms.wilcybertek.com/api \
-H "Content-Type: application/json" \
-d '{
"api_key": "YOUR_API_KEY_HERE",
"to": "260977000000",
"message": "Hello! This is my first WilCybertek SMS."
}'
Authentication
The platform uses two independent authentication mechanisms depending on whether you're calling the API or using the web app.
API Key (developer / device endpoints)
Every API and device endpoint below resolves your account from an API key. The key can be supplied in any of these ways, checked in this order — the first one found wins:
| Priority | Location | Example |
|---|---|---|
| 1 | X-API-Key | HTTP header (recommended) |
| 2 | api_key | JSON body field |
| 3 | api_key | URL query string |
| 4 | api_key | Form-encoded body field |
X-API-Key: wp_live_a1b2c3d4-e5f6-...
Session Cookie (web dashboard)
Logging in at /login issues a signed session cookie. All dashboard pages (Dashboard, Devices, Messages, Send SMS, AI Assistant) and their POST actions run behind this session — see Security Model for CSRF details. Session-based routes do not accept an API key.
Send SMS
The core endpoint. Queues one SMS for delivery through your default (or a specified) gateway device.
Accepts either application/json or application/x-www-form-urlencoded.
Body Parameters
| Field | Type | Description | |
|---|---|---|---|
| to | string | required | Recipient number, in full international format. Auto-normalized — see Phone Number Formatting. |
| message | string | required | SMS body. Max 1,600 characters. |
| device_id | string | optional | Send from a specific gateway device instead of your account default. |
| sim_slot | int | optional | 0 = SIM 1, 1 = SIM 2. Falls back to your account/device default. |
POST /api HTTP/1.1
Host: sms.wilcybertek.com
Content-Type: application/json
X-API-Key: wp_live_xxxx-xxxx-...
{
"to": "260977000000",
"message": "Your order #4821 has shipped.",
"sim_slot": 0
}
{
"success": true,
"id": 4821,
"status": "QUEUED"
}
Message Status Values
Every queued message moves through this lifecycle. You'll see these values in your dashboard logs and in /update_status.php reports.
| Status | Meaning |
|---|---|
| QUEUED | Accepted, waiting for a gateway device to pick it up. |
| SENDING | A device has claimed the job and is dispatching it via GSM. |
| SENT | Confirmed sent by the device. |
| DELIVERED | Confirmed delivered (when the network/device reports a delivery receipt). |
| FAILED | Delivery failed — the spent credit is automatically refunded. |
| PROCESSING | Shown on the dashboard for jobs mid-flight; requeue-able if stuck. |
Register a Device
Registers (or updates) a gateway device under your account. The official Android app calls this automatically — you only need it directly if you're building a custom gateway client.
| Field | Type | Description | |
|---|---|---|---|
| device_id | string | required | Stable unique identifier for the phone (e.g. Android ID). |
| device_name | string | optional | Friendly label shown on the Devices page. |
| sim_slots | int | optional | Number of active SIM slots. Defaults to 1. |
{ "success": true }
Push Token Registration
Registers a device's push (Firebase Cloud Messaging) token so it can be woken instantly when a new job is queued, instead of waiting for its next poll.
| Field | Type | Description | |
|---|---|---|---|
| device_id | string | required | Must match a registered device. |
| fcm_token | string | required | Push token issued by Firebase on the device. |
Job Polling (gateway devices)
Used by a gateway device to ask "do you have a message for me to send?" This is how the Android app fetches queued jobs; build against it directly only if you're writing your own gateway client.
| Field | Type | Description | |
|---|---|---|---|
| device_id | string | required | The polling device's ID (accepts device as an alias). |
| api_key | string | optional | Only needed the very first time a brand-new device polls, to auto-register it. |
Any active device on the account may claim an unpinned job. The claim is atomic, so two phones can never receive the same message. A job with a device_id only goes to that phone.
{
"status": "found",
"id": "4821",
"phone": "260977000000",
"message": "Your order #4821 has shipped.",
"sim_slot": 0
}
{ "status": "none" }
Delivery Status Report
Called by a gateway device after it attempts to send, to report the outcome back to the platform.
| Field | Type | Description | |
|---|---|---|---|
| id | int | required | The message id returned by /api or fetched via job polling. |
| status | string | required | SENT, FAILED, or DELIVERED. Anything else is recorded as FAILED. |
| device_id | string | optional | Reporting device, for the audit trail. |
Receive Inbound SMS
Called by the gateway app the moment it receives an SMS on the phone, forwarding it into your inbox in real time (single message — see the next section for bulk sync).
| Field | Type | Description | |
|---|---|---|---|
| sender | string | required | Number that sent the SMS. |
| message | string | required | SMS body. |
| device_id | string | optional | Receiving device. |
| sim_slot | int | optional | Which SIM received it. |
Bulk Message Sync
Bulk-syncs a batch of native SMS threads (inbox and/or sent) from the phone into the platform's Messages view. /sync_inbox.php and /sync_messages.php are aliases — both run the exact same handler.
| Field | Type | Description | |
|---|---|---|---|
| device_id | string | required | Syncing device (auto-registered if unseen before). |
| phone_name | string | optional | Friendly device name if not already set. |
| messages | array | required | Array of message objects (form field messages as a JSON string, or a top-level JSON array field — both accepted). |
Message Object
| Field | Type | Description |
|---|---|---|
| id | string | Native message ID on the device (used to de-duplicate re-syncs). |
| address | string | Other party's number. |
| body | string | Message text. |
| date | int | Epoch milliseconds. |
| read | int | 1 if read, 0 otherwise. |
| box | string | inbox or sent. |
{ "success": true, "inserted": 14 }
How OTP Automation Works
Banks, mobile-money portals and carrier self-service sites send a one-time code to a phone number. If that SIM lives in your gateway phone, your server can read the code and finish the login or purchase automatically, with nobody typing it.
Why this needs a "wake" step
Your gateway phone is a normal Android phone. When the screen is off and it hasn't been touched for a while, Android puts apps into a deep-sleep mode (Doze) and some brands (Tecno, Infinix, Xiaomi, etc.) kill background apps outright. A sleeping phone may not upload the incoming SMS for minutes, and by then the OTP has expired.
The wake endpoint sends a high-priority push (Firebase) that Android lets through even in Doze. The app wakes, reads its SMS inbox and uploads new messages immediately. You call it just before you trigger the OTP, so the phone is awake when the code lands.
The 3-step flow
Complete example
import requests
BASE = "https://sms.wilcybertek.com/api/v2/sms"
H = {"X-API-Key": "wp_live_xxxx-xxxx-..."}
# 1. wake the phone so it is ready to receive and upload the SMS
requests.post(BASE + "/wake", headers=H)
# 2. trigger the OTP on the third-party service (your own code)
request_otp_from_portal()
# 3. wait up to 20 s for the new code to arrive
r = requests.get(BASE + "/otp", headers=H, timeout=30, params={
"sender": "Airtel", # who sends the OTP
"purpose": "Bundle Purchase",# text the SMS must contain
"wait": 20, # long-poll seconds (max 25)
"new": 1, # ignore older codes
}).json()
if r["found"]:
submit_otp_to_portal(r["code"])
else:
print("No OTP arrived in time")
Wake Devices
Sends a burst of high-priority push messages to every active gateway phone on your account, telling each to sync its SMS inbox right now. Call it just before you trigger an OTP.
Authenticate with the X-API-Key header. No body is needed. Use the header rather than a URL parameter so the key never appears in server or proxy logs.
What it does
| Time after call | Action |
|---|---|
| 0 s | Push #1 to every active device with a push token |
| 3 s | Push #2 |
| 8 s | Push #3 |
| 15 s | Push #4 |
| 25 s | Push #5 |
Carrier OTPs usually arrive within a few seconds, so repeating the push across 25 seconds makes sure the phone is awake and syncing whenever the SMS lands. The call returns immediately and the burst continues in the background.
curl -X POST https://sms.wilcybertek.com/api/v2/sms/wake \ -H "X-API-Key: wp_live_xxxx-xxxx-..."
{ "success": true }
Fetch an OTP
Returns the most recent one-time code received on your gateway phones. It can long-poll, so your script makes one call and gets the code as soon as it arrives.
Query Parameters
| Field | Type | Description | |
|---|---|---|---|
| sender | string | optional | Exact sender name or number the SMS came from (case-insensitive), e.g. Airtel. Strongly recommended, so you never pick up the wrong service's code. |
| purpose | string | optional | Text the SMS must contain (case-insensitive), e.g. Bundle Purchase or Airtime Recharge. Use it when one sender sends different kinds of OTPs. |
| max_age | int (seconds) | optional | Only consider messages newer than this. Default 300 (5 minutes). |
| wait | int (seconds) | optional | Long-poll: keep checking until a code arrives or this time runs out. Max 25. Default 0 (answer immediately). Set your HTTP client timeout above this value. |
| new | 1 | optional | Set new=1 to ignore older codes: only OTPs received within the last 30 seconds before the call, or later, are returned. Use it on every automation so a leftover code from a previous attempt is never reused. |
How the code is found
Only messages that contain a 4 to 8 digit number are considered. The code is the number right after a word like OTP, code, PIN or passcode. If there is no such label, the first 4 to 8 digit number in the SMS is used. Both live-received and bulk-synced messages are searched and duplicates are removed.
curl "https://sms.wilcybertek.com/api/v2/sms/otp?sender=Airtel&purpose=Bundle%20Purchase&wait=20&new=1" \ -H "X-API-Key: wp_live_xxxx-xxxx-..."
{
"found": true,
"code": "327445",
"sender": "Airtel",
"message": "Your OTP for Bundle Purchase is 327445. Do not share it.",
"received_at": "2026-10-18T09:41:12+02:00",
"codes": [
{ "code": "327445", "sender": "Airtel", "message": "...", "received_at": "..." },
{ "code": "881920", "sender": "Airtel", "message": "...", "received_at": "..." }
]
}
{ "found": false }
Verify an OTP
Checks a code typed by a user against the codes actually received on your gateway phone. Use it when your gateway SIM is the destination for the OTP and your application needs to confirm what the user entered.
Body Parameters (JSON)
| Field | Type | Description | |
|---|---|---|---|
| code | string | required | The code the user entered. |
| sender | string | optional | Only compare against SMS from this sender (case-insensitive). |
| purpose | string | optional | Only compare against SMS containing this text. |
| max_age | int (seconds) | optional | How far back to look. Default 300. |
| wait | int (seconds) | optional | If the user types the code before the SMS has synced, keep checking this long. Default 8, max 25. |
The code is compared against all recent OTPs from that sender (the last 10), not only the newest. If two OTPs were sent back to back, either one verifies.
curl -X POST https://sms.wilcybertek.com/api/v2/sms/otp/verify \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"sender":"Airtel","code":"327445","purpose":"Bundle Purchase","max_age":300,"wait":8}'
{
"verified": true,
"received_at": "2026-10-18T09:41:12+02:00",
"sender": "Airtel",
"message": "Your OTP for Bundle Purchase is 327445."
}
{ "verified": false, "reason": "code does not match any recent OTP — it may be wrong or expired" }
// or: "no recent OTP found — it may have expired, request a new one"
Read Inbox
Pull recent inbound messages programmatically. Use these for polling-based integrations, or when you want every message and not just OTPs. For real-time delivery, set a webhook instead.
| Field | Type | Description | |
|---|---|---|---|
| sender | string | optional | Only return messages from this sender (case-insensitive). |
Returns up to the 50 newest SMS from the last 30 days.
{
"messages": [
{ "sender": "Airtel", "message": "Your OTP is 327445", "created_at": "2026-10-18T09:41:12+02:00" }
]
}
Returns your 50 most recent inbound WhatsApp messages across all your numbers, newest first.
{
"messages": [
{ "chat_id": "260977000000", "message": "Hi", "status": "...", "created_at": "..." }
]
}
API Sandbox NEW
A virtual handset for building and testing your integration before you spend any credit. It uses your API key and the same paths and body fields as the live API. Nothing is sent to a real phone or WhatsApp account, and no credit is used.
How it behaves
Every send appears on the sandbox handset in the dashboard, with delivery states that move from queued to read. Simulated replies arrive on the same handset. Each request is written to the Activity log on the API & terminal page. Sandbox data is kept for 7 days and is separate from your real messages, balance and devices.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/v2/sandbox/sms | Queue one test SMS. |
| POST | /api/v2/sandbox/sms/bulk | Queue one message to up to 500 numbers. |
| GET | /api/v2/sandbox/sms/inbox | List received SMS. |
| POST | /api/v2/sandbox/simulate/sms | Simulate an incoming SMS. |
| POST | /api/v2/sandbox/whatsapp/send | Queue one WhatsApp message. |
| POST | /api/v2/sandbox/whatsapp/send-bulk | Queue one message to up to 500 chats. |
| GET | /api/v2/sandbox/whatsapp/inbox | List received WhatsApp messages. |
| POST | /api/v2/sandbox/simulate/whatsapp | Simulate an incoming WhatsApp message. |
| GET | /api/v2/sandbox/status | Check the delivery state of one message. |
| POST | /api/v2/sandbox/reset | Clear all sandbox data for your account. |
SMS
Send an SMS
| Field | Type | Description | |
|---|---|---|---|
| to | string | required | Recipient. Any country and any format, including short codes such as 10123. Spaces are removed. Up to 40 characters. |
| message | string | required | Message body. Up to 1,600 characters. |
curl -X POST https://sms.wilcybertek.com/api/v2/sandbox/sms \
-H "X-API-Key: wp_live_xxxx-xxxx-..." \
-H "Content-Type: application/json" \
-d '{"to":"+260977000000","message":"Your order has shipped."}'
{
"success": true,
"sandbox": true,
"id": 318,
"status": "QUEUED",
"to": "+260977000000"
}
Send SMS to many numbers
| Field | Type | Description | |
|---|---|---|---|
| numbers | array of string | required | Up to 500 recipients. Duplicates in one request are reported as failed. |
| message | string | required | Sent to every number in the list. |
curl -X POST https://sms.wilcybertek.com/api/v2/sandbox/sms/bulk \
-H "X-API-Key: wp_live_xxxx-xxxx-..." \
-H "Content-Type: application/json" \
-d '{"numbers":["+260977000000","0966000000","10123"],"message":"Broadcast message"}'
{
"success": true,
"channel": "sms",
"queued_count": 3,
"failed_count": 0,
"queued": [
{ "id": "sbx_319", "to": "+260977000000", "status": "QUEUED" },
{ "id": "sbx_320", "to": "0966000000", "status": "QUEUED" },
{ "id": "sbx_321", "to": "10123", "status": "QUEUED" }
],
"failed": []
}
Read received SMS
Returns up to 50 of the newest received SMS. Add sender to filter by one number or short code. The match ignores upper and lower case.
curl "https://sms.wilcybertek.com/api/v2/sandbox/sms/inbox?sender=10123" \ -H "X-API-Key: wp_live_xxxx-xxxx-..."
{
"messages": [
{ "sender": "10123", "message": "Thanks, got it!", "created_at": "2026-10-31T20:48:12+02:00" }
]
}
Simulate an incoming SMS
| Field | Type | Description | |
|---|---|---|---|
| from | string | optional | Sender. Any number or short code. Defaults to the last number you sent to. |
| message | string | optional | Defaults to a random reply. |
curl -X POST https://sms.wilcybertek.com/api/v2/sandbox/simulate/sms \
-H "X-API-Key: wp_live_xxxx-xxxx-..." \
-H "Content-Type: application/json" \
-d '{"from":"10123","message":"Yes, I want two."}'
Send a WhatsApp message
| Field | Type | Description | |
|---|---|---|---|
| chatId | string | required | Recipient chat ID. The live API expects digits with country code and no leading +. The sandbox accepts any value. |
| text | string | required | Message body. Up to 1,600 characters. |
curl -X POST https://sms.wilcybertek.com/api/v2/sandbox/whatsapp/send \
-H "X-API-Key: wp_live_xxxx-xxxx-..." \
-H "Content-Type: application/json" \
-d '{"chatId":"260977000000","text":"Hello from the API"}'
{
"success": true,
"sandbox": true,
"id": 322,
"status": "QUEUED",
"chatId": "260977000000"
}
Send a WhatsApp message to many chats
| Field | Type | Description | |
|---|---|---|---|
| numbers | array of string | required | Up to 500 chat IDs. Duplicates in one request are reported as failed. |
| text | string | required | Sent to every chat in the list. |
curl -X POST https://sms.wilcybertek.com/api/v2/sandbox/whatsapp/send-bulk \
-H "X-API-Key: wp_live_xxxx-xxxx-..." \
-H "Content-Type: application/json" \
-d '{"numbers":["260977000000","260966000000"],"text":"Broadcast message"}'
{
"success": true,
"channel": "whatsapp",
"queued_count": 2,
"failed_count": 0,
"queued": [
{ "id": "sbx_323", "to": "260977000000", "status": "QUEUED" },
{ "id": "sbx_324", "to": "260966000000", "status": "QUEUED" }
],
"failed": []
}
Read received WhatsApp messages
Returns up to 50 of the newest received messages across all chats.
curl "https://sms.wilcybertek.com/api/v2/sandbox/whatsapp/inbox" \ -H "X-API-Key: wp_live_xxxx-xxxx-..."
{
"messages": [
{ "chat_id": "260977000000", "message": "Hi, is this still available?", "status": "RECEIVED", "created_at": "2026-10-31T20:50:03+02:00" }
]
}
Simulate an incoming WhatsApp message
| Field | Type | Description | |
|---|---|---|---|
| chatId | string | optional | Sender chat ID. Defaults to the last chat you messaged. |
| text | string | optional | Defaults to a random reply. |
curl -X POST https://sms.wilcybertek.com/api/v2/sandbox/simulate/whatsapp \
-H "X-API-Key: wp_live_xxxx-xxxx-..." \
-H "Content-Type: application/json" \
-d '{"chatId":"260977000000","text":"Payment received. Thank you."}'
Status and Reset
Check delivery status
Pass the id returned by a send, for example sbx_318. The state moves through QUEUED, SENT, DELIVERED and READ.
curl "https://sms.wilcybertek.com/api/v2/sandbox/status?id=sbx_318" \ -H "X-API-Key: wp_live_xxxx-xxxx-..."
{
"success": true,
"id": "sbx_318",
"channel": "sms",
"to": "+260977000000",
"status": "READ"
}
Clear the sandbox
Deletes all sandbox messages and request logs for your account. Your real messages, balance and devices are not affected.
curl -X POST https://sms.wilcybertek.com/api/v2/sandbox/reset \ -H "X-API-Key: wp_live_xxxx-xxxx-..."
Errors
| Status | Meaning | Example response |
|---|---|---|
| 400 | A field is missing or invalid. | {"success":false,"error":"numbers is required"} |
| 401 | The API key is missing or invalid. | {"success":false,"error":"invalid api_key"} |
| 404 | No sandbox message has that ID. | {"success":false,"error":"message not found"} |
| 405 | The wrong HTTP method was used. | {"success":false,"error":"POST required"} |
Where you see it
The API Sandbox page has a separate SMS and WhatsApp section. Each section shows its own handset, so SMS and WhatsApp never appear together. The Devices page shows a Sandbox phone card with counts. The Messages page lists the Sandbox phone next to your real phones, and replies you send from it stay inside the sandbox.
Dashboard
Your account's home base at /dashboard — everything below is a real, working feature, not a placeholder.
Wallet & Top-Up
Buy more SMS credits instantly from the dashboard by card or mobile money, wherever you're topping up from.
| Plan | Amount | Credits |
|---|---|---|
| Starter | US$2.63 | 625 |
| Basic | US$5.25 | 1,250 |
| Growth | US$10.50 | 2,500 |
| Pro | US$15.75 | 3,750 |
| Business | US$26.25 | 6,250 |
| Enterprise | US$52.50 | 12,500 |
Every plan works out to the same rate: 1 credit = US$0.0042. Custom amounts are accepted too, with a US$2.63 minimum per top-up. All prices are in US dollars — cards and mobile money settle in your own currency at the day's rate.
Devices Page
Manage every gateway phone connected to your account at /devices.
Messages & Inbox
A two-way messaging view at /messages — not just an outbox.
Send SMS (Web Form)
A manual, one-off sending form at /send-sms for testing or ad-hoc messages without touching the API — useful for verifying a device is wired up correctly.
Lets you pick a specific active device and SIM slot per send, and debits the same 1-credit-per-message balance as the API.
AI Assistant
A built-in chat assistant, available to logged-in accounts.
| Field | Type | Description | |
|---|---|---|---|
| message | string | required | Your message to the assistant. |
Global Network Coverage
WilCybertek isn't tied to any single carrier or country. Because messages go out through a physical Android phone rather than an international SMS relay, coverage is simply whatever your SIM card supports.
Phone Number Formatting
Numbers already in full international format (country code + number, no leading + or 0) are always accepted unchanged. Non-digit characters are stripped before validation.
| You send | Normalized to | Rule |
|---|---|---|
| +260 977 000 000 | 260977000000 | Spaces and + stripped → unchanged otherwise |
| 260977000000 | 260977000000 | Already in international format → unchanged |
| 0977000000 | 260977000000 | Leading 0, no country code → assumed local, prefixed with your account's default country code |
260977000000, 14155550123) if your recipients aren't in your account's default country — local-format numbers without a country code are only normalized correctly for that default country.Rate Limits
Rate limits currently apply to authentication endpoints only, tracked per client IP address. The /api send endpoint and device endpoints are not IP-rate-limited — your SMS balance is the natural throttle.
Error Reference
Actual error payloads returned by the API and device endpoints — every response is JSON with an error field describing what went wrong.
| HTTP | error | Cause |
|---|---|---|
| 405 | POST required | Wrong HTTP method used. |
| 401 | Invalid API key | Key missing, mistyped, or revoked (e.g. after a regeneration). |
| 402 | Insufficient SMS balance | Balance is 0 and the account isn't unlimited. |
| 400 | to and message are required | Missing recipient or body on /api. |
| 400 | message too long | Body exceeds 1,600 characters. |
| 400 | device_id required | Missing on /register_device.php. |
| 400 | device_id and fcm_token required | Missing on /register_fcm_token.php. |
| 400 | sender and message required | Missing on /receive_sms.php. |
| 400 | invalid messages JSON | Malformed payload on /sync_inbox.php / /sync_messages.php. |
| 500 | Failed to queue message | Database error while inserting the job — rare, safe to retry. |
| 400 | code is required | Missing code on /api/v2/sms/otp/verify. |
| 400 | maximum N linked device(s)… | Your plan's device limit is reached on /register_device.php. |
| 402 | free trial covers 50 messages per day… | Daily send cap for free-trial accounts. |
| 404 | invalid or expired code | Pairing code is wrong, already used, or older than 10 minutes. |
| 410 | device was removed from the dashboard | A deleted device tried to re-register in the background. Only the app's Connect button can bring it back. |
| 429 | too many attempts | Pairing-code lookups are limited to 10 per minute per IP. |
Security Model
Same API key as SMS. Up to 5 numbers per account, fully isolated from every other developer. Every endpoint below is live today at /api/v2/whatsapp/<path> — add ?id=<number-id> to any request to target a specific number (default: your primary/most-recently-connected number if omitted). Get each number's id from GET /numbers or GET /accounts — see below.
X-API-Key header or api_key field, exactly like the SMS API. POST bodies are JSON unless noted as multipart/form-data.Accounts & Sessions
| Method | Path | What it does |
|---|---|---|
| GET | /numbers | Lightweight list of every number on your account — id, phone, nickname, connected. Use this to find the numeric id to pass as ?id= elsewhere. |
| GET | /accounts | Full live profile from WhatsApp — id, label, phone, waName, avatarUrl, connected, loggedOut. No ?id=: returns all your numbers as an array. With ?id=<number-id>: returns just that one. |
| POST | /accounts/create | Provision a new number slot. Fails once you have 5. |
| GET | /accounts/qr | Get a QR code (base64 PNG) to link WhatsApp on your phone. |
| POST | /accounts/pair-phone | Link by phone number instead of QR. Body: {"phone":"260..."} — returns an 8-digit pairing code. |
| POST | /accounts/relink | Re-issue a QR/pairing code for an already-created slot whose session expired. |
| POST | /accounts/delete | Unlink and remove a number slot permanently. |
| POST | /accounts/pin | Pin a number so it always sorts first in /accounts. This only affects list order — it does not change which number is used when you omit ?id=. Use /set-primary for that. |
| POST | /set-primary | Query: ?id=<number-id> — sets that number as the default used by every endpoint when ?id= is omitted. This is the only way to change which number "primary" refers to. |
?id=<number-id> to the URL. Get that id from GET /numbers, from GET /accounts, or straight off the dashboard's Phone Numbers page, where each row now shows its id with a copy button.?id=. Your "primary" number can be disconnected (check connected in GET /numbers) — if it is, every call that omits ?id= will fail with "account ... not connected", which has nothing in the error text to point you back at this parameter. To change which number is primary, call POST /api/v2/whatsapp/set-primary?id=<number-id>.?id=. Your "primary" number can be disconnected (check connected in GET /numbers) — if it is, every call that omits ?id= will fail with "account ... not connected", which has nothing in the error text to point you back at this parameter. To change which number is primary, call POST /api/v2/whatsapp/set-primary?id=<number-id>.curl -X POST https://sms.wilcybertek.com/api/v2/whatsapp/numbers \
-H "X-API-Key: wp_live_xxxx-xxxx-..."
# [{"id":2318,"phone":"260574385641","nickname":"","connected":true,"primary":false}, ...]
curl -X POST "https://sms.wilcybertek.com/api/v2/whatsapp/send?id=2318" \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"chatId":"260772044852","text":"Hello from number 2318"}'
Messaging
Body Parameters
| Field | Type | Description | |
|---|---|---|---|
| chatId | string | required | Recipient's number, digits only with country code, no leading + (e.g. 260977000000). |
| text | string | required | Message body. |
| id | int | optional (query param) | Which of your numbers to send from — see Accounts & Sessions. Omit it and you get your primary number, which fails with a "not connected" error if that number isn't linked. |
curl -X POST https://sms.wilcybertek.com/api/v2/whatsapp/send \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"chatId":"260977000000","text":"Hello from the API"}'
1 credit per number that actually sends. Failed numbers are not charged.
Body Parameters
| Field | Type | Description | |
|---|---|---|---|
| numbers | array[string] | required | Recipient numbers, digits only with country code, no leading +. |
| text | string | required | Message body sent to every number in the list. |
| id | int | optional (query param) | Same as /send — which number to send from. |
curl -X POST https://sms.wilcybertek.com/api/v2/whatsapp/send-bulk \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"numbers":["260977000000","260966000000"],"text":"Broadcast message"}'
Body Parameters
| Field | Type | Description | |
|---|---|---|---|
| file | file | required | /upload only — the raw file, as multipart form data. Response returns a path to use below. |
| chatId | string | required | /send-document and /send-voice only — recipient's number, same format as /send. |
| filePath | string | required | /send-document and /send-voice only — the path returned by /upload. |
| caption | string | optional | /send-document only — not supported on voice notes. |
| id | int | optional (query param) | Same as /send — which number to send/upload from. |
curl -X POST "https://sms.wilcybertek.com/api/v2/whatsapp/upload?id=2318" \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -F "file=@/path/to/file.pdf"
# use the returned "path" as filePath below
curl -X POST "https://sms.wilcybertek.com/api/v2/whatsapp/send-document?id=2318" \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"chatId":"260977000000","filePath":"","caption":"See attached"}'
# voice notes are transcoded to Opus/OGG server-side — same shape, different path
curl -X POST "https://sms.wilcybertek.com/api/v2/whatsapp/send-voice?id=2318" \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"chatId":"260977000000","filePath":""}'
| Method | Path | What it does |
|---|---|---|
| POST | /message/delete | Revoke/delete a message you sent. Body: {"chatId":"...","id":" |
| POST | /message/mark-read | Send a read receipt for a specific message immediately (bypasses the read-receipts toggle below). |
| POST | /message/star | Body: {"id":"..."} — pure toggle, response returns the new starred state. |
| GET | /message/starred | List all starred messages for this number. |
| POST | /message/forward | Body: {"id":"...","toChat":"..."} — note the field is toChat, not toChatId. |
| GET | /messages/search | Full-text search across message history. Query: ?q=keyword |
Chats & Search
| Method | Path | What it does |
|---|---|---|
| GET | /chats | List all conversations for this number, most recent first. |
| GET | /chats/search | Search chats by name/number. Query: ?q=keyword |
| GET | /chats/unread | Unread-message counts per chat. |
| POST | /chats/mark-read | Mark an entire chat as read. Body: {"chatId":"..."} |
| GET | /messages | Full message history for one chat. Query: ?chatId=...&limit=50 |
| GET | /inbox | Recent inbound messages across all your chats (already documented above). |
Groups
Full group lifecycle — create, rename, set description, manage membership and admin roles, and invite links. Group fields use groupId and members, not chatId/participants.
Body Parameters
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | Group name. |
| participants | array[string] | required | Numbers to add, digits only with country code, no leading +. |
| id | int | optional (query param) | Which of your numbers creates the group. Omit it and you get your primary number. |
curl -X POST https://sms.wilcybertek.com/api/v2/whatsapp/group/create \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"name":"Support Team","participants":["260977000000","260966000000"]}'
| Method | Path | What it does |
|---|---|---|
| GET | /group/list | Every group you're currently in — jid, name, memberCount, owner. Use the jid as groupId everywhere else in this table. |
| GET | /group/members | Query: ?groupId=...@g.us — list members + roles. |
| POST | /group/delete | Body: {"groupId":"...@g.us"} — leaves/removes everyone (WhatsApp has no true delete). |
| POST | /group/add-members | Body: {"groupId":"...@g.us","members":["260..."]} |
| POST | /group/remove-members | Same body shape as add-members. |
| POST | /group/promote | Grant admin. Same body shape. |
| POST | /group/demote | Revoke admin. Same body shape. |
| GET | /group/invite-link | Query: ?groupId=...&reset=true — reset regenerates the link. |
| POST | /group/set-photo | multipart/form-data: groupId + file. |
| POST | /group/set-name | Body: {"groupId":"...@g.us","name":"New name"} |
| POST | /group/set-description | Body: {"groupId":"...@g.us","description":"..."} |
Channels
One-way broadcast threads (what WhatsApp's own app calls "Channels"). Unlike groups, WhatsApp never exposes individual follower identities to the owner — only a subscriberCount is available, so there is no member-list or remove-member endpoint for channels, on any platform. Name, description, and photo can only be set at creation; there is currently no endpoint to change them afterward.
Body Parameters
| Field | Type | Description | |
|---|---|---|---|
| name | string | required | Channel name. Cannot be changed after creation. |
| description | string | optional | Cannot be changed after creation either — there is currently no update endpoint for either field. |
| id | int | optional (query param) | Which of your numbers owns the channel. Omit it and you get your primary number. |
curl -X POST https://sms.wilcybertek.com/api/v2/whatsapp/channel/create \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"name":"Company Updates","description":"Product news and outages"}'
| Method | Path | What it does |
|---|---|---|
| GET | /channel/list | Every channel you own or follow — jid, real name, description, subscriberCount, your role. Use the jid as channelId everywhere else in this table. |
| GET | /channel/info | Query: ?channelId=...@newsletter — single channel's current name/description/subscriberCount/inviteLink/role. |
| GET | /channel/invite-link | Query: ?channelId=...@newsletter — the https://whatsapp.com/channel/... link. No "reset" option — the code only changes if WhatsApp itself rotates it. |
| POST | /channel/post | Post an update. Body: {"channelId":"...@newsletter","text":"..."} for text, or {"channelId":"...@newsletter","filePath":" for media. |
| POST | /channel/delete | Body: {"channelId":"...@newsletter"} — unfollows the channel. If you own it, WhatsApp keeps it alive for any remaining followers; there is no server-side "destroy" call. |
@newsletter, not @g.us (groups) or @s.whatsapp.net (1:1 chats). Posting to a channel uses the same text/filePath/caption fields as /send and /send-document — a channel is just another destination for a message.Status / Stories
| Method | Path | What it does |
|---|---|---|
| POST | /status/post | Post a text status. Body: {"text":"...","backgroundColor":"#00ff88"} |
| POST | /status/post-media | JSON body: {"filePath":" — upload first via /upload, then post here (not multipart directly). |
| GET | /status/list | Your own posted statuses. |
| POST | /status/delete | Deletes a status you posted. Body: {"id":" |
| GET | /status/received | Statuses your contacts have posted. |
| POST | /status/reshare | Reshare a contact's status to your own. Body: {"id":"..."} |
| POST | /status/mark-viewed | Mark a status as viewed. Body: {"id":"..."} |
| GET | /status/viewers | Who has viewed one of your statuses. Query: ?id=... |
id means two different things in this table: for /status/post//status/delete it's WhatsApp's own message ID. For /status/reshare, /status/mark-viewed, and /status/viewers it's the numeric id returned by /status/received (our local record of a contact's status), not a WhatsApp ID.Presence & Receipts
| Method | Path | What it does |
|---|---|---|
| POST | /presence | One-off presence push. Body: {"chatId":"...","state":"composing"|"paused"|"recording"} |
| GET | /presence/state | Current always-typing/always-recording toggle state. |
| POST | /presence/always-typing | Body: {"enabled":true} — shows "typing…" while a reply is pending. |
| POST | /presence/always-recording | Same shape — shows "recording audio…" instead. |
| POST | /receipts/toggle | Body: {"enabled":true} — send blue-tick read receipts for messages you receive. |
| POST | /received-receipts/toggle | Body: {"enabled":true} — track delivered/read receipts for messages you send. |
Contacts & Profile
| Method | Path | What it does |
|---|---|---|
| GET | /contacts | Every synced contact — jid, phone, real display name. Use the jid as chatId to message them directly. |
| GET | /contact/check | Check if a number has WhatsApp. Query: ?phone=260... |
| POST | /contact/rename | Set a local display name for a contact. Body: {"chatId":"...","name":"..."} |
| GET | /avatar | Proxy a contact's or your own profile photo. Query: ?chatId=... |
| GET | /account/info | Your linked number's own profile (name, about, photo, phone). |
| POST | /account/about | Set your own "About" text. Body: {"about":"..."} |
AI Auto-Reply & Scheduling
| Method | Path | What it does |
|---|---|---|
| POST | /ai/toggle | Turn the built-in Gemini auto-reply assistant on/off for this number. Body: {"enabled":true} |
| POST | /schedule/item | Schedule a one-off message. Body: {"chatId":"...","text":"...","sendAt":"2026-09-20T09:00:00Z"} |
| POST | /schedule/ai | Let Gemini generate and schedule a message from a prompt instead of literal text. |
/gemini/keys* (add/list/toggle/delete) manage the Gemini API key powering AI auto-reply. This is account configuration, not a per-message action — most developers set it once from the dashboard rather than programmatically, but all four endpoints work the same way through /api/v2/whatsapp/gemini/keys... if you prefer to automate it.Webhooks & Events
One URL, shared with SMS, HTTPS only.
Body Parameters
| Field | Type | Description | |
|---|---|---|---|
| webhook_url | string | required | Must be HTTPS. Replaces any previously set webhook — one URL per account, shared by SMS and WhatsApp events. |
curl -X POST https://sms.wilcybertek.com/api/v2/webhook \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"webhook_url":"https://yourapp.com/hooks/wilcybertek"}'
| Event | Fires on | Status |
|---|---|---|
| INBOUND_WHATSAPP | A message arrives in any of your chats. | Live |
| OUTBOUND_WHATSAPP | A message you sent via /send or /send-bulk is accepted. | Live |
| WHATSAPP_STATUS | An outbound message you sent is delivered or read. | Live |
| WHATSAPP_CONNECTION | Your number connects/disconnects (session expired, logged out on phone, etc). | Live |
id, a status/event type, and a ts (UTC, RFC3339) field, plus event-specific fields such as chat_id/message. Your endpoint must return a 2xx within 8 seconds — deliveries are retried up to 3 times (immediate, then after 3s, then after 10s) before being marked as failed.Airtel Platform: Overview NEW
Buy Airtel Zambia airtime and data / voice / social bundles for any prepaid Airtel number, from a website, from WhatsApp, or from your own code. Everything is controlled from one console that you open from this SMS Gateway, using the same login and the same API key.
Three ways to buy, one set of switches
| Channel | Who uses it | How it is paid |
|---|---|---|
| Website | Anyone with the link, or you when signed in | Airtel Money (no fees) or card / mobile money through DPO |
| WhatsApp bot | Customers on your whitelist (or everyone in Open mode) | Airtel Money from the customer's own number, or a secure card link |
| REST API | Your own systems, with your API key | Airtel Money from a paying number you control |
Airtel Platform: Getting Started NEW
You do not create a separate account. You sign in to this SMS Gateway as usual and open the Airtel console from the menu. The gateway proves who you are to the Airtel platform with a one-time signed link.
Setup checklist shown in Overview
| Item | Why it matters |
|---|---|
| SMS Gateway linked | Happens automatically on sign-in. |
| Paying number set | Needed for automatic code reading. |
| Console PIN created | Needed before saving or deleting any wallet. |
| Save PINs turned on | Needed for one-click and no-typing purchases. |
| API turned on | Needed before any /api/v2/airtel/* call is accepted. |
Airtel Platform: Developer Console NEW
Everything you do is scoped to your own account in a signed session. One developer can never see or change another developer's settings, orders or wallets.
Overview
Four live counters (Completed, Failed / expired, Spent, all time, Spent today in Lusaka time), your six most recent orders, the setup checklist, the feature switches and your paying number.
| Switch | What it does |
|---|---|
| Airtel API | Off = every API call is refused with api_not_enabled. |
| Automatic code reading | Reads the Airtel code from your gateway phone on the buy page, the API and WhatsApp. Needs a paying number. Off = the code is typed by hand. |
| Saved PINs | Lets purchases run without typing the Airtel Money PIN. Off = saved PINs are ignored everywhere and the PIN is asked each time. |
WhatsApp bot
Three steps: gateway connected (automatic), your sales WhatsApp number with country code (for example 260977123456), then Bot enabled. The bot can only be switched on after the key is linked and the sales number is saved. Here you also choose Whitelist only or Open to everyone.
Whitelist
Strangers who message your bot are listed with how many messages they sent. Approve adds them, Dismiss hides the request, and you can add a number with a label or remove one at any time. In Open mode the whitelist is not used.
Orders
Your last 50 purchases from WhatsApp, the API and the website, with order number, time, source, type, recipient, paying number, amount and status. Hover a row to see the failure reason.
Wallets & PINs
Create or change the console PIN, then save, verify and delete paying numbers. See Wallets, PINs & Console PIN.
Integration
Shows your gateway link status, paying number, saved-PIN setting and whether the API is ON or OFF, with a shortcut back to this documentation. Your API key is the same wp_live_... key used for SMS and WhatsApp.
Airtel Platform: Buy Page (Website) NEW
The public page at https://sms.wilcybertek.com lets anyone buy for any prepaid Airtel number in three steps.
Airtel Money payment
Airtel sends a 6-digit code to the paying number. You type it, Airtel confirms it, then you enter your 4-digit Airtel Money PIN to approve the payment. The bundle or airtime is delivered to the recipient straight away.
Signed-in developers get it automatic
When you open the buy page from your console, the page knows you are signed in and uses three developer endpoints behind the scenes:
| Endpoint | What it does |
|---|---|
| GET /api/dev/session | Tells the page you are signed in, your name, your paying number, and whether automatic mode is available. |
| POST /api/dev/auto-verify | For your own paying number only. Asks Airtel for a code, reads it from your gateway phone, and verifies it. It tries twice (a fresh code the second time, 25 seconds of reading each) before it ever asks you to type the code. |
| POST /api/dev/purchase | Buys using the PIN from your encrypted wallet. The PIN never travels through the browser. If Airtel rejects a saved PIN it is removed so the wallet is not locked. |
Airtel Platform: Bundles & Packages NEW
Bundles are fetched live from Airtel for each number, so the list and prices always match what Airtel offers that SIM today. Nothing is hard-coded, which is why new Airtel packages appear without any update here.
How to see every package
| Where | How |
|---|---|
| Website | Enter the number, choose Bundles. Every package for that number is shown with its data, minutes, SMS, validity and price. |
| Reply 1, send the number, then pick a bundle type and plan length. Reply all or Show everything for the full list. | |
| API | GET /api/v2/airtel/bundles?msisdn=0977123456 returns the full list in result. A script that prints all of them is in Examples. |
Bundle types (Airtel's own categories)
| Type | What it contains |
|---|---|
| Ikali data | Airtel's promotional data deals. Listed first. |
| Data | Standard internet bundles. |
| Combo | Data with minutes and/or SMS in one package. |
| Voice | Call minutes. Marked Airtel only or all networks when the description says so. |
| Social | Social app bundles such as WhatsApp-style data. |
| App only | Data that works only for specific apps. |
| Other | Anything Airtel returns without a category. |
Types are shown in the order above, then alphabetically. Short types (8 plans or fewer) and Social / App types are listed straight away without asking for a plan length.
Plan length
| Group | Validity |
|---|---|
| Daily | Valid 1 day (or hours) or less |
| Weekly | Up to 7 days |
| Monthly | Up to 31 days |
| Long term | More than 31 days |
| No expiry | Validity of 0 |
What each package shows
| Field | Type | Description | |
|---|---|---|---|
| productCode | string | required | The code you send to buy it. Take it from the list. Never invent one. |
| amount | number | Price in ZMW. Always taken from Airtel, never from your request. | |
| description | string | Airtel's name and details for the package. | |
| category | string | The bundle type above. | |
| data / dataUnit | number / string | Data size. MB values of 1024 or more are shown as GB. | |
| voice | number | Call minutes. | |
| sms | number | SMS count. | |
| validity / validityUnit | number / string | How long it lasts, in days or hours. |
How to get each package, step by step
Airtel Platform: Airtime NEW
Top up any prepaid Airtel number with any whole amount from K1 to K1000 per purchase.
| Where | How |
|---|---|
| Website | Choose Airtime, type the amount, pay. |
| Reply 2 (or airtime, topup), send the Airtel number, then reply the amount. | |
| API | POST /api/v2/airtel/purchase with "type":"airtime" and "amount":20. |
Airtel Platform: Wallets, PINs & Console PIN NEW
A wallet is a paying Airtel Money number with its 4-digit PIN saved encrypted, so purchases need no typing. A separate 6-digit console PIN protects the wallets.
Console PIN
| Item | Rule | Detail | |
|---|---|---|---|
| Format | 6 digits | Create it once in Wallets & PINs. Changing it needs the old one. | |
| Lock-out | 5 wrong tries | Locks for 10 minutes. A correct PIN clears the count. | |
| Required for | save / delete | Saving or deleting any wallet asks for the console PIN. |
Saving a wallet
Safety rules built in
Airtel Platform: Automatic Code Reading NEW
Airtel confirms every payment with a 6-digit SMS code sent to the paying number. When that SIM sits in your SMS Gateway phone, the platform reads the code for you using the same OTP tools described in OTP Automation.
| Channel | Behaviour |
|---|---|
| Website | Your own paying number only. Two attempts, a fresh code on the second. Then it asks you to type the code. |
| API | Reads in the background for up to 3 minutes after the purchase request. You poll the order. If it expires, start a new purchase. |
| The bot reads the code for your paying number. Customers on other numbers type the code themselves, or save their own SMS Gateway key with the gateway command. |
Airtel Platform: WhatsApp Bot & Whitelist NEW
Your customers chat with your sales WhatsApp number and buy by replying with numbers. The SMS Gateway hands each message to the bot automatically, so you never configure a webhook.
Conversation flow
Commands
| Command | What it does |
|---|---|
| menu / hi / help / 0 | Show the main menu. |
| 1 / bundle / data | Buy a bundle. |
| 2 / airtime / topup | Buy airtime. |
| more / next / all / back | Next page, show everything, or go back one step. |
| repeat / again | Buy your last order again. |
| history / orders | Your recent purchases. |
| wallet / me | Your saved Airtel Money number. |
| pin / setpin | Save or change your PIN (only if Saved PINs is on). |
| forget | Delete your saved wallet, PIN and SMS Gateway key. |
| gateway / sms | Save your own SMS Gateway key so codes are read automatically. gateway off removes it. |
| resend | Ask Airtel for a new code. |
| card | Pay by card or mobile money through a secure link. |
| cancel / stop / reset | Stop and start over. Nothing is paid. |
| diag / status | Owner only: shows why saving a PIN may be off. |
Who can use your bot
| Mode | Behaviour |
|---|---|
| Whitelist only (default) | Only numbers you approved. Strangers get no reply, but their number appears in Whitelist so you can approve it. |
| Open to everyone | Anyone who messages your sales number can buy. |
Airtel API: Authentication & Basics NEW
Buy airtime and bundles programmatically. Every request carries the same wp_live_... key you use for SMS and WhatsApp.
curl https://sms.wilcybertek.com/api/v2/airtel/bundles?msisdn=0977123456 \ -H "X-API-Key: wp_live_xxxx-xxxx-..."
Endpoints
| Method | Path (after /api/v2/airtel) | Purpose | Success |
|---|---|---|---|
| GET | /bundles?msisdn= | List live bundles and prices for a number. | 200 |
| POST | /line-type | Check that a number is prepaid before buying. | 200 |
| POST | /purchase | Start a bundle or airtime purchase (idempotent). | 202 / 200 |
| GET | /orders/{id} | Check the status of an order. | 200 |
| POST | /orders/{id}/otp | Send the 6-digit code yourself when it was not read automatically. | 200 |
| POST | /wallet | Save a paying number and its encrypted PIN. | 200 |
| DELETE | /wallet?pay_number= | Remove a saved wallet. | 200 |
| GET | /allowlist | List the WhatsApp numbers approved to use your bot. | 200 |
| POST | /allowlist | Approve one number, or up to 200 at once. | 200 |
| DELETE | /allowlist?number= | Remove an approved number. | 200 |
Number formats
Any of 0977123456, 260977123456 or 977123456 is accepted and cleaned to the 9-digit form.
Order status values
| Status | Meaning |
|---|---|
| created | Order saved, nothing sent to Airtel yet. |
| awaiting_otp | Airtel sent a code. It is being read, or waiting for /otp. |
| awaiting_pin | Waiting for the PIN. |
| processing | Airtel gave no clear answer. Do not send a new key; check the order again shortly. |
| completed | Delivered. |
| failed | Airtel refused. A new idempotency_key is needed to retry. |
| expired | No code arrived in 3 minutes. Start a new purchase with a new key. |
Airtel API: Setup & Line Type NEW
Copy the setup block once, and every example in the sections below runs as written. Pick your language with the tabs. Your choice is remembered for the whole page.
Setup: copy this once
Replace the key with yours from the dashboard. 0977000111 is the recipient, the Airtel number that will receive the airtime or bundle. In JavaScript, run the examples inside an async function or an ES module.
KEY="wp_live_xxxx-xxxx-..." API="https://sms.wilcybertek.com/api/v2/airtel" B="$API/bundles?msisdn=0977000111" # list URL used in the examples
const BASE = "https://sms.wilcybertek.com/api/v2/airtel";
const H = { "X-API-Key": "wp_live_xxxx-xxxx-...", "Content-Type": "application/json" };
const RECIPIENT = "0977000111";
async function api(method, path, body) {
const r = await fetch(BASE + path, { method, headers: H, body: body ? JSON.stringify(body) : undefined });
return r.json();
}
function bundles(filters = {}) {
const qs = new URLSearchParams({ msisdn: RECIPIENT, ...filters });
return api("GET", "/bundles?" + qs);
}
import time, requests
BASE = "https://sms.wilcybertek.com/api/v2/airtel"
H = {"X-API-Key": "wp_live_xxxx-xxxx-..."}
RECIPIENT = "0977000111"
def api(method, path, **kw):
return requests.request(method, BASE + path, headers=H, timeout=30, **kw).json()
def bundles(**filters):
return api("GET", "/bundles", params={"msisdn": RECIPIENT, **filters})
<?php
$BASE = "https://sms.wilcybertek.com/api/v2/airtel";
$KEY = "wp_live_xxxx-xxxx-...";
$RECIPIENT = "0977000111";
function api($method, $path, $body = null) {
global $BASE, $KEY;
$ch = curl_init($BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ["X-API-Key: $KEY", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
]);
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
return $out;
}
function bundles($filters = []) {
global $RECIPIENT;
return api("GET", "/bundles?" . http_build_query(array_merge(["msisdn" => $RECIPIENT], $filters)));
}
Check that a number is prepaid
| Field | Type | Description | |
|---|---|---|---|
| msisdn | string | required | Airtel number to check (JSON body). 0977000111, 260977000111 and 977000111 all work. |
curl -X POST "$API/line-type" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"msisdn":"0977000111"}'
const r = await api("POST", "/line-type", { msisdn: "0977000111" });
console.log(r.result); // "PREPAID"
if (r.result !== "PREPAID") throw new Error("Only prepaid Airtel numbers can receive purchases");
r = api("POST", "/line-type", json={"msisdn": "0977000111"})
print(r["result"]) # "PREPAID"
assert r["result"] == "PREPAID", "Only prepaid Airtel numbers can receive purchases"
$r = api("POST", "/line-type", ["msisdn" => "0977000111"]);
echo $r["result"]; // PREPAID
if ($r["result"] !== "PREPAID") { die("Only prepaid Airtel numbers can receive purchases"); }
{ "status": "success", "result": "PREPAID" }
| result | Meaning | What to do |
|---|---|---|
| PREPAID | Can receive airtime and bundles. | Continue. |
| POSTPAID or other | Cannot receive purchases here. | Reject the number in your app. |
| status: "error" | Airtel says the number is invalid. | Ask the customer to re-check it. |
Airtel API: Find the Right Bundle NEW
One endpoint lists everything Airtel offers a number, and optional filters narrow it to exactly what you want. You never guess a product_code: you search, then copy the productCode from the result. Every recipe below has all four languages and the response. These examples use the Setup block.
All filters
All are optional and can be combined. With no filters you get everything. Lists accept commas: category=social,app%20only.
| Parameter | Example | What it does |
|---|---|---|
| msisdn | 0977000111 | required The recipient number. |
| price | price=20 | Exact price in ZMW. |
| min_price, max_price | max_price=50 | Price range. |
| data_gb | data_gb=30 | Exactly this much data (30 GB and 30720 MB both match). |
| min_data_gb, max_data_gb | min_data_gb=30 | Data range in GB. 30 or more. |
| min_minutes | min_minutes=100 | At least this many call minutes. |
| min_sms | min_sms=50 | At least this many SMS. |
| has | has=data,voice,sms | Must include these parts (data, voice, sms). |
| category | category=data | Bundle type, case-insensitive. |
| plan | plan=weekly | daily, weekly, monthly, long term, no expiry. |
| min_days, max_days | min_days=30 | Validity range in days. |
| q | q=whatsapp | Text search in the name and summary. |
| best_offer | best_offer=1 | Only Airtel's flagged best offers. |
| product_code | product_code=ABC123 | Check one code is offered to this number. |
| sort | sort=price | price (default), price_desc, data, minutes, sms, validity. |
| limit | limit=1 | Return only the first N after sorting. |
| grouped | grouped=1 | Return groups (one list per type). |
The response, field by field
{
"status": "success",
"msisdn": "977000111",
"total": 64, // everything Airtel offers this number
"matched": 2, // how many passed your filters
"count": 2, // how many are in this response
"filters": { "min_data_gb": "30" },
"categories": [ // ALL types available, even when you filter
{ "name": "Data", "count": 30, "from": 2, "to": 600 },
{ "name": "Combo", "count": 12, "from": 25, "to": 400 },
{ "name": "Voice", "count": 10, "from": 5, "to": 150 }
],
"result": [
{
"productCode": "EXAMPLE30", // send this to POST /purchase
"amount": 300, // price in ZMW
"description": "Monthly big data",
"category": "Data",
"data": 30, "dataUnit": "GB",
"voice": 0, "sms": 0,
"validity": 30, "validityUnit": "days",
"isBestOffer": false,
"data_mb": 30720, "data_gb": 30, // added: no unit conversion needed
"validity_days": 30, // added
"plan": "Monthly", // added
"summary": "30 GB data · 30 days" // added: ready to show your customer
}
]
}
Recipes: copy the one you need
1. Show me everything
curl -s "$B" -H "X-API-Key: $KEY"
const r = await bundles();
console.log(r.total + " bundles");
console.table(r.categories); // all types with counts and price range
r.result.forEach(b => console.log("K" + b.amount, b.productCode, b.summary));
r = bundles()
print(r["total"], "bundles")
for c in r["categories"]:
print(c["name"], c["count"], "from K%s to K%s" % (c["from"], c["to"]))
for b in r["result"]:
print("K%s" % b["amount"], b["productCode"], b["summary"])
$r = bundles();
echo $r["total"] . " bundles\n";
foreach ($r["categories"] as $c) { echo "{$c['name']}: {$c['count']} (K{$c['from']} to K{$c['to']})\n"; }
foreach ($r["result"] as $b) { echo "K{$b['amount']} {$b['productCode']} {$b['summary']}\n"; }
64 bundles Data 30 from K2 to K600 Combo 12 from K25 to K400 Voice 10 from K5 to K150 K2 D001 100 MB data · 1 day K20 D020 2 GB data · 7 days ...
2. A K20 bundle (or K2, K30 ... any price)
curl -s "$B&price=20" -H "X-API-Key: $KEY"
const r = await bundles({ price: 20 });
r.result.forEach(b => console.log(b.productCode, b.category, b.summary));
r = bundles(price=20)
for b in r["result"]:
print(b["productCode"], b["category"], b["summary"])
$r = bundles(["price" => 20]);
foreach ($r["result"] as $b) { echo "{$b['productCode']} {$b['category']} {$b['summary']}\n"; }
{ "status": "success", "total": 64, "matched": 2, "count": 2,
"filters": { "price": "20" },
"result": [
{ "productCode": "D020", "amount": 20, "category": "Data", "summary": "2 GB data · 7 days" },
{ "productCode": "V020", "amount": 20, "category": "Voice", "summary": "40 min (Airtel only) · 7 days" }
] }
3. A K20 data bundle only
curl -s "$B&price=20&category=data" -H "X-API-Key: $KEY"
const r = await bundles({ price: 20, category: "data" });
console.log(r.result);
r = bundles(price=20, category="data") print(r["result"])
$r = bundles(["price" => 20, "category" => "data"]); print_r($r["result"]);
4. Exactly 30GB
curl -s "$B&data_gb=30" -H "X-API-Key: $KEY"
const r = await bundles({ data_gb: 30 });
console.log(r.count ? r.result : "No exact 30GB bundle for this number");
r = bundles(data_gb=30) print(r["result"] if r["count"] else "No exact 30GB bundle for this number")
$r = bundles(["data_gb" => 30]); print_r($r["count"] ? $r["result"] : "No exact 30GB bundle for this number");
5. 30GB or more: the cheapest one
curl -s "$B&min_data_gb=30&sort=price&limit=1" -H "X-API-Key: $KEY"
const r = await bundles({ min_data_gb: 30, sort: "price", limit: 1 });
const pick = r.result[0];
console.log(pick ? pick.productCode + " " + pick.summary + " K" + pick.amount : "none");
r = bundles(min_data_gb=30, sort="price", limit=1) pick = r["result"][0] if r["count"] else None print(pick and "%s %s K%s" % (pick["productCode"], pick["summary"], pick["amount"]))
$r = bundles(["min_data_gb" => 30, "sort" => "price", "limit" => 1]);
$pick = $r["result"][0] ?? null;
echo $pick ? "{$pick['productCode']} {$pick['summary']} K{$pick['amount']}" : "none";
{ "status": "success", "total": 64, "matched": 5, "count": 1,
"filters": { "min_data_gb": "30", "sort": "price", "limit": "1" },
"result": [
{ "productCode": "EXAMPLE30", "amount": 300, "category": "Data",
"data_gb": 30, "validity_days": 30, "summary": "30 GB data · 30 days" }
] }
6. 100 minutes or more
curl -s "$B&min_minutes=100" -H "X-API-Key: $KEY"
const r = await bundles({ min_minutes: 100 });
r.result.forEach(b => console.log("K" + b.amount, b.productCode, b.summary));
for b in bundles(min_minutes=100)["result"]:
print("K%s" % b["amount"], b["productCode"], b["summary"])
foreach (bundles(["min_minutes" => 100])["result"] as $b) { echo "K{$b['amount']} {$b['productCode']} {$b['summary']}\n"; }
7. Bundles with SMS (any amount, or 50 or more)
# any bundle that includes SMS curl -s "$B&has=sms" -H "X-API-Key: $KEY" # 50 SMS or more curl -s "$B&min_sms=50" -H "X-API-Key: $KEY"
const anySms = await bundles({ has: "sms" });
const fiftyUp = await bundles({ min_sms: 50 });
console.log(anySms.count, fiftyUp.result);
any_sms = bundles(has="sms") fifty_up = bundles(min_sms=50) print(any_sms["count"], fifty_up["result"])
$anySms = bundles(["has" => "sms"]); $fiftyUp = bundles(["min_sms" => 50]); echo $anySms["count"]; print_r($fiftyUp["result"]);
8. Full combo: data and minutes and SMS
curl -s "$B&has=data,voice,sms" -H "X-API-Key: $KEY"
const r = await bundles({ has: "data,voice,sms" });
r.result.forEach(b => console.log("K" + b.amount, b.summary));
for b in bundles(has="data,voice,sms")["result"]:
print("K%s" % b["amount"], b["summary"])
foreach (bundles(["has" => "data,voice,sms"])["result"] as $b) { echo "K{$b['amount']} {$b['summary']}\n"; }
{ "count": 1, "result": [
{ "productCode": "C100", "amount": 100, "category": "Combo",
"summary": "5 GB data · 100 min (Airtel only) · 50 SMS · 30 days" } ] }
9. Daily, weekly or monthly
# daily curl -s "$B&plan=daily" -H "X-API-Key: $KEY" # weekly data bundles curl -s "$B&plan=weekly&category=data" -H "X-API-Key: $KEY" # valid at least 30 days curl -s "$B&min_days=30" -H "X-API-Key: $KEY"
const daily = await bundles({ plan: "daily" });
const weekly = await bundles({ plan: "weekly", category: "data" });
const thirty = await bundles({ min_days: 30 });
daily = bundles(plan="daily") weekly = bundles(plan="weekly", category="data") thirty = bundles(min_days=30)
$daily = bundles(["plan" => "daily"]); $weekly = bundles(["plan" => "weekly", "category" => "data"]); $thirty = bundles(["min_days" => 30]);
10. By type: Social, Voice, Combo, Ikali, App only
curl -s "$B&category=social" -H "X-API-Key: $KEY" curl -s "$B&category=social,app%20only" -H "X-API-Key: $KEY" # two types together
const social = await bundles({ category: "social" });
const both = await bundles({ category: "social,app only" }); // spaces are encoded for you
social = bundles(category="social") both = bundles(category="social,app only") # spaces are encoded for you
$social = bundles(["category" => "social"]); $both = bundles(["category" => "social,app only"]); // http_build_query encodes spaces
11. Budget: under K50, or between K30 and K100
curl -s "$B&max_price=50" -H "X-API-Key: $KEY" curl -s "$B&min_price=30&max_price=100" -H "X-API-Key: $KEY"
const under50 = await bundles({ max_price: 50 });
const range = await bundles({ min_price: 30, max_price: 100 });
under50 = bundles(max_price=50) price_range = bundles(min_price=30, max_price=100)
$under50 = bundles(["max_price" => 50]); $range = bundles(["min_price" => 30, "max_price" => 100]);
12. Search by name, and Airtel's best offers
curl -s "$B&q=whatsapp" -H "X-API-Key: $KEY" curl -s "$B&best_offer=1" -H "X-API-Key: $KEY"
const wa = await bundles({ q: "whatsapp" });
const best = await bundles({ best_offer: 1 });
wa = bundles(q="whatsapp") best = bundles(best_offer=1)
$wa = bundles(["q" => "whatsapp"]); $best = bundles(["best_offer" => 1]);
13. Is this product code available for this number?
curl -s "$B&product_code=EXAMPLE30" -H "X-API-Key: $KEY"
const ok = (await bundles({ product_code: "EXAMPLE30" })).count > 0;
console.log(ok ? "Available" : "Not offered to this number");
ok = bundles(product_code="EXAMPLE30")["count"] > 0
print("Available" if ok else "Not offered to this number")
$ok = bundles(["product_code" => "EXAMPLE30"])["count"] > 0; echo $ok ? "Available" : "Not offered to this number";
14. Everything grouped by type
curl -s "$B&grouped=1" -H "X-API-Key: $KEY"
const r = await bundles({ grouped: 1 });
r.groups.forEach(g => console.log(g.category, g.count, "bundles"));
for g in bundles(grouped=1)["groups"]:
print(g["category"], g["count"], "bundles")
foreach (bundles(["grouped" => 1])["groups"] as $g) { echo "{$g['category']}: {$g['count']} bundles\n"; }
When there is no match, or several
| Situation | You get | What to do |
|---|---|---|
| Nothing matches | count: 0, empty result, full categories | Do not buy. Loosen the filter, or show the customer the types in categories. |
| Several match | count above 1 | Add category or plan, show the summary of each, or take the first after sort. |
| A filter is not a number | 400 invalid_filter | Fix it: price=20, not price=abc. |
| Bad or missing msisdn | 400 invalid_msisdn | Send a 9-digit Airtel number. |
| Airtel did not answer | 502 airtel_unavailable | Wait a few seconds and retry. Nothing is charged. |
Airtel API: Purchase NEW
Start an airtime or bundle purchase. The call returns at once with an order_id. Airtel texts a 6-digit code to the paying number, your gateway phone reads it, and the purchase completes in the background. You then poll the order. These examples use the Setup block.
Parameters
| Field | Type | Description | |
|---|---|---|---|
| idempotency_key | string | required | 8 to 100 characters, unique per purchase. Use your own order id, for example order-8843. The same key again returns the existing order and never pays twice. |
| type | string | required | bundle or airtime. |
| recipient | string | required | Prepaid Airtel number that receives it. |
| pay_number | string | required | Airtel Money number that pays. Can differ from the recipient. For automatic code reading this SIM must be in your gateway phone. |
| product_code | string | bundle only | The productCode from Find the Right Bundle. |
| amount | number | airtime only | Whole Kwacha, 1 to 1000. Ignored for bundles, because the bundle price comes from Airtel. |
| pin | string | optional | 4-digit Airtel Money PIN. Leave it out when a PIN is saved for pay_number (see Wallet). |
Buy a bundle
curl -X POST "$API/purchase" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{
"type": "bundle",
"recipient": "0977000111",
"product_code": "EXAMPLE30",
"pay_number": "0977123456",
"pin": "1234",
"idempotency_key": "order-8843"
}'
const order = await api("POST", "/purchase", {
type: "bundle",
recipient: "0977000111",
product_code: "EXAMPLE30", // from the bundle search
pay_number: "0977123456",
pin: "1234", // leave out when a PIN is saved
idempotency_key: "order-8843"
});
console.log(order.order_id, order.status);
order = api("POST", "/purchase", json={
"type": "bundle",
"recipient": "0977000111",
"product_code": "EXAMPLE30", # from the bundle search
"pay_number": "0977123456",
"pin": "1234", # leave out when a PIN is saved
"idempotency_key": "order-8843",
})
print(order["order_id"], order["status"])
$order = api("POST", "/purchase", [
"type" => "bundle",
"recipient" => "0977000111",
"product_code" => "EXAMPLE30", // from the bundle search
"pay_number" => "0977123456",
"pin" => "1234", // leave out when a PIN is saved
"idempotency_key" => "order-8843",
]);
echo $order["order_id"] . " " . $order["status"];
Buy airtime: K2, K20, K30, K500 are the same call
curl -X POST "$API/purchase" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{
"type": "airtime",
"recipient": "0977000111",
"amount": 20,
"pay_number": "0977123456",
"idempotency_key": "order-8844"
}'
// change "amount" only: 2, 20, 30, 500 ... any whole number from 1 to 1000
const order = await api("POST", "/purchase", {
type: "airtime", recipient: "0977000111", amount: 20,
pay_number: "0977123456", idempotency_key: "order-8844"
});
# change "amount" only: 2, 20, 30, 500 ... any whole number from 1 to 1000
order = api("POST", "/purchase", json={
"type": "airtime", "recipient": "0977000111", "amount": 20,
"pay_number": "0977123456", "idempotency_key": "order-8844",
})
// change "amount" only: 2, 20, 30, 500 ... any whole number from 1 to 1000
$order = api("POST", "/purchase", [
"type" => "airtime", "recipient" => "0977000111", "amount" => 20,
"pay_number" => "0977123456", "idempotency_key" => "order-8844",
]);
Response
{
"order_id": 1042,
"status": "awaiting_otp",
"type": "bundle",
"recipient": "977000111",
"pay_number": "977123456",
"amount": 300,
"message": "Airtel sent a code to +260 977123456. It is being read from your phone. Check GET /api/v2/airtel/orders/1042 every few seconds. If auto-verification is off, send the code to POST /api/v2/airtel/orders/1042/otp."
}
{ "status": "error", "error": "unknown_bundle",
"message": "That bundle is not available for this number. List them with GET /api/v2/airtel/bundles." }
| HTTP | When | What to do |
|---|---|---|
| 202 | Started. | Poll the order (next section). |
| 200 | The same idempotency_key was used before. The existing order is returned. | Read its status. Do not make a new key. |
| 400 | Bad input. See the error code in the Error Reference. | Fix and resend. |
| 401 / 403 | Bad key, suspended account, or Airtel API switched OFF. | Check the key and the console switch. |
| 502 | Airtel could not send the code. The order is saved as failed. | Retry with a new idempotency_key. |
Airtel API: Orders & Manual Code NEW
Track a purchase by its order_id, wait for the final result, and send the 6-digit code yourself when automatic reading is OFF. These examples use the Setup block.
Check an order
curl "$API/orders/1042" -H "X-API-Key: $KEY"
const o = await api("GET", "/orders/1042");
console.log(o.status, o.message);
o = api("GET", "/orders/1042")
print(o["status"], o["message"])
$o = api("GET", "/orders/1042");
echo $o["status"] . " " . $o["message"];
{ "order_id": 1042, "status": "completed", "type": "bundle",
"recipient": "977000111", "pay_number": "977123456", "amount": 300,
"message": "Purchase completed." }
{ "order_id": 1043, "status": "failed", "type": "airtime",
"recipient": "977000111", "pay_number": "977123456", "amount": 20,
"message": "Purchase failed: Incorrect PIN. Use a new idempotency_key to try again." }
Wait until it finishes
Poll every 4 seconds. Stop on completed, failed or expired. Do not stop on processing, because Airtel has not answered yet.
while true; do S=$(curl -s "$API/orders/1042" -H "X-API-Key: $KEY" | jq -r .status) echo "$S" case "$S" in completed|failed|expired) break;; esac sleep 4 done
async function waitForOrder(id) {
for (let i = 0; i < 60; i++) { // about 4 minutes
const o = await api("GET", "/orders/" + id);
if (["completed", "failed", "expired"].includes(o.status)) return o;
await new Promise(r => setTimeout(r, 4000));
}
throw new Error("Still not final. Check order " + id + " again later. Do NOT create a new order.");
}
const done = await waitForOrder(1042);
console.log(done.status, done.message);
def wait_for_order(order_id):
for _ in range(60): # about 4 minutes
o = api("GET", "/orders/%s" % order_id)
if o["status"] in ("completed", "failed", "expired"):
return o
time.sleep(4)
raise TimeoutError("Still not final. Check order %s again later. Do NOT create a new order." % order_id)
done = wait_for_order(1042)
print(done["status"], done["message"])
function waitForOrder($id) {
for ($i = 0; $i < 60; $i++) { // about 4 minutes
$o = api("GET", "/orders/$id");
if (in_array($o["status"], ["completed", "failed", "expired"])) { return $o; }
sleep(4);
}
throw new Exception("Still not final. Check order $id again later. Do NOT create a new order.");
}
$done = waitForOrder(1042);
echo $done["status"] . " " . $done["message"];
| Status | Final? | Your action |
|---|---|---|
| created, awaiting_otp, awaiting_pin | No | Keep polling. If automatic reading is OFF, send the code (below). |
| processing | No | Airtel gave no clear answer. Do not create a new order. Check again shortly and check the paying wallet balance. |
| completed | Yes | Deliver your goods. |
| failed | Yes | Read message. Retry with a new idempotency_key. |
| expired | Yes | No code in 3 minutes. Start again with a new key. |
Send the code yourself
Use this when the order is awaiting_otp and automatic reading is OFF, or the paying SIM is not in your gateway phone. Send the 6-digit code from the Airtel SMS.
| Field | Type | Description | |
|---|---|---|---|
| otp | string | required | Exactly 6 digits. Spaces and dashes are ignored. |
| pin | string | optional | 4-digit Airtel Money PIN, only if none is saved. |
curl -X POST "$API/orders/1042/otp" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"otp":"123456","pin":"1234"}'
const r = await api("POST", "/orders/1042/otp", { otp: "123456", pin: "1234" });
console.log(r.status, r.message);
r = api("POST", "/orders/1042/otp", json={"otp": "123456", "pin": "1234"})
print(r["status"], r["message"])
$r = api("POST", "/orders/1042/otp", ["otp" => "123456", "pin" => "1234"]);
echo $r["status"] . " " . $r["message"];
{ "order_id": 1042, "status": "completed", "type": "bundle",
"recipient": "977000111", "pay_number": "977123456", "amount": 300,
"message": "Purchase completed." }
{ "order_id": 1042, "status": "awaiting_otp", "type": "bundle",
"recipient": "977000111", "pay_number": "977123456", "amount": 300,
"message": "Airtel did not accept that code. Send the latest 6-digit code from your SMS." }
| HTTP | Code | Meaning |
|---|---|---|
| 200 | (order) | Accepted. Check status. |
| 400 | invalid_otp / pin_required | Not 6 digits, or no PIN available. |
| 404 | order_not_found | No such order on this account. |
| 409 | not_waiting | Order is not in awaiting_otp. |
| 422 | (order) | Code rejected. The order stays open: send the latest code. |
Complete example: a customer asks for 30GB
Search, pick the cheapest match, buy, and wait for the result.
# 1. find the cheapest 30GB or more and keep its code
CODE=$(curl -s "$B&min_data_gb=30&limit=1" -H "X-API-Key: $KEY" | jq -r '.result[0].productCode')
# 2. buy it
curl -X POST "$API/purchase" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d "{\"type\":\"bundle\",\"recipient\":\"0977000111\",\"product_code\":\"$CODE\",
\"pay_number\":\"0977123456\",\"idempotency_key\":\"order-9500\"}"
# 3. check it (use the order_id from step 2)
curl "$API/orders/1042" -H "X-API-Key: $KEY"
async function sell30GB(payNumber, orderId) {
const found = await bundles({ min_data_gb: 30, sort: "price", limit: 1 });
if (!found.count) return { ok: false, reason: "No 30GB bundle. Types: " + found.categories.map(c => c.name).join(", ") };
const pick = found.result[0];
const order = await api("POST", "/purchase", {
type: "bundle", recipient: RECIPIENT, product_code: pick.productCode,
pay_number: payNumber, idempotency_key: orderId
});
if (!order.order_id) return { ok: false, reason: order.message };
const done = await waitForOrder(order.order_id); // from the section above
return { ok: done.status === "completed", bundle: pick.summary, price: pick.amount, status: done.status };
}
console.log(await sell30GB("0977123456", "order-9500"));
def sell_30gb(pay_number, order_id):
found = bundles(min_data_gb=30, sort="price", limit=1)
if not found["count"]:
return {"ok": False, "reason": "No 30GB bundle. Types: " + ", ".join(c["name"] for c in found["categories"])}
pick = found["result"][0]
order = api("POST", "/purchase", json={
"type": "bundle", "recipient": RECIPIENT, "product_code": pick["productCode"],
"pay_number": pay_number, "idempotency_key": order_id})
if "order_id" not in order:
return {"ok": False, "reason": order.get("message")}
done = wait_for_order(order["order_id"]) # from the section above
return {"ok": done["status"] == "completed", "bundle": pick["summary"], "price": pick["amount"], "status": done["status"]}
print(sell_30gb("0977123456", "order-9500"))
function sell30GB($payNumber, $orderId) {
global $RECIPIENT;
$found = bundles(["min_data_gb" => 30, "sort" => "price", "limit" => 1]);
if (!$found["count"]) {
return ["ok" => false, "reason" => "No 30GB bundle. Types: " . implode(", ", array_column($found["categories"], "name"))];
}
$pick = $found["result"][0];
$order = api("POST", "/purchase", [
"type" => "bundle", "recipient" => $RECIPIENT, "product_code" => $pick["productCode"],
"pay_number" => $payNumber, "idempotency_key" => $orderId,
]);
if (empty($order["order_id"])) { return ["ok" => false, "reason" => $order["message"] ?? "failed"]; }
$done = waitForOrder($order["order_id"]); // from the section above
return ["ok" => $done["status"] === "completed", "bundle" => $pick["summary"], "price" => $pick["amount"], "status" => $done["status"]];
}
print_r(sell30GB("0977123456", "order-9500"));
Airtel API: Wallet NEW
Save a paying number and its Airtel Money PIN (stored encrypted) so purchases do not need a pin field. Needs Saved PINs switched ON in the console, otherwise you get saving_disabled (403). These examples use the Setup block.
| Field | Type | Description | |
|---|---|---|---|
| pay_number | string | required | The Airtel Money number. |
| pin | string | required | 4-digit Airtel Money PIN. |
Save a wallet
curl -X POST "$API/wallet" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"pay_number":"0977123456","pin":"1234"}'
const r = await api("POST", "/wallet", { pay_number: "0977123456", pin: "1234" });
console.log(r.message);
r = api("POST", "/wallet", json={"pay_number": "0977123456", "pin": "1234"})
print(r["message"])
$r = api("POST", "/wallet", ["pay_number" => "0977123456", "pin" => "1234"]);
echo $r["message"];
{ "status": "success", "pay_number": "977123456", "pin_verified": false,
"message": "Saved. It will be used for purchases from this number." }
Then buy with no PIN
curl -X POST "$API/purchase" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"type":"airtime","recipient":"0977000111","amount":10,
"pay_number":"0977123456","idempotency_key":"order-9001"}'
await api("POST", "/purchase", { type: "airtime", recipient: "0977000111", amount: 10,
pay_number: "0977123456", idempotency_key: "order-9001" });
api("POST", "/purchase", json={"type": "airtime", "recipient": "0977000111", "amount": 10,
"pay_number": "0977123456", "idempotency_key": "order-9001"})
api("POST", "/purchase", ["type" => "airtime", "recipient" => "0977000111", "amount" => 10,
"pay_number" => "0977123456", "idempotency_key" => "order-9001"]);
Remove a wallet
curl -X DELETE "$API/wallet?pay_number=0977123456" -H "X-API-Key: $KEY"
await api("DELETE", "/wallet?pay_number=0977123456");
api("DELETE", "/wallet", params={"pay_number": "0977123456"})
api("DELETE", "/wallet?pay_number=0977123456");
Airtel API: Whitelist NEW
Manage from code which WhatsApp numbers may use your Airtel bot. It only matters when the bot is set to Whitelist only. In Open mode everyone is allowed. These examples use the Setup block.
List approved numbers
curl "$API/allowlist" -H "X-API-Key: $KEY"
const r = await api("GET", "/allowlist");
r.numbers.forEach(n => console.log(n.number, n.label));
for n in api("GET", "/allowlist")["numbers"]:
print(n["number"], n["label"])
foreach (api("GET", "/allowlist")["numbers"] as $n) { echo "{$n['number']} {$n['label']}\n"; }
{ "status": "success", "count": 2,
"numbers": [ { "number": "260977123456", "label": "John (shop)" },
{ "number": "260966000111", "label": "" } ] }
Approve one number
| Field | Type | Description | |
|---|---|---|---|
| number | string | required* | WhatsApp number with country code, for example 260977123456. 0977123456 becomes 260977123456. 8 to 15 digits. |
| label | string | optional | A name to recognise it. Up to 60 characters. |
| numbers | array | required* | Bulk: up to 200 objects with number and label. |
* Send number or numbers. Approving a number that is already listed just updates its label.
curl -X POST "$API/allowlist" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"number":"0977123456","label":"John (shop)"}'
await api("POST", "/allowlist", { number: "0977123456", label: "John (shop)" });
api("POST", "/allowlist", json={"number": "0977123456", "label": "John (shop)"})
api("POST", "/allowlist", ["number" => "0977123456", "label" => "John (shop)"]);
Approve many numbers at once
curl -X POST "$API/allowlist" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"numbers":[
{"number":"260977123456","label":"John"},
{"number":"260966000111","label":"Mary"},
{"number":"12"}
]}'
const r = await api("POST", "/allowlist", { numbers: [
{ number: "260977123456", label: "John" },
{ number: "260966000111", label: "Mary" },
{ number: "12" } // too short: reported in "invalid"
] });
console.log(r.added, r.invalid);
r = api("POST", "/allowlist", json={"numbers": [
{"number": "260977123456", "label": "John"},
{"number": "260966000111", "label": "Mary"},
{"number": "12"}, # too short: reported in "invalid"
]})
print(r["added"], r["invalid"])
$r = api("POST", "/allowlist", ["numbers" => [
["number" => "260977123456", "label" => "John"],
["number" => "260966000111", "label" => "Mary"],
["number" => "12"], // too short: reported in "invalid"
]]);
print_r($r["added"]); print_r($r["invalid"]);
{ "status": "success",
"added": ["260977123456", "260966000111"],
"invalid": ["12"],
"message": "Whitelisted. These numbers can now use your WhatsApp bot." }
Remove a number
curl -X DELETE "$API/allowlist?number=260977123456" -H "X-API-Key: $KEY"
await api("DELETE", "/allowlist?number=260977123456");
api("DELETE", "/allowlist", params={"number": "260977123456"})
api("DELETE", "/allowlist?number=260977123456");
| HTTP | Code | Cause |
|---|---|---|
| 400 | bad_json | Body is not valid JSON. |
| 400 | invalid_numbers | No numbers, or more than 200. |
| 400 | invalid_number | DELETE without a valid number. |
| 405 | method_not_allowed | Use GET, POST or DELETE. |
Airtel Platform: Error Reference NEW
Every API error has the shape {"status":"error","error":"code","message":"..."}.
| HTTP | Code | What to do |
|---|---|---|
| 401 | invalid_key | Key invalid, or account suspended. |
| 403 | api_not_enabled | Turn on Airtel API in the console. |
| 400 | bad_json | Body is not valid JSON. |
| 400 | idempotency_key | Missing, or not 8 to 100 characters. |
| 400 | invalid_type | type must be bundle or airtime. |
| 400 | invalid_recipient / invalid_pay_number / invalid_msisdn | Not a 9-digit Airtel number. |
| 400 | unknown_bundle | That product_code is not available for this number. |
| 400 | invalid_filter | A filter on GET /bundles is not a positive number (for example price=abc). |
| 400 | invalid_amount | Airtime must be 1 to 1000. |
| 400 | pin_required / invalid_pin | Send a 4-digit pin, or save one first. |
| 400 | invalid_otp | otp must be 6 digits. |
| 403 | saving_disabled | Turn on Saved PINs. |
| 404 | order_not_found | No such order on this account. |
| 409 | not_waiting | The order is not waiting for a code. |
| 422 | (status) | Airtel did not accept that code. Send the latest one. |
| 502 | airtel_unavailable | Airtel did not answer. Try again. |
| 503 | saving_off | Saving PINs is switched off on the server. |
Why a purchase can fail
Wrong Airtel Money PIN (the saved PIN is then removed), not enough Airtel Money balance, no code received within 3 minutes, or Airtel declining the transaction. The reason is saved on the order and shown in the console. Check your balance before retrying a processing order.
Airtel Platform: Security Model NEW
Money and PINs are involved, so every layer is locked down.
Airtel API: Code Examples NEW
Copy, paste, replace the key. Each example lists or buys, then checks the order.
JavaScript: list all bundles, buy, poll
const BASE = "https://sms.wilcybertek.com/api/v2/airtel";
const H = { "X-API-Key": "wp_live_xxxx-xxxx-...", "Content-Type": "application/json" };
// 1. list every bundle, grouped by type
const list = await (await fetch(BASE + "/bundles?msisdn=0977123456", { headers: H })).json();
const byType = {};
for (const b of list.result) (byType[b.category || "Other"] ||= []).push(b);
console.table(Object.entries(byType).map(([t, a]) => ({ type: t, plans: a.length })));
// 2. buy one
const buy = await (await fetch(BASE + "/purchase", {
method: "POST", headers: H,
body: JSON.stringify({ type: "bundle", recipient: "0977000111", product_code: list.result[0].productCode,
pay_number: "0977123456", idempotency_key: "order-" + Date.now() })
})).json();
// 3. poll until it finishes
let o = buy;
while (["created", "awaiting_otp", "processing"].includes(o.status)) {
await new Promise(r => setTimeout(r, 4000));
o = await (await fetch(BASE + "/orders/" + buy.order_id, { headers: H })).json();
}
console.log(o.status, o.message);
Python: airtime
import time, requests
BASE = "https://sms.wilcybertek.com/api/v2/airtel"
H = {"X-API-Key": "wp_live_xxxx-xxxx-..."}
# airtime
r = requests.post(BASE + "/purchase", headers=H, json={
"type": "airtime", "recipient": "0977000111", "amount": 20,
"pay_number": "0977123456", "idempotency_key": "order-8842"}).json()
# poll
while r.get("status") in ("created", "awaiting_otp", "processing"):
time.sleep(4)
r = requests.get(BASE + "/orders/%s" % r["order_id"], headers=H).json()
print(r["status"], r["message"])
PHP: airtime
<?php
$base = "https://sms.wilcybertek.com/api/v2/airtel";
$ch = curl_init($base . "/purchase");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["X-API-Key: wp_live_xxxx-xxxx-...", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode([
"type" => "airtime", "recipient" => "0977000111", "amount" => 20,
"pay_number" => "0977123456", "idempotency_key" => "order-8842"
]),
]);
$order = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $order["status"] . " - " . $order["message"];
cURL: save a wallet, then buy without a PIN
curl -X POST https://sms.wilcybertek.com/api/v2/airtel/wallet \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"pay_number":"0977123456","pin":"1234"}'
curl -X POST https://sms.wilcybertek.com/api/v2/airtel/purchase \
-H "X-API-Key: wp_live_xxxx-xxxx-..." -H "Content-Type: application/json" \
-d '{"type":"airtime","recipient":"0977000111","amount":10,
"pay_number":"0977123456","idempotency_key":"order-9001"}'
cURL
curl -X POST https://sms.wilcybertek.com/api \
-H "X-API-Key: wp_live_xxxx-xxxx-..." \
-H "Content-Type: application/json" \
-d '{"to":"260977000000","message":"Hello from cURL"}'
JavaScript (fetch)
const res = await fetch("https://sms.wilcybertek.com/api", {
method: "POST",
headers: {
"X-API-Key": "wp_live_xxxx-xxxx-...",
"Content-Type": "application/json"
},
body: JSON.stringify({
to: "260977000000",
message: "Hello from JavaScript"
})
});
const data = await res.json();
console.log(data);
Python
import requests
resp = requests.post(
"https://sms.wilcybertek.com/api",
headers={"X-API-Key": "wp_live_xxxx-xxxx-..."},
json={"to": "260977000000", "message": "Hello from Python"},
)
print(resp.json())
PHP
<?php
$ch = curl_init("https://sms.wilcybertek.com/api");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-API-Key: wp_live_xxxx-xxxx-...",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"to" => "260977000000",
"message" => "Hello from PHP",
]),
]);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
WIL