WWaesta Enterprises U Ltd +256750081491 [email protected]
Base address for every call: https://waestaos.waestaltd.com

Bulk 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/bundles says which (wallet).

What a message costs

Credits per part depend on where the message goes:

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, 254712345678 or 00254712345678) and they are used exactly as given.
  • A number written the local way (0712 345 678, or 712345678) is read as belonging to your account's default country (set by Waesta on your SMS account; GET /sms/balance and GET /sms/rates show 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 country on each call.
  • Numbers are checked exactly: after the country code there must be nine digits starting with a mobile prefix (Uganda 7, Kenya 1 or 7, Tanzania 6 or 7). A stray leading 0 after the code (+254 0712...) is dropped; a number that is too short, too long or starts with the wrong digit comes back as Invalid phone number and is not charged. Writing the same number two ways (0772345678 and +256772345678) counts as a duplicate.
  • A number from any other country comes back in rejected with the reason We 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

  1. Staff: *SMS → Customers → Turn on SMS for a client*, set the sender name, sell a bundle.
  2. 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

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
}
  • accepted are queued (the first 200 go out at once, the rest within minutes).
  • rejected lists numbers not sent and not charged (Invalid phone number, Duplicate in this request, We cannot send to this country ...).
  • by_country shows how many messages went to each country and the credits they used. balance is your Uganda credits left and balance_international your International credits left.
  • replayed: true means this was a repeat of an earlier call with the same Idempotency-Key. A replayed reply shows the first call's result, with balance as it is now (not as it was then). Use a new Idempotency-Key for 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 provider and phone, 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.
  • amount is 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": "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

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-Key built from your own record (for example order-1042-ready) so a retry never sends or charges twice.
  • Check balance or estimated_days_left daily and alert your team early. Handle 402 by 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.