POASMS API v1
Base URL https://poasms.com/api/v1. JSON in, JSON out. All amounts in TZS.
Authentication
Create a key under Apps & API keys. Send it as a bearer token (or an X-API-Key header). Keys starting poa_test_ are sandbox keys: messages are validated and logged but never delivered or charged.
Authorization: Bearer poa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Each key belongs to an app, so usage and spend are tracked per app automatically. Rate limit: 300 requests/minute per key.
Send SMS
POST /sms/send — one or many recipients (up to 10,000 per request).
curl -X POST https://poasms.com/api/v1/sms/send \
-H "Authorization: Bearer $POASMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["0712345678", "+255 688 123 456"],
"message": "Habari! Your code is 482913",
"from": "NETPOA",
"type": "otp",
"reference": "login-77812",
"schedule_at": null
}'
| Field | Description |
|---|---|
| to | Required. A number, an array of numbers, or a comma-separated string. 07…, 2557…, +255… accepted. |
| message | Required. Up to 10 parts (≈1,530 GSM characters). |
| from | Approved sender ID. Defaults to the app's default sender. |
| type | transactional (default), otp or promotional. Promotional must contain STOP text and skips opted-out numbers. |
| reference | Your own ID (≤100 chars), returned in status and webhooks. |
| schedule_at | ISO-8601 time to send later. |
| app | Only for workspace-wide keys: which app to bill (created on first use). |
Response 202 Accepted:
{
"data": {
"batch_id": "e5b7e001-…",
"accepted": 2,
"rejected": [{ "to": "123", "reason": "invalid_number" }],
"segments": 1, "encoding": "gsm7",
"price": 32, "currency": "TZS", "balance": 148968,
"messages": [{ "id": "3270ecd1-…", "to": "255712345678", "status": "queued" }]
}
}
Message status
GET /sms/{id}. Statuses: queued → sent → delivered, or failed (not accepted by the network — automatically refunded), undelivered, rejected, expired.
{ "data": { "id": "3270ecd1-…", "to": "255712345678", "status": "delivered", "network": "vodacom",
"segments": 1, "price": 16, "reference": "login-77812", "app": "kurasa",
"sent_at": "2026-10-14T10:02:11+03:00", "delivered_at": "2026-10-14T10:02:15+03:00" } }
List messages
GET /sms?status=&to=&reference=&batch_id=&from_date=&to_date=&per_page=50 — newest first, paginated.
Balance
GET /balance
{ "data": { "balance": 148968, "currency": "TZS", "rate_per_sms": 16, "sms_remaining": 9310, "billing_mode": "prepaid" } }
Usage
GET /usage?from=2026-10-01&to=2026-10-31&group_by=app — group_by is one of app, day, month, status, network, type, sender. App keys only see their own app; workspace keys see all.
{ "data": { "totals": { "messages": 18240, "segments": 19911, "delivered": 17902, "spend": 318576 },
"groups": { "kanisa": { "messages": 12010, "spend": 204160, … }, "kurasa": { … } } } }
Messages go out from POASMS's WhatsApp Business number. A message you start must use an approved template; free text is allowed only within 24 hours of the customer's last message. Priced per message by category (see Pricing).
POST /whatsapp/templates — create a template (reviewed by WhatsApp, usually within hours)
{ "name": "order_ready", "category": "utility", "language": "en",
"body": "Habari 1, order 2 is ready.", "examples": ["Asha", "1043"],
"footer": "Duka la Asha", "buttons": [{ "type": "url", "text": "Track", "url": "https://duka.co.tz/o" }] }
GET /whatsapp/templates — your templates with status: draft | pending | approved | rejected
POST /whatsapp/send
{ "to": ["0712345678"], "template": "order_ready", "params": ["Asha", "1043"],
"header": { "type": "document", "url": "https://duka.co.tz/receipt-1043.pdf", "filename": "receipt.pdf" },
"reference": "order-1043" }
→ 202 { "data": { "accepted": 1, "category": "utility", "price": 40, "messages": [{ "id": "…", "status": "queued" }] } }
POST /whatsapp/send — reply inside the 24-hour window
{ "to": "0712345678", "message": "Karibu! Tunafungua saa 2 asubuhi." }
GET /whatsapp/inbox?from=&since= — messages customers sent you
GET /sms?channel=whatsapp — WhatsApp messages in the log (same shape as SMS, plus category, template, read_at)
Status flow: queued → sent → delivered → read, or failed (refunded). Webhook events: whatsapp.sent, whatsapp.delivered, whatsapp.read, whatsapp.failed and whatsapp.inbound (data: id, from, name, type, text, received_at). Errors: template_not_approved, missing_params, missing_header, outside_24h_window (per recipient).
Contacts & opt-outs
POST /contacts { "phone": "0712345678", "name": "Asha", "groups": ["Members"] }
POST /opt-outs { "phone": "0712345678" }
Sender IDs
Request a sender ID and follow its approval by the networks — useful when your platform requests one for each of its own customers. Send multipart/form-data to attach a document (PDF, JPG or PNG, up to 5 MB), or JSON without one. reference is your own id, returned on every read.
POST /sender-ids
{ "name": "MLIMABARAKA", "use_case": "transactional",
"sample_message": "Asante kwa sadaka yako ya TZS 10,000. Risiti: RCP-0042",
"reference": "kanisa:12:34", "document": <file> }
201 { "data": { "id": 81, "name": "MLIMABARAKA", "status": "pending",
"networks": { "vodacom": "pending", "airtel": "pending", … },
"reference": "kanisa:12:34", "rejection_reason": null,
"requested_at": "2026-10-14T10:02:11+03:00", "approved_at": null } }
status moves from pending to approved or rejected — networks usually decide within 1–3 working days. Poll it:
GET /sender-ids/MLIMABARAKA one request (404 not_found if it is not in your workspace) GET /sender-ids all of your workspace's sender IDs
A name already requested in your workspace returns 409 sender_exists with the existing request in error.sender_id. Sandbox keys validate the request and answer "sandbox": true, but nothing is stored or sent to the networks.
Webhooks
Set a webhook URL on the app. On every status change we POST:
X-PoaSMS-Event: message.delivered
X-PoaSMS-Signature: sha256=<HMAC-SHA256(raw body, signing secret)>
{ "event": "message.delivered", "data": { …same as GET /sms/{id}… } }
Verify the signature with a constant-time compare. Non-2xx responses are retried with back-off for about 3 hours.
$ok = hash_equals('sha256='.hash_hmac('sha256', $rawBody, $secret), $_SERVER['HTTP_X_POASMS_SIGNATURE']);
Errors
{ "error": { "code": "insufficient_balance", "message": "…" } }
| 401 | unauthenticated | Missing or revoked key |
| 402 | insufficient_balance / budget_exceeded | Top up, or raise the app budget |
| 403 | sender_not_approved / workspace_suspended | |
| 404 | not_found | No such sender ID in your workspace |
| 409 | sender_exists | That sender ID was already requested |
| 422 | validation_failed / no_valid_recipients / stop_required / message_too_long | |
| 429 | rate_limited | Back off and retry |
SDKs
Drop-in clients live in the repository's sdk/ folder: a single-file PHP client (no Composer needed — works in plain PHP, Laravel and WHMCS) and a TypeScript client for Node / Next.js.
require 'PoaSms.php';
$sms = new PoaSms(getenv('POASMS_API_KEY'));
$sms->send('0712345678', 'Habari! Malipo yako yamepokelewa.');