https://waestaos.waestaltd.comBulk SMS API
Send SMS from a customer's prepaid credits, from your own system. New here? Read API.md first (keys, errors, rate limits, retries). Machine-readable: openapi.json.
Key: wsk_... · Base: https://waestaos.waestaltd.com/api/v1/sms
How it works
- A customer has an SMS account: prepaid credits in two separate wallets, a sender name, and API keys. One credit sends one message part (about 160 characters).
- Uganda credits pay for messages to Uganda and are priced in UGX. International credits pay for messages to Kenya and Tanzania and are priced in USD. They are never converted into each other: Uganda credits cannot send to Kenya, and the other way round.
- Every message goes out from our shared sender ID and starts with the customer's name, for example
Benofy: Your code is 4821. That name counts towards the message length. - We send to Uganda, Kenya and Tanzania. Numbers in any other country are refused and never charged.
- Credits are reserved before sending. Invalid, repeated or unsupported numbers are refused and never charged. A message that fails or is not delivered gets its credits back automatically.
- Credits arrive when the customer pays a bundle invoice (portal: *SMS credits → Buy credits*). Each bundle buys one kind of credit:
GET /api/v1/sms/bundlessays which (wallet).
What a message costs
| Message (with the name added) | Parts per recipient |
|---|---|
| up to 160 characters (standard characters) | 1 |
| 161 to 306 characters | 2 (then 153 per part) |
| contains emoji or non-Latin letters | 70 characters per part, then 67 |
Credits per part depend on where the message goes:
| Destination | Credits per message part |
|---|---|
| Uganda (+256) | 1 |
| Kenya (+254) | see GET /sms/rates (currently 1 International credit) |
| Tanzania (+255) | see GET /sms/rates (currently 1 International credit) |
credits used = Σ (parts × the credits per part of each accepted recipient's country), taken from that country's own wallet. For example one 1-part message to a Ugandan number and one to a Kenyan number uses 1 Uganda credit and 1 International credit. The reply tells you credits_used, credits_used_by_wallet, parts_per_message and a by_country split. A message can be at most 640 characters including the name. The rates can change: read them with GET /api/v1/sms/rates rather than hard-coding them.
Numbers and countries
- Write numbers with the country code (
+254712345678,254712345678or00254712345678) and they are used exactly as given. - A number written the local way (
0712 345 678, or712345678) is read as belonging to your account's default country (set by Waesta on your SMS account;GET /sms/balanceandGET /sms/ratesshow it). A Kenyan client's default is Kenya; a Ugandan client's is Uganda. - If you send to several countries from one system, always send the country code, or set
countryon each call. - Numbers are checked exactly: after the country code there must be nine digits starting with a mobile prefix (Uganda
7, Kenya1or7, Tanzania6or7). A stray leading0after the code (+254 0712...) is dropped; a number that is too short, too long or starts with the wrong digit comes back asInvalid phone numberand is not charged. Writing the same number two ways (0772345678and+256772345678) counts as a duplicate. - A number from any other country comes back in
rejectedwith the reasonWe cannot send to this country, and is not charged.
Rates
GET /api/v1/sms/rates
{ "ok": true, "default_country": "UG",
"countries": { "UG": { "name": "Uganda", "wallet": "local", "credits_per_part": 1 }, "KE": { "name": "Kenya", "wallet": "international", "credits_per_part": 1 }, "TZ": { "name": "Tanzania", "wallet": "international", "credits_per_part": 1 } } }
Only countries we can send to today are listed. wallet is local (Uganda credits) or international (International credits): the wallet that country draws from.
Get a key
- Staff: *SMS → Customers → Turn on SMS for a client*, set the sender name, sell a bundle.
- Create a key: the customer in the portal (*SMS credits → Connect your system*), or staff on the customer's SMS page. It is shown once.
Send messages
POST /api/v1/sms/send
| Field | Required | Description |
|---|---|---|
to | yes | One number or a list of up to 1,000. Accepts 0772345678, +256772345678, 256772345678. |
message | yes | The text. Do not add your own name at the start: we add it. |
country | no | UG, KE or TZ: the country to read numbers written without a country code in (for example 0712 345 678) for this call only. Without it your account's default country is used. Numbers that carry their country code are never changed. |
Header Idempotency-Key: <your unique id> (letters, numbers, . _ - :, up to 80 characters) is optional but recommended: if you retry after a timeout, the same key returns the first result and is not charged again.
curl -X POST https://waestaos.waestaltd.com/api/v1/sms/send \
-H "Authorization: Bearer wsk_YOUR_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-ready" \
-d '{"to":["0772345678","+256701234567","abc"],"message":"Your order is ready"}'
Reply 200:
{
"ok": true,
"batch_id": 12,
"accepted": 2,
"rejected": [ { "number": "abc", "reason": "Invalid phone number" } ],
"credits_used": 2,
"credits_used_by_wallet": { "uganda": 2, "international": 0 },
"parts_per_message": 1,
"by_country": { "UG": { "messages": 2, "credits": 2 } },
"balance": 998,
"balance_international": 40,
"replayed": false
}
acceptedare queued (the first 200 go out at once, the rest within minutes).rejectedlists numbers not sent and not charged (Invalid phone number,Duplicate in this request,We cannot send to this country ...).by_countryshows how many messages went to each country and the credits they used.balanceis your Uganda credits left andbalance_internationalyour International credits left.replayed: truemeans this was a repeat of an earlier call with the sameIdempotency-Key. A replayed reply shows the first call's result, withbalanceas it is now (not as it was then). Use a newIdempotency-Keyfor a different message: reusing a key with a different message returns the old result and sends nothing.
Not enough credits, 402. Nothing is sent or charged:
{ "ok": false, "error": "Not enough SMS credits: this needs 5 and the balance is 3.", "balance": 3, "balance_international": 40 }
The message names the wallet that ran short (Not enough International SMS credits: ... for Kenya and Tanzania). Both balances are in the reply. If one send mixes countries and either wallet is short, nothing is sent and nothing is charged.
Check balance
GET /api/v1/sms/balance
{ "ok": true, "balance": 998, "balance_international": 40, "sender_name": "Benofy", "status": "active", "default_country": "UG", "average_daily_use": 120.5, "estimated_days_left": 8.3, "estimated_days_left_international": null }
average_daily_use and estimated_days_left use the last 30 days of sending and are null until there is enough history. Use estimated_days_left to warn your own team before you run out (customers are also reminded by email and SMS at about 7, 3 and 1 days left).
Buy credits (top up from your own dashboard)
Let your users buy credits without leaving your system. Credits are added automatically the moment the payment is confirmed.
GET /api/v1/sms/bundles lists the packs: id, name, wallet (local or international), wallet_name (Uganda or International), credits, price, price_per_credit, currency, and mobile_money_providers (the providers that can pay in that currency).
POST /api/v1/sms/purchases
{ "bundle_id": 3, "provider": "mtn_momo", "phone": "0772345678" }
- With
providerandphone, a payment prompt goes to that phone and the payer approves it with their PIN. - Without them, send the payer to the returned
pay_url(our hosted payment page). - Repeating the same request within 30 minutes continues the open purchase instead of raising a second invoice.
amountis the invoice total (tax included when it applies).
{ "ok": true, "purchase_id": 41, "status": "pending", "credits": 1000, "amount": "59000.00", "currency": "UGX", "pay_url": "https://waestaos.waestaltd.com/i/ab12...", "balance": 12,
"payment": { "request_id": 9, "status": "pending", "provider": "mtn_momo", "phone_last4": "5678" } }
GET /api/v1/sms/purchases/{purchase_id} returns the same shape. Poll every few seconds until status is credited (then balance includes the new credits). payment.status can be pending, processing, completed, failed, timeout or cancelled; on a failure start the purchase again. Credits are added only once, and only for a fully paid invoice.
Callbacks: get told when credits arrive
Instead of polling, set an https callback address under SMS credits → Connect your system. We POST JSON to it when:
event | When |
|---|---|
credits.added | A payment was confirmed and credits were added (includes the purchase, whose wallet says which credits, and the new balance and balance_international) |
credits.reversed | A payment was undone and the credits were taken back |
balance.low | The balance fell to your alert level |
ping | You pressed "Send a test event" |
{ "event": "credits.added", "sent_at": "2026-10-02T09:15:00+00:00", "balance": 1012, "balance_international": 0,
"purchase": { "id": 41, "wallet": "local", "status": "credited", "bundle": "Bundle 1000", "credits": 1000, "amount": "50000.00", "currency": "UGX" } }
Every call carries X-Waesta-Event, X-Waesta-Delivery (a unique id: ignore one you have already handled) and X-Waesta-Signature: sha256= followed by the HMAC-SHA256 of the raw body using your signing secret (shown once when you save the address). Check the signature before trusting a call. Reply with any 2xx. Otherwise we retry after 1, 5, 30 minutes, 2 and 12 hours, then give up (the list of recent deliveries shows the outcome). The address must be https and public. Polling GET /api/v1/sms/purchases/{id} still works alongside callbacks.
Delivery status
GET /api/v1/sms/batches/{batch_id}
{
"ok": true,
"batch": { "id": 12, "source": "api", "recipients": 3, "accepted": 2, "rejected": 1, "units": 2, "created_at": "2026-09-28 08:00:00" },
"progress": { "queued": 0, "sent": 1, "delivered": 1, "failed": 0 },
"messages": [
{ "number": "256772345678", "status": "delivered", "error": null, "delivered_at": "2026-09-28 08:00:09" },
{ "number": "256701234567", "status": "sent", "error": null, "delivered_at": null }
]
}
Message status: queued (waiting), sending, sent (handed to the network, no report yet), delivered, failed, skipped, unknown (the network answer was unclear). failed and skipped messages are refunded in full (the credits they cost, which for Kenya and Tanzania are more than one per part). A message that cannot be handed to the network after several tries, or has waited a day, is marked failed and refunded the same way. Only the customer that sent the batch can read it.
Errors
| HTTP | Meaning |
|---|---|
| 401 | Missing, wrong or revoked key |
| 402 | Not enough credits in one of the wallets; balance and balance_international are in the reply |
| 404 | Batch not found for this key |
| 422 | Fix the request: no valid number (or only numbers in countries we cannot send to), empty or too long message, more than 1,000 numbers, suspended account |
| 429 | Too many requests (120 per minute per key; 20 purchases per hour) or too many wrong keys |
| 503 | SMS sending is temporarily unavailable; retry with the same Idempotency-Key |
Examples
// PHP
$r = waesta('POST', '/api/v1/sms/send', $smsKey, ['to' => $numbers, 'message' => $text]); // helper in API.md
if (!$r['ok']) {
// 402: alert the team to top up; 422: fix the request; 429/503: retry later with the same Idempotency-Key
}
// Node.js
const r = await waesta('POST', '/api/v1/sms/send', smsKey, { to: ['0772345678'], message: 'Your code is 4821' });
if (r.status === 402) notifyTeam(`SMS credits low: ${r.balance}`);
Good practice
- Send an
Idempotency-Keybuilt from your own record (for exampleorder-1042-ready) so a retry never sends or charges twice. - Check
balanceorestimated_days_leftdaily and alert your team early. Handle402by queueing the message on your side, not by dropping it. - Send OTP and alerts in their own calls; batch marketing to many numbers in one call.
- Keep the customer name short: it uses up part of every message.