WILCYBERTEK DOCS v2.1
Back to Gateway Dashboard Download App Get API Key
▶ Complete Platform Documentation

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.

Any Network, Any Country TLS Encrypted REST / JSON Free Tier Included

Creating an Account

Every feature on this page — the API, the dashboard, the AI assistant — sits behind a free developer account.

FieldRule
name2–80 characters
emailMust contain "@" and be at least 5 characters; must be unique
passwordAt least 8 characters, with at least one uppercase letter and one digit
New accounts start with 100 free SMS credits — no card or mobile money payment required to test the platform.

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.

You need at least one active gateway device for messages to actually be delivered. Without one, requests are accepted and stored, but nothing leaves the queue.
WilCybertek SMS Gateway v1.0.0
Android 8.0+
Download APK

Setup

1
Install the APK
Download the APK above on the Android phone you want to use as a gateway. Enable "Install from unknown sources" if prompted — expected for apps distributed outside the Play Store.
2
Link with your API key or a pairing code
Open the app and paste either your developer API key from the dashboard, or a one-time pairing code from the Devices page (Generate pairing code, valid 10 minutes, single use). The app verifies it with the server before saving anything, then locks to your account.
3
Grant permissions
The server (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.
4
Start the gateway service
Tap "Start Gateway." The device is auto-registered the first time it syncs, and appears on your Devices page within about 30 seconds.
5
Instant wake via push, polling as fallback
The app registers a push token so new jobs wake it immediately instead of waiting for the next poll cycle. If push delivery fails for any reason, the device still picks the job up on its next poll — nothing is lost.
Disable battery optimization for the app in Android settings and keep the phone plugged in. Aggressive battery savers can pause background polling/push delivery and delay messages.
Dual-SIM phones are supported. Set a default SIM slot per device on the Devices page, or override it per message with the sim_slot field (0 = SIM 1, 1 = SIM 2).
Routing rule: if you don't pass device_id, a job goes to whichever of your active phones polls first, so multiple gateway phones share the load. Pass device_id to pin a message to one phone. A job pinned to a phone that has been offline for more than 2 minutes is automatically released to your other active phones.

What's New & Features

Everything the platform does today, in one place.

Gateway app

One-tap updates
The app shows an "Update available" banner and notification. Updating installs over the old version and keeps your link, device ID and settings. Press Update on the Devices page to send the prompt to one or all phones.
Pairing codes
Link a phone with a one-time 8-character code instead of the long API key. Valid 10 minutes, single use.
Remote Relink, Delete and Uninstall
Reconnect a phone, sign it out, or prompt uninstall straight from the dashboard.
Instant push wake
New jobs wake the phone immediately through a high-priority push. Polling remains as a fallback.
Built to stay alive
Foreground service, wake lock, two independent watchdogs, restart after reboot or update, and guidance for Tecno, Infinix, Xiaomi, Samsung and other aggressive battery savers.
SIM selection
Choose Auto, SIM 1 or SIM 2 in the app, or per message with sim_slot.

Sending & reliability

Multi-phone load balancing
Unpinned messages go to whichever active phone is free. Jobs pinned to an offline phone are released to others automatically.
Automatic retries
Failed sends are retried on the phone and on the server. Stuck jobs are recovered, and the credit is refunded if a message finally fails.
Live two-way inbox
Incoming and sent SMS appear on the Messages page in real time, with reply from the web.
OTP automation
Wake, fetch and verify one-time codes received on your gateway SIM. See the OTP section.

WhatsApp, AI & commerce

WhatsApp numbers
Link by QR or phone code, send text, files and voice notes, run groups, channels and statuses, all on the same API key.
AI auto-reply
Bring your own Gemini, OpenAI, Groq or Claude key. Define persona, working hours and knowledge, with automatic fail-over between keys.
Shop bot
Sell products on WhatsApp with a numbered catalog, orders and mobile money checkout.
Plans & billing
SMS, WhatsApp and Growth plans, plus pay-as-you-go top-ups by mobile money or card.

Airtel airtime & bundles NEW

Airtel Platform
Buy airtime and every Airtel bundle (Ikali, Data, Combo, Voice, Social, App only) from the website, WhatsApp or the API. Read more
One-click sign-in
Open the Airtel console straight from this gateway with a one-time signed link. No second account.
Airtel REST API
Idempotent purchases, live bundle lists, line check, order tracking and manual code entry. API docs
Automatic code reading
Airtel's 6-digit code is read from your gateway phone and verified for you, with a fresh code on retry.
Saved wallets and console PIN
Encrypted PINs protected by a 6-digit console PIN with a 10-minute lock-out.
WhatsApp whitelist
Approve or dismiss people who message your bot, or open it to everyone.
Card and mobile money checkout
DPO secure payment with bank card, MTN Mobile Money or Airtel Money, plus free Airtel Money.
Orders and spend stats
Completed, failed, spent all-time and spent today, plus your last 50 orders.
New features included. Everything marked NEW in this documentation was added in this release: the Airtel platform, its console, the Airtel API, the WhatsApp bot, wallets, automatic code reading and the whitelist. Existing SMS, WhatsApp and OTP features are unchanged.
Updating phones that run the original app: they can't receive update prompts, so install the new APK over them once. Every release after that updates from the dashboard.

Quickstart

Send your first SMS in under 5 minutes.

1
Register & Get Your API Key
Create a free account. Your unique API key (format wp_live_xxxx-xxxx-...) is generated automatically and shown on your dashboard.
2
Connect a Gateway Device
Install the Android app (above) so there's hardware to actually send through.
3
Make Your First API Call
POST a JSON payload with your API key, recipient number, and message.
cURL — Quick Test
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."
  }'
4
Top Up When You Need More Credits
You get 100 credits on signup plus up to 50 free credits every day, capped at 250/month. For higher volume, top up instantly via mobile money — see Wallet & Mobile Money Top-Up.

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:

PriorityLocationExample
1X-API-KeyHTTP header (recommended)
2api_keyJSON body field
3api_keyURL query string
4api_keyForm-encoded body field
HTTP Header — Recommended
X-API-Key: wp_live_a1b2c3d4-e5f6-...
Your API key can be regenerated any time from the dashboard (Regenerate Key button). Doing so immediately invalidates the old key everywhere — update every integration and the gateway app before rotating in production.

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.

POST/api/v2/smsRequires API key · recommended
POST/apiSame handler · legacy, still works

Accepts either application/json or application/x-www-form-urlencoded.

Body Parameters

FieldTypeDescription
tostringrequiredRecipient number, in full international format. Auto-normalized — see Phone Number Formatting.
messagestringrequiredSMS body. Max 1,600 characters.
device_idstringoptionalSend from a specific gateway device instead of your account default.
sim_slotintoptional0 = SIM 1, 1 = SIM 2. Falls back to your account/device default.
Request — JSON
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
}
Response — 200 OK
{
  "success": true,
  "id": 4821,
  "status": "QUEUED"
}
Each successful send debits exactly 1 credit from your balance (skipped entirely for unlimited accounts). If a message later fails delivery, the credit is automatically refunded to your balance.

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.

StatusMeaning
QUEUEDAccepted, waiting for a gateway device to pick it up.
SENDINGA device has claimed the job and is dispatching it via GSM.
SENTConfirmed sent by the device.
DELIVEREDConfirmed delivered (when the network/device reports a delivery receipt).
FAILEDDelivery failed — the spent credit is automatically refunded.
PROCESSINGShown 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.

POST/register_device.phpRequires API key
FieldTypeDescription
device_idstringrequiredStable unique identifier for the phone (e.g. Android ID).
device_namestringoptionalFriendly label shown on the Devices page.
sim_slotsintoptionalNumber of active SIM slots. Defaults to 1.
The first device you ever register is automatically set as your account's default device.
Response — 200 OK
{ "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.

POST/register_fcm_token.phpRequires API key
FieldTypeDescription
device_idstringrequiredMust match a registered device.
fcm_tokenstringrequiredPush token issued by Firebase on the device.
This can be called before /register_device.php finishes — the token is safely stored either way and linked up automatically. Push is best-effort: if it's ever unavailable, normal polling still delivers the job.

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.

GET/get_pending_sms.php?device_id=...POST also accepted
FieldTypeDescription
device_idstringrequiredThe polling device's ID (accepts device as an alias).
api_keystringoptionalOnly 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.

Response — job found
{
  "status": "found",
  "id": "4821",
  "phone": "260977000000",
  "message": "Your order #4821 has shipped.",
  "sim_slot": 0
}
Response — nothing queued
{ "status": "none" }

Delivery Status Report

Called by a gateway device after it attempts to send, to report the outcome back to the platform.

POST/update_status.php
FieldTypeDescription
idintrequiredThe message id returned by /api or fetched via job polling.
statusstringrequiredSENT, FAILED, or DELIVERED. Anything else is recorded as FAILED.
device_idstringoptionalReporting device, for the audit trail.
Reporting FAILED on a message automatically refunds 1 credit to the developer's balance (unless the account is unlimited).

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

POST/receive_sms.phpRequires API key
FieldTypeDescription
senderstringrequiredNumber that sent the SMS.
messagestringrequiredSMS body.
device_idstringoptionalReceiving device.
sim_slotintoptionalWhich 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.

POST/sync_inbox.phpRequires API key
POST/sync_messages.phpRequires API key · alias
FieldTypeDescription
device_idstringrequiredSyncing device (auto-registered if unseen before).
phone_namestringoptionalFriendly device name if not already set.
messagesarrayrequiredArray of message objects (form field messages as a JSON string, or a top-level JSON array field — both accepted).

Message Object

FieldTypeDescription
idstringNative message ID on the device (used to de-duplicate re-syncs).
addressstringOther party's number.
bodystringMessage text.
dateintEpoch milliseconds.
readint1 if read, 0 otherwise.
boxstringinbox or sent.
Response — 200 OK
{ "success": true, "inserted": 14 }
Duplicate syncs are safe — messages are keyed by (device_id, native id, box) and re-inserts are silently skipped.

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

1
Wake the phone
POST /api/v2/sms/wake, immediately before you ask the third-party service to send the OTP.
2
Trigger the OTP
Click "Send code" on the portal, or call its API. The SMS arrives on your gateway SIM.
3
Fetch the code
GET /api/v2/sms/otp?wait=20&new=1 waits up to 20 seconds and returns the code the moment it arrives. Enter it into the portal and you're done.
Verifying a user's code instead? If you are the portal and a customer types a code that was sent to the gateway SIM, use Verify an OTP. It checks their input against recent messages.

Complete example

python
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 only works if the phone has a push token registered (it does once the app has been opened while online) and the app isn't force-stopped. On Tecno/Infinix/Xiaomi phones also set the app to No restrictions in battery settings, otherwise the OS may block even a high-priority push.

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.

POST/api/v2/sms/wakeRequires API key

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 callAction
0 sPush #1 to every active device with a push token
3 sPush #2
8 sPush #3
15 sPush #4
25 sPush #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
curl -X POST https://sms.wilcybertek.com/api/v2/sms/wake \
  -H "X-API-Key: wp_live_xxxx-xxxx-..."
Response — 200 OK
{ "success": true }
success: true means the request was accepted, not that a phone received it. If no device has a push token, nothing is sent and the endpoint still returns success. Check that your device shows Online on the Devices page.
The OTP endpoints also trigger a wake themselves when you use wait, limited to once every 8 seconds per account. Calling /wake first is still best practice because it wakes the phone before the SMS is sent.

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.

GET/api/v2/sms/otpRequires API key

Query Parameters

FieldTypeDescription
senderstringoptionalExact 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.
purposestringoptionalText the SMS must contain (case-insensitive), e.g. Bundle Purchase or Airtime Recharge. Use it when one sender sends different kinds of OTPs.
max_ageint (seconds)optionalOnly consider messages newer than this. Default 300 (5 minutes).
waitint (seconds)optionalLong-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.
new1optionalSet 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
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-..."
Response — code found
{
  "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": "..." }
  ]
}
Response — nothing arrived
{ "found": false }
code is the newest match. codes lists up to 5 recent codes, newest first. This is useful when a portal sends two OTPs back to back (for example airtime and bundle) and you need the other one.
OTPs expire quickly. Keep max_age low and always pair new=1 with wait in automation, so each attempt only accepts a code that belongs to it.

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.

POST/api/v2/sms/otp/verifyRequires API key

Body Parameters (JSON)

FieldTypeDescription
codestringrequiredThe code the user entered.
senderstringoptionalOnly compare against SMS from this sender (case-insensitive).
purposestringoptionalOnly compare against SMS containing this text.
max_ageint (seconds)optionalHow far back to look. Default 300.
waitint (seconds)optionalIf 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
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}'
Response — verified
{
  "verified": true,
  "received_at": "2026-10-18T09:41:12+02:00",
  "sender": "Airtel",
  "message": "Your OTP for Bundle Purchase is 327445."
}
Response — not verified
{ "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"
Rate-limit wrong attempts on your side (for example 5 tries per code). The endpoint tells you whether a code matches, so an unrestricted caller could guess 6-digit codes.

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.

GET/api/v2/sms/inboxRequires API key
FieldTypeDescription
senderstringoptionalOnly return messages from this sender (case-insensitive).

Returns up to the 50 newest SMS from the last 30 days.

Response — 200 OK
{
  "messages": [
    { "sender": "Airtel", "message": "Your OTP is 327445", "created_at": "2026-10-18T09:41:12+02:00" }
  ]
}
GET/api/v2/whatsapp/inboxRequires API key

Returns your 50 most recent inbound WhatsApp messages across all your numbers, newest first.

Response — 200 OK
{
  "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.

MethodEndpointPurpose
POST/api/v2/sandbox/smsQueue one test SMS.
POST/api/v2/sandbox/sms/bulkQueue one message to up to 500 numbers.
GET/api/v2/sandbox/sms/inboxList received SMS.
POST/api/v2/sandbox/simulate/smsSimulate an incoming SMS.
POST/api/v2/sandbox/whatsapp/sendQueue one WhatsApp message.
POST/api/v2/sandbox/whatsapp/send-bulkQueue one message to up to 500 chats.
GET/api/v2/sandbox/whatsapp/inboxList received WhatsApp messages.
POST/api/v2/sandbox/simulate/whatsappSimulate an incoming WhatsApp message.
GET/api/v2/sandbox/statusCheck the delivery state of one message.
POST/api/v2/sandbox/resetClear all sandbox data for your account.
Send your API key in the X-API-Key header. Find it in the dashboard under API Token. Use the same key for sandbox and live requests.

SMS

Send an SMS

POST/api/v2/sandbox/smsTest mode
FieldTypeDescription
tostringrequiredRecipient. Any country and any format, including short codes such as 10123. Spaces are removed. Up to 40 characters.
messagestringrequiredMessage body. Up to 1,600 characters.
Terminal
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."}'
Response: 200 OK
{
  "success": true,
  "sandbox": true,
  "id": 318,
  "status": "QUEUED",
  "to": "+260977000000"
}

Send SMS to many numbers

POST/api/v2/sandbox/sms/bulkTest mode
FieldTypeDescription
numbersarray of stringrequiredUp to 500 recipients. Duplicates in one request are reported as failed.
messagestringrequiredSent to every number in the list.
Terminal
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"}'
Response: 200 OK
{
  "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

GET/api/v2/sandbox/sms/inboxTest mode

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.

Terminal
curl "https://sms.wilcybertek.com/api/v2/sandbox/sms/inbox?sender=10123" \
  -H "X-API-Key: wp_live_xxxx-xxxx-..."
Response: 200 OK
{
  "messages": [
    { "sender": "10123", "message": "Thanks, got it!", "created_at": "2026-10-31T20:48:12+02:00" }
  ]
}

Simulate an incoming SMS

POST/api/v2/sandbox/simulate/smsTest mode
FieldTypeDescription
fromstringoptionalSender. Any number or short code. Defaults to the last number you sent to.
messagestringoptionalDefaults to a random reply.
Terminal
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."}'

WhatsApp

Send a WhatsApp message

POST/api/v2/sandbox/whatsapp/sendTest mode
FieldTypeDescription
chatIdstringrequiredRecipient chat ID. The live API expects digits with country code and no leading +. The sandbox accepts any value.
textstringrequiredMessage body. Up to 1,600 characters.
Terminal
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"}'
Response: 200 OK
{
  "success": true,
  "sandbox": true,
  "id": 322,
  "status": "QUEUED",
  "chatId": "260977000000"
}

Send a WhatsApp message to many chats

POST/api/v2/sandbox/whatsapp/send-bulkTest mode
FieldTypeDescription
numbersarray of stringrequiredUp to 500 chat IDs. Duplicates in one request are reported as failed.
textstringrequiredSent to every chat in the list.
Terminal
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"}'
Response: 200 OK
{
  "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

GET/api/v2/sandbox/whatsapp/inboxTest mode

Returns up to 50 of the newest received messages across all chats.

Terminal
curl "https://sms.wilcybertek.com/api/v2/sandbox/whatsapp/inbox" \
  -H "X-API-Key: wp_live_xxxx-xxxx-..."
Response: 200 OK
{
  "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

POST/api/v2/sandbox/simulate/whatsappTest mode
FieldTypeDescription
chatIdstringoptionalSender chat ID. Defaults to the last chat you messaged.
textstringoptionalDefaults to a random reply.
Terminal
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

GET/api/v2/sandbox/statusTest mode

Pass the id returned by a send, for example sbx_318. The state moves through QUEUED, SENT, DELIVERED and READ.

Terminal
curl "https://sms.wilcybertek.com/api/v2/sandbox/status?id=sbx_318" \
  -H "X-API-Key: wp_live_xxxx-xxxx-..."
Response: 200 OK
{
  "success": true,
  "id": "sbx_318",
  "channel": "sms",
  "to": "+260977000000",
  "status": "READ"
}

Clear the sandbox

POST/api/v2/sandbox/resetTest mode

Deletes all sandbox messages and request logs for your account. Your real messages, balance and devices are not affected.

Terminal
curl -X POST https://sms.wilcybertek.com/api/v2/sandbox/reset \
  -H "X-API-Key: wp_live_xxxx-xxxx-..."

Errors

StatusMeaningExample response
400A field is missing or invalid.{"success":false,"error":"numbers is required"}
401The API key is missing or invalid.{"success":false,"error":"invalid api_key"}
404No sandbox message has that ID.{"success":false,"error":"message not found"}
405The 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.

Going live. The sandbox accepts any recipient format so you can test edge cases. The live API validates recipients and enforces the format in the WhatsApp docs. Bulk and simulate endpoints are sandbox-only, so check the live endpoint list before you switch over.

Dashboard

Your account's home base at /dashboard — everything below is a real, working feature, not a placeholder.

Delivery Stats
Live totals for sent, failed, queued, and processing messages, plus an overall delivery rate percentage.
Paginated Message Log
Your last messages (15 per page) with recipient, truncated message preview, device, status, and timestamp.
Regenerate API Key
POST /dashboard/regen-key — instantly rotates your key and logs the action (with your IP) to the account's security log.
Clear Logs
POST /dashboard/clear-logs — permanently wipes your API log history. This does not affect your credit balance.
Requeue Stuck Messages
POST /dashboard/requeue — resets any FAILED, PENDING, or PROCESSING message back to QUEUED for another delivery attempt.
Daily Free Top-Up
Non-unlimited accounts automatically receive +50 credits the first time they load the dashboard each day.

Wallet & Top-Up

Buy more SMS credits instantly from the dashboard by card or mobile money, wherever you're topping up from.

PlanAmountCredits
StarterUS$2.63625
BasicUS$5.251,250
GrowthUS$10.502,500
ProUS$15.753,750
BusinessUS$26.256,250
EnterpriseUS$52.5012,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.

1
Choose an amount and a payment method
Pay by card or, where available, mobile money — your details are normalized automatically.
2
Approve the payment
Confirm the charge on your card or the collection prompt pushed to your phone.
3
Credits land automatically
Once payment is confirmed, your balance updates without needing to refresh — the dashboard polls the transaction status for you.
Payment confirmations are pushed to the server by the payments provider's webhook the moment a transaction clears — you don't need to do anything beyond approving the prompt.

Devices Page

Manage every gateway phone connected to your account at /devices.

Set Default Device & SIM
Choose which phone (and which SIM slot on it) receives new jobs by default.
Enable / Disable a Device
Temporarily pause a device without deleting it — disabled devices are excluded from the manual Send SMS device list.
Per-Device Sent Count
See how many messages each device has successfully sent.
Last Seen
Timestamp of the device's most recent poll or sync — the quickest way to tell if a phone has gone offline or lost connectivity.
Generate Pairing Code
Creates an 8-character, single-use code valid for 10 minutes. Type it into the app instead of the long API key. It respects your plan's device limit.
Relink
Pushes your current API key to a phone that disconnected itself or whose key was rotated, so it reconnects with no typing. Needs the phone to be online with a push token.
Delete
Removes the device and signs the app out remotely. A deleted device can only return when the key or a pairing code is entered in the app.
Uninstall App
Signs the app out, removes its device-admin lock, and prompts the phone to uninstall. Android never allows silent uninstall, so the phone user taps Uninstall once.

Messages & Inbox

A two-way messaging view at /messages — not just an outbox.

Inbox & Sent Tabs
Browse everything synced from your devices via Receive Inbound SMS and Bulk Message Sync, paginated 30 at a time.
Filter by Device
Narrow the thread list down to a single gateway phone.
Reply From the Web
POST /messages/reply queues an outbound reply through the specified device — the same underlying queue the API and manual Send SMS form use.

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.

POST/aiRequires session login
FieldTypeDescription
messagestringrequiredYour message to the assistant.
This is a dashboard convenience feature reached through your logged-in session — it authenticates differently from the rest of the API and is not callable with your API key. It's for use from the web app, not for integrating into third-party apps.

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.

Any Carrier
Any GSM network that your gateway phone's SIM card is registered on — no carrier allowlist or integration required on our side.
Any Country
Set up a gateway device anywhere in the world with a working SIM and data connection. The platform doesn't restrict devices by region.
Real, Local Delivery
Messages leave on the network's own local rate and reliability — not routed through international aggregators.
Dual-SIM Ready
Run two networks off a single gateway phone and choose the SIM per message with the sim_slot field.

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 sendNormalized toRule
+260 977 000 000260977000000Spaces and + stripped → unchanged otherwise
260977000000260977000000Already in international format → unchanged
0977000000260977000000Leading 0, no country code → assumed local, prefixed with your account's default country code
Always send numbers in full international format (e.g. 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.

Login attempts
5 / 5 min
Registration attempts
3 / 10 min
Password reset requests
3 / 10 min

Error Reference

Actual error payloads returned by the API and device endpoints — every response is JSON with an error field describing what went wrong.

HTTPerrorCause
405POST requiredWrong HTTP method used.
401Invalid API keyKey missing, mistyped, or revoked (e.g. after a regeneration).
402Insufficient SMS balanceBalance is 0 and the account isn't unlimited.
400to and message are requiredMissing recipient or body on /api.
400message too longBody exceeds 1,600 characters.
400device_id requiredMissing on /register_device.php.
400device_id and fcm_token requiredMissing on /register_fcm_token.php.
400sender and message requiredMissing on /receive_sms.php.
400invalid messages JSONMalformed payload on /sync_inbox.php / /sync_messages.php.
500Failed to queue messageDatabase error while inserting the job — rare, safe to retry.
400code is requiredMissing code on /api/v2/sms/otp/verify.
400maximum N linked device(s)…Your plan's device limit is reached on /register_device.php.
402free trial covers 50 messages per day…Daily send cap for free-trial accounts.
404invalid or expired codePairing code is wrong, already used, or older than 10 minutes.
410device was removed from the dashboardA deleted device tried to re-register in the background. Only the app's Connect button can bring it back.
429too many attemptsPairing-code lookups are limited to 10 per minute per IP.

Security Model

Password Hashing
Passwords are hashed with bcrypt before storage — never stored or logged in plaintext.
CSRF Protection
All classic form-based dashboard POSTs (login, register, password reset, key regen, clear logs, requeue, refill, device settings) require a matching csrf_token tied to your session.
API Key Scope
Your API key authorizes sending, device management, and message sync — it does not grant access to the web dashboard session.
Anti-Enumeration
Password reset always returns the same message regardless of whether the email exists, so the endpoint can't be used to discover registered accounts.

WhatsApp

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.

All endpoints accept 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

MethodPathWhat it does
GET/numbersLightweight list of every number on your account — id, phone, nickname, connected. Use this to find the numeric id to pass as ?id= elsewhere.
GET/accountsFull 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/createProvision a new number slot. Fails once you have 5.
GET/accounts/qrGet a QR code (base64 PNG) to link WhatsApp on your phone.
POST/accounts/pair-phoneLink by phone number instead of QR. Body: {"phone":"260..."} — returns an 8-digit pairing code.
POST/accounts/relinkRe-issue a QR/pairing code for an already-created slot whose session expired.
POST/accounts/deleteUnlink and remove a number slot permanently.
POST/accounts/pinPin 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-primaryQuery: ?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.
Targeting a specific number: every endpoint below defaults to your primary/most-recently-connected number. To use a different one, add ?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.
If you have more than one number, always pass ?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>.
If you have more than one number, always pass ?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 — list, then target one
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

POST/api/v2/whatsapp/sendRequires API key

Body Parameters

FieldTypeDescription
chatIdstringrequiredRecipient's number, digits only with country code, no leading + (e.g. 260977000000).
textstringrequiredMessage body.
idintoptional (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
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"}'
POST/api/v2/whatsapp/send-bulkRequires API key

1 credit per number that actually sends. Failed numbers are not charged.

Body Parameters

FieldTypeDescription
numbersarray[string]requiredRecipient numbers, digits only with country code, no leading +.
textstringrequiredMessage body sent to every number in the list.
idintoptional (query param)Same as /send — which number to send from.
cURL — Bulk Send
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"}'
POST/api/v2/whatsapp/uploadmultipart/form-data
POST/api/v2/whatsapp/send-documentRequires API key
POST/api/v2/whatsapp/send-voiceRequires API key

Body Parameters

FieldTypeDescription
filefilerequired/upload only — the raw file, as multipart form data. Response returns a path to use below.
chatIdstringrequired/send-document and /send-voice only — recipient's number, same format as /send.
filePathstringrequired/send-document and /send-voice only — the path returned by /upload.
captionstringoptional/send-document only — not supported on voice notes.
idintoptional (query param)Same as /send — which number to send/upload from.
cURL — Upload, Document, Voice
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":""}'
MethodPathWhat it does
POST/message/deleteRevoke/delete a message you sent. Body: {"chatId":"...","id":""}
POST/message/mark-readSend a read receipt for a specific message immediately (bypasses the read-receipts toggle below).
POST/message/starBody: {"id":"..."} — pure toggle, response returns the new starred state.
GET/message/starredList all starred messages for this number.
POST/message/forwardBody: {"id":"...","toChat":"..."} — note the field is toChat, not toChatId.
GET/messages/searchFull-text search across message history. Query: ?q=keyword

Chats & Search

MethodPathWhat it does
GET/chatsList all conversations for this number, most recent first.
GET/chats/searchSearch chats by name/number. Query: ?q=keyword
GET/chats/unreadUnread-message counts per chat.
POST/chats/mark-readMark an entire chat as read. Body: {"chatId":"..."}
GET/messagesFull message history for one chat. Query: ?chatId=...&limit=50
GET/inboxRecent 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.

POST/api/v2/whatsapp/group/createRequires API key

Body Parameters

FieldTypeDescription
namestringrequiredGroup name.
participantsarray[string]requiredNumbers to add, digits only with country code, no leading +.
idintoptional (query param)Which of your numbers creates the group. Omit it and you get your primary number.
cURL
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"]}'
MethodPathWhat it does
GET/group/listEvery group you're currently in — jid, name, memberCount, owner. Use the jid as groupId everywhere else in this table.
GET/group/membersQuery: ?groupId=...@g.us — list members + roles.
POST/group/deleteBody: {"groupId":"...@g.us"} — leaves/removes everyone (WhatsApp has no true delete).
POST/group/add-membersBody: {"groupId":"...@g.us","members":["260..."]}
POST/group/remove-membersSame body shape as add-members.
POST/group/promoteGrant admin. Same body shape.
POST/group/demoteRevoke admin. Same body shape.
GET/group/invite-linkQuery: ?groupId=...&reset=true — reset regenerates the link.
POST/group/set-photomultipart/form-data: groupId + file.
POST/group/set-nameBody: {"groupId":"...@g.us","name":"New name"}
POST/group/set-descriptionBody: {"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.

POST/api/v2/whatsapp/channel/createRequires API key

Body Parameters

FieldTypeDescription
namestringrequiredChannel name. Cannot be changed after creation.
descriptionstringoptionalCannot be changed after creation either — there is currently no update endpoint for either field.
idintoptional (query param)Which of your numbers owns the channel. Omit it and you get your primary number.
cURL
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"}'
MethodPathWhat it does
GET/channel/listEvery channel you own or follow — jid, real name, description, subscriberCount, your role. Use the jid as channelId everywhere else in this table.
GET/channel/infoQuery: ?channelId=...@newsletter — single channel's current name/description/subscriberCount/inviteLink/role.
GET/channel/invite-linkQuery: ?channelId=...@newsletter — the https://whatsapp.com/channel/... link. No "reset" option — the code only changes if WhatsApp itself rotates it.
POST/channel/postPost an update. Body: {"channelId":"...@newsletter","text":"..."} for text, or {"channelId":"...@newsletter","filePath":"","caption":"..."} for media.
POST/channel/deleteBody: {"channelId":"...@newsletter"} — unfollows the channel. If you own it, WhatsApp keeps it alive for any remaining followers; there is no server-side "destroy" call.
Channel JIDs end in @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

MethodPathWhat it does
POST/status/postPost a text status. Body: {"text":"...","backgroundColor":"#00ff88"}
POST/status/post-mediaJSON body: {"filePath":"","caption":"..."} — upload first via /upload, then post here (not multipart directly).
GET/status/listYour own posted statuses.
POST/status/deleteDeletes a status you posted. Body: {"id":""}
GET/status/receivedStatuses your contacts have posted.
POST/status/reshareReshare a contact's status to your own. Body: {"id":"..."}
POST/status/mark-viewedMark a status as viewed. Body: {"id":"..."}
GET/status/viewersWho 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

MethodPathWhat it does
POST/presenceOne-off presence push. Body: {"chatId":"...","state":"composing"|"paused"|"recording"}
GET/presence/stateCurrent always-typing/always-recording toggle state.
POST/presence/always-typingBody: {"enabled":true} — shows "typing…" while a reply is pending.
POST/presence/always-recordingSame shape — shows "recording audio…" instead.
POST/receipts/toggleBody: {"enabled":true} — send blue-tick read receipts for messages you receive.
POST/received-receipts/toggleBody: {"enabled":true} — track delivered/read receipts for messages you send.

Contacts & Profile

MethodPathWhat it does
GET/contactsEvery synced contact — jid, phone, real display name. Use the jid as chatId to message them directly.
GET/contact/checkCheck if a number has WhatsApp. Query: ?phone=260...
POST/contact/renameSet a local display name for a contact. Body: {"chatId":"...","name":"..."}
GET/avatarProxy a contact's or your own profile photo. Query: ?chatId=...
GET/account/infoYour linked number's own profile (name, about, photo, phone).
POST/account/aboutSet your own "About" text. Body: {"about":"..."}

AI Auto-Reply & Scheduling

MethodPathWhat it does
POST/ai/toggleTurn the built-in Gemini auto-reply assistant on/off for this number. Body: {"enabled":true}
POST/schedule/itemSchedule a one-off message. Body: {"chatId":"...","text":"...","sendAt":"2026-09-20T09:00:00Z"}
POST/schedule/aiLet 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

POST/api/v2/webhookRequires API key

One URL, shared with SMS, HTTPS only.

Body Parameters

FieldTypeDescription
webhook_urlstringrequiredMust be HTTPS. Replaces any previously set webhook — one URL per account, shared by SMS and WhatsApp events.
cURL
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"}'
EventFires onStatus
INBOUND_WHATSAPPA message arrives in any of your chats.Live
OUTBOUND_WHATSAPPA message you sent via /send or /send-bulk is accepted.Live
WHATSAPP_STATUSAn outbound message you sent is delivered or read.Live
WHATSAPP_CONNECTIONYour number connects/disconnects (session expired, logged out on phone, etc).Live
Every webhook POST includes an 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.

Buy page (website)
A 3-step page at sms.wilcybertek.com: enter the number, pick a bundle or airtime, pay with Airtel Money or card.
REST API
Buy airtime and bundles from your own systems with your existing X-API-Key. Safe retries through idempotency keys.
WhatsApp bot
Your customers message your WhatsApp number and buy by replying with numbers. No webhook to configure.
Developer console
Overview, orders, whitelist, wallets and switches. Opened from the SMS Gateway with one click, no second password.
Automatic code reading
Airtel texts a 6-digit code to the paying number. If that SIM is in your gateway phone, the code is read and verified for you.
Saved wallets
Save a paying number and its Airtel Money PIN (encrypted) so purchases need no typing. Protected by a 6-digit console PIN.
Card and mobile money
Customers without Airtel Money can pay on a secure DPO page with a bank card, MTN Mobile Money or Airtel Money.
Whitelist control
Choose who may use your WhatsApp bot: only numbers you approve, or everyone.

Three ways to buy, one set of switches

ChannelWho uses itHow it is paid
WebsiteAnyone with the link, or you when signed inAirtel Money (no fees) or card / mobile money through DPO
WhatsApp botCustomers on your whitelist (or everyone in Open mode)Airtel Money from the customer's own number, or a secure card link
REST APIYour own systems, with your API keyAirtel Money from a paying number you control
Amounts are in Zambian Kwacha (ZMW, shown as K). Recipients must be prepaid Airtel numbers. Postpaid and other networks are refused at the first step.
New: the Airtel platform, its console, the API and the WhatsApp bot are all new in this release. See What's New for the full list.

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.

1
Open the console from the SMS Gateway
In the dashboard menu choose Airtime & Bundles and press Open Airtel Bot. The link is valid for a short time and works once. If you see "expired" or "already used", simply press the button again.
2
Your gateway key links itself
On every sign-in the Airtel platform asks the SMS Gateway, server to server, for your own API key and name. Nothing to paste, the browser never sees the key, and a rotated key is picked up at the next sign-in.
3
Set your paying number
In Overview, enter the Airtel Money number whose SIM is in your gateway phone, for example 0977123456. Only this number is verified automatically.
4
Create your console PIN
In Wallets & PINs create a 6-digit console PIN. It guards every saved Airtel Money PIN.
5
Turn on what you need
In Overview, switch on Airtel API, Automatic code reading and Saved PINs. For WhatsApp, open WhatsApp bot, enter your sales number and switch the bot on.
6
Make your first purchase
Open the Buy page from the console menu, or call the API. Both are explained in the next sections.

Setup checklist shown in Overview

ItemWhy it matters
SMS Gateway linkedHappens automatically on sign-in.
Paying number setNeeded for automatic code reading.
Console PIN createdNeeded before saving or deleting any wallet.
Save PINs turned onNeeded for one-click and no-typing purchases.
API turned onNeeded before any /api/v2/airtel/* call is accepted.
Your console session lasts 8 hours. After that, open the console from the SMS Gateway again. Sign out in the console menu ends it immediately and returns you to the gateway.

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.

SwitchWhat it does
Airtel APIOff = every API call is refused with api_not_enabled.
Automatic code readingReads 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 PINsLets purchases run without typing the Airtel Money PIN. Off = saved PINs are ignored everywhere and the PIN is asked each time.
Every switch controls the website, the API and the WhatsApp bot at the same 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.

1
Enter the Airtel number
Type the number that should receive the purchase (for example 0977123456). The page checks the line first. Only PREPAID numbers continue; postpaid and unknown lines are refused with a clear message.
2
Choose bundle or airtime
Pick Bundles to see the live list for that number, or Airtime to enter an amount. See Bundles & Packages and Airtime.
3
Choose how to pay
Airtel Money has no fees. Card or Mobile Money opens DPO's secure page where you can use a bank card, MTN Mobile Money or Airtel Money. DPO may add a fee, and you enter an email for your receipt.

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:

EndpointWhat it does
GET /api/dev/sessionTells the page you are signed in, your name, your paying number, and whether automatic mode is available.
POST /api/dev/auto-verifyFor 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/purchaseBuys 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.
These three endpoints work only with a signed console session. For your own systems use the REST API with your API key.

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

WhereHow
WebsiteEnter the number, choose Bundles. Every package for that number is shown with its data, minutes, SMS, validity and price.
WhatsAppReply 1, send the number, then pick a bundle type and plan length. Reply all or Show everything for the full list.
APIGET /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)

TypeWhat it contains
Ikali dataAirtel's promotional data deals. Listed first.
DataStandard internet bundles.
ComboData with minutes and/or SMS in one package.
VoiceCall minutes. Marked Airtel only or all networks when the description says so.
SocialSocial app bundles such as WhatsApp-style data.
App onlyData that works only for specific apps.
OtherAnything 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

GroupValidity
DailyValid 1 day (or hours) or less
WeeklyUp to 7 days
MonthlyUp to 31 days
Long termMore than 31 days
No expiryValidity of 0

What each package shows

FieldTypeDescription
productCodestringrequiredThe code you send to buy it. Take it from the list. Never invent one.
amountnumberPrice in ZMW. Always taken from Airtel, never from your request.
descriptionstringAirtel's name and details for the package.
categorystringThe bundle type above.
data / dataUnitnumber / stringData size. MB values of 1024 or more are shown as GB.
voicenumberCall minutes.
smsnumberSMS count.
validity / validityUnitnumber / stringHow long it lasts, in days or hours.

How to get each package, step by step

1
Find it
Search the list. In WhatsApp, send a word like data, social, weekly or an amount such as 20 to filter the plans.
2
Pick it
Website: press the plan. WhatsApp: reply its number (use more for the next page, back to change type). API: copy its productCode.
3
Pay and verify
Confirm the paying number, enter the Airtel code (or let it be read automatically), then approve with the Airtel Money PIN.
4
Delivered
Airtel activates the package on the recipient. The order shows completed in Orders.
The recipient can be a different number from the one that pays. Buy for family or customers from your own Airtel Money.

Airtel Platform: Airtime NEW

Top up any prepaid Airtel number with any whole amount from K1 to K1000 per purchase.

WhereHow
WebsiteChoose Airtime, type the amount, pay.
WhatsAppReply 2 (or airtime, topup), send the Airtel number, then reply the amount.
APIPOST /api/v2/airtel/purchase with "type":"airtime" and "amount":20.
Amounts below 1 or above 1000 are refused (invalid_amount). Amounts are never taken from the customer for bundles; only airtime uses the amount you send.

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

ItemRuleDetail
Format6 digitsCreate it once in Wallets & PINs. Changing it needs the old one.
Lock-out5 wrong triesLocks for 10 minutes. A correct PIN clears the count.
Required forsave / deleteSaving or deleting any wallet asks for the console PIN.

Saving a wallet

1
Switch on Saved PINs
In Overview. Without it saved PINs are not used anywhere.
2
Add the wallet
In Wallets & PINs enter the 9-digit paying number, the 4-digit Airtel Money PIN and your console PIN.
3
Verification happens by use
A new wallet shows not verified until one real payment succeeds with it, then it shows verified.

Safety rules built in

Encrypted at rest
PINs are stored with AES-256-GCM. They are never logged and never sent to the browser.
Rejected PIN is removed
If Airtel rejects a saved PIN, only the PIN is deleted and the number is kept. This protects the wallet from being locked by repeated wrong tries.
Per-developer isolation
Wallets belong to one developer. Nobody else can use or list them.
One payment at a time
A lock per developer and paying number means two requests can never share one Airtel code.
On WhatsApp, customers save their own PIN with the pin command. Those are separate from your wallets and are removed with forget.

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.

1
Airtel sends the code
The platform asks Airtel to text a code to the paying number and keeps a reference for it.
2
Your gateway phone receives it
The phone is woken, the SMS arrives, and the code is read from your inbox.
3
Verified automatically
The code is checked with Airtel. If Airtel rejects it, the platform keeps waiting for the next SMS instead of failing.
4
Purchase completes
The PIN is applied (typed or saved) and the bundle or airtime is bought.
ChannelBehaviour
WebsiteYour own paying number only. Two attempts, a fresh code on the second. Then it asks you to type the code.
APIReads in the background for up to 3 minutes after the purchase request. You poll the order. If it expires, start a new purchase.
WhatsAppThe 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.
Set your paying number before switching on Automatic code reading, otherwise the switch is refused.

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

1
Start
Send menu, hi or help. Reply 1 for a bundle or 2 for airtime, or just send the Airtel number.
2
Choose
Pick the bundle type (with the cheapest price shown), then the plan length, then the plan. For airtime, reply the amount.
3
Pay
Reply yes to pay with Airtel Money, other to pay from a different number, or card for a secure payment link.
4
Verify
Reply the 6-digit code Airtel sent (or it is read automatically), then send the Airtel Money PIN. Reply save to keep it encrypted or once to use it once.

Commands

CommandWhat it does
menu / hi / help / 0Show the main menu.
1 / bundle / dataBuy a bundle.
2 / airtime / topupBuy airtime.
more / next / all / backNext page, show everything, or go back one step.
repeat / againBuy your last order again.
history / ordersYour recent purchases.
wallet / meYour saved Airtel Money number.
pin / setpinSave or change your PIN (only if Saved PINs is on).
forgetDelete your saved wallet, PIN and SMS Gateway key.
gateway / smsSave your own SMS Gateway key so codes are read automatically. gateway off removes it.
resendAsk Airtel for a new code.
cardPay by card or mobile money through a secure link.
cancel / stop / resetStop and start over. Nothing is paid.
diag / statusOwner only: shows why saving a PIN may be off.

Who can use your bot

ModeBehaviour
Whitelist only (default)Only numbers you approved. Strangers get no reply, but their number appears in Whitelist so you can approve it.
Open to everyoneAnyone who messages your sales number can buy.
WhatsApp numbers are 8 to 15 digits with country code, for example 260977123456. Too many wrong codes ends the session and the customer is asked to start again.

Airtel API: Authentication & Basics NEW

Buy airtime and bundles programmatically. Every request carries the same wp_live_... key you use for SMS and WhatsApp.

BASEhttps://sms.wilcybertek.com/api/v2/airtelJSON over HTTPS
bash
curl https://sms.wilcybertek.com/api/v2/airtel/bundles?msisdn=0977123456 \
  -H "X-API-Key: wp_live_xxxx-xxxx-..."
1
Turn the API on
Console, Overview, Airtel API. Until then every call returns api_not_enabled (HTTP 403).
2
Send your key
Put it in the X-API-Key header. The key is checked with the SMS Gateway (cached for 60 seconds). Suspended accounts return invalid_key (401).
3
Use idempotency keys
Every purchase needs a unique idempotency_key of 8 to 100 characters, such as your own order id. Repeating the same key returns the existing order and never pays twice.

Endpoints

MethodPath (after /api/v2/airtel)PurposeSuccess
GET/bundles?msisdn=List live bundles and prices for a number.200
POST/line-typeCheck that a number is prepaid before buying.200
POST/purchaseStart a bundle or airtime purchase (idempotent).202 / 200
GET/orders/{id}Check the status of an order.200
POST/orders/{id}/otpSend the 6-digit code yourself when it was not read automatically.200
POST/walletSave a paying number and its encrypted PIN.200
DELETE/wallet?pay_number=Remove a saved wallet.200
GET/allowlistList the WhatsApp numbers approved to use your bot.200
POST/allowlistApprove one number, or up to 200 at once.200
DELETE/allowlist?number=Remove an approved number.200
All endpoints need X-API-Key and the Airtel API switch turned ON in the console. Every response is JSON. Errors always look like {"status":"error","error":"code","message":"..."}.

Number formats

Any of 0977123456, 260977123456 or 977123456 is accepted and cleaned to the 9-digit form.

Order status values

StatusMeaning
createdOrder saved, nothing sent to Airtel yet.
awaiting_otpAirtel sent a code. It is being read, or waiting for /otp.
awaiting_pinWaiting for the PIN.
processingAirtel gave no clear answer. Do not send a new key; check the order again shortly.
completedDelivered.
failedAirtel refused. A new idempotency_key is needed to retry.
expiredNo code arrived in 3 minutes. Start a new purchase with a new key.
Bundle prices come from Airtel's own list. You choose the product_code, never the price.

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)));
}
You need the Airtel API switch ON in the console, and a valid X-API-Key. Without them every call returns api_not_enabled (403) or invalid_key (401).

Check that a number is prepaid

POST/api/v2/airtel/line-typeRequires API key
FieldTypeDescription
msisdnstringrequiredAirtel 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"); }
Response — 200 OK
{ "status": "success", "result": "PREPAID" }
resultMeaningWhat to do
PREPAIDCan receive airtime and bundles.Continue.
POSTPAID or otherCannot 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.

GET/api/v2/airtel/bundles?msisdn=0977000111Requires API key
Airtime has no lookup. For airtime (K2, K20, K500 ...) skip this page and go to Purchase: send "type":"airtime" and any whole amount from 1 to 1000. Only bundles (data, minutes, SMS, combo, social) need a product_code.

All filters

All are optional and can be combined. With no filters you get everything. Lists accept commas: category=social,app%20only.

ParameterExampleWhat it does
msisdn0977000111required The recipient number.
priceprice=20Exact price in ZMW.
min_price, max_pricemax_price=50Price range.
data_gbdata_gb=30Exactly this much data (30 GB and 30720 MB both match).
min_data_gb, max_data_gbmin_data_gb=30Data range in GB. 30 or more.
min_minutesmin_minutes=100At least this many call minutes.
min_smsmin_sms=50At least this many SMS.
hashas=data,voice,smsMust include these parts (data, voice, sms).
categorycategory=dataBundle type, case-insensitive.
planplan=weeklydaily, weekly, monthly, long term, no expiry.
min_days, max_daysmin_days=30Validity range in days.
qq=whatsappText search in the name and summary.
best_offerbest_offer=1Only Airtel's flagged best offers.
product_codeproduct_code=ABC123Check one code is offered to this number.
sortsort=priceprice (default), price_desc, data, minutes, sms, validity.
limitlimit=1Return only the first N after sorting.
groupedgrouped=1Return groups (one list per type).

The response, field by field

Response — 200 OK (example values; yours are live from Airtel)
{
  "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

Every bundle the number can receive, cheapest first, plus a summary of all types.
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"; }
Output (example)
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)

Change the number to the price you want. Several bundles can cost the same, for example a K20 data bundle and a K20 voice bundle, so check category or summary.
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"; }
Response (example)
{ "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

Combine filters to remove the voice and SMS bundles at the same price.
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

Matches 30 GB and 30720 MB alike. Airtel may not offer an exact size, so an empty result is normal. Try recipe 5.
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

sort=price is the default. limit=1 keeps only the cheapest match. Drop limit to see all matches.
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";
Response (example)
{ "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

The summary says Airtel only or all networks when Airtel's description does.
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"; }
Response (example)
{ "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 is 1 day or less. weekly is up to 7 days. monthly is up to 31 days. Or set exact days with min_days and max_days.
# 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

Type names come from Airtel. Your own response lists them in categories. Case does not matter.
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?

Use it right before buying, if you saved a code earlier. An empty result means it is not offered, and buying it would return unknown_bundle.
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

Good for building a menu. You get groups instead of result.
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

SituationYou getWhat to do
Nothing matchescount: 0, empty result, full categoriesDo not buy. Loosen the filter, or show the customer the types in categories.
Several matchcount above 1Add category or plan, show the summary of each, or take the first after sort.
A filter is not a number400 invalid_filterFix it: price=20, not price=abc.
Bad or missing msisdn400 invalid_msisdnSend a 9-digit Airtel number.
Airtel did not answer502 airtel_unavailableWait a few seconds and retry. Nothing is charged.
Do not hard-code product codes. A code that works for one number may not be offered to another, and Airtel can add or retire bundles at any time. Search right before you buy. The purchase always charges Airtel's current price for the code.

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.

POST/api/v2/airtel/purchaseRequires API key · JSON body

Parameters

FieldTypeDescription
idempotency_keystringrequired8 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.
typestringrequiredbundle or airtime.
recipientstringrequiredPrepaid Airtel number that receives it.
pay_numberstringrequiredAirtel Money number that pays. Can differ from the recipient. For automatic code reading this SIM must be in your gateway phone.
product_codestringbundle onlyThe productCode from Find the Right Bundle.
amountnumberairtime onlyWhole Kwacha, 1 to 1000. Ignored for bundles, because the bundle price comes from Airtel.
pinstringoptional4-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

202 Accepted — started
{
  "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."
}
400 Bad Request — example
{ "status": "error", "error": "unknown_bundle",
  "message": "That bundle is not available for this number. List them with GET /api/v2/airtel/bundles." }
HTTPWhenWhat to do
202Started.Poll the order (next section).
200The same idempotency_key was used before. The existing order is returned.Read its status. Do not make a new key.
400Bad input. See the error code in the Error Reference.Fix and resend.
401 / 403Bad key, suspended account, or Airtel API switched OFF.Check the key and the console switch.
502Airtel could not send the code. The order is saved as failed.Retry with a new idempotency_key.
Safe retries. If your request times out, resend the same idempotency_key. You get the existing order back and are never charged twice. Use a new key only after an order ended as failed or expired.

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

GET/api/v2/airtel/orders/{id}Requires API key
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"];
Response — completed
{ "order_id": 1042, "status": "completed", "type": "bundle",
  "recipient": "977000111", "pay_number": "977123456", "amount": 300,
  "message": "Purchase completed." }
Response — failed
{ "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"];
StatusFinal?Your action
created, awaiting_otp, awaiting_pinNoKeep polling. If automatic reading is OFF, send the code (below).
processingNoAirtel gave no clear answer. Do not create a new order. Check again shortly and check the paying wallet balance.
completedYesDeliver your goods.
failedYesRead message. Retry with a new idempotency_key.
expiredYesNo 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.

POST/api/v2/airtel/orders/{id}/otpRequires API key · JSON body
FieldTypeDescription
otpstringrequiredExactly 6 digits. Spaces and dashes are ignored.
pinstringoptional4-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"];
Response — 200 OK (code accepted, purchase finished)
{ "order_id": 1042, "status": "completed", "type": "bundle",
  "recipient": "977000111", "pay_number": "977123456", "amount": 300,
  "message": "Purchase completed." }
Response — 422 (Airtel rejected the code: send the latest one)
{ "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." }
HTTPCodeMeaning
200(order)Accepted. Check status.
400invalid_otp / pin_requiredNot 6 digits, or no PIN available.
404order_not_foundNo such order on this account.
409not_waitingOrder 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.

POST/api/v2/airtel/walletRequires API key
FieldTypeDescription
pay_numberstringrequiredThe Airtel Money number.
pinstringrequired4-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"];
Response — 200 OK
{ "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

DELETE/api/v2/airtel/wallet?pay_number=0977123456Requires API key
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");
If Airtel rejects a saved PIN during a purchase, the PIN is removed automatically (the number is kept) so repeated wrong tries cannot lock the wallet. Save a new one with this endpoint.

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

GET/api/v2/airtel/allowlistRequires API key
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"; }
Response — 200 OK
{ "status": "success", "count": 2,
  "numbers": [ { "number": "260977123456", "label": "John (shop)" },
               { "number": "260966000111", "label": "" } ] }

Approve one number

POST/api/v2/airtel/allowlistRequires API key · JSON body
FieldTypeDescription
numberstringrequired*WhatsApp number with country code, for example 260977123456. 0977123456 becomes 260977123456. 8 to 15 digits.
labelstringoptionalA name to recognise it. Up to 60 characters.
numbersarrayrequired*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"]);
Response — 200 OK (one bad number does not fail the batch)
{ "status": "success",
  "added": ["260977123456", "260966000111"],
  "invalid": ["12"],
  "message": "Whitelisted. These numbers can now use your WhatsApp bot." }

Remove a number

DELETE/api/v2/airtel/allowlist?number=260977123456Requires API key
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");
HTTPCodeCause
400bad_jsonBody is not valid JSON.
400invalid_numbersNo numbers, or more than 200.
400invalid_numberDELETE without a valid number.
405method_not_allowedUse GET, POST or DELETE.

Airtel Platform: Error Reference NEW

Every API error has the shape {"status":"error","error":"code","message":"..."}.

HTTPCodeWhat to do
401invalid_keyKey invalid, or account suspended.
403api_not_enabledTurn on Airtel API in the console.
400bad_jsonBody is not valid JSON.
400idempotency_keyMissing, or not 8 to 100 characters.
400invalid_typetype must be bundle or airtime.
400invalid_recipient / invalid_pay_number / invalid_msisdnNot a 9-digit Airtel number.
400unknown_bundleThat product_code is not available for this number.
400invalid_filterA filter on GET /bundles is not a positive number (for example price=abc).
400invalid_amountAirtime must be 1 to 1000.
400pin_required / invalid_pinSend a 4-digit pin, or save one first.
400invalid_otpotp must be 6 digits.
403saving_disabledTurn on Saved PINs.
404order_not_foundNo such order on this account.
409not_waitingThe order is not waiting for a code.
422(status)Airtel did not accept that code. Send the latest one.
502airtel_unavailableAirtel did not answer. Try again.
503saving_offSaving 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.

One-time sign-in link
The link from the SMS Gateway is signed, expires quickly and works only once. Your session cookie is HttpOnly, Secure and lasts 8 hours.
Encrypted PINs
AES-256-GCM at rest. Never logged, never sent to the browser.
Console PIN
6 digits, hashed, with a 10-minute lock-out after 5 wrong tries.
Account isolation
Every query is scoped to your developer id. Other developers' data is unreachable.
Idempotent purchases
A repeated key never causes a second payment.
Server-to-server key link
Your SMS Gateway key moves between servers only, protected by a shared internal secret.

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

javascript
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

python
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
<?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

bash
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

bash
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)

javascript
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

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
<?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;