POASMS

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
  }'
FieldDescription
toRequired. A number, an array of numbers, or a comma-separated string. 07…, 2557…, +255… accepted.
messageRequired. Up to 10 parts (≈1,530 GSM characters).
fromApproved sender ID. Defaults to the app's default sender.
typetransactional (default), otp or promotional. Promotional must contain STOP text and skips opted-out numbers.
referenceYour own ID (≤100 chars), returned in status and webhooks.
schedule_atISO-8601 time to send later.
appOnly 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": { … } } } }

WhatsApp

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": "…" } }
401unauthenticatedMissing or revoked key
402insufficient_balance / budget_exceededTop up, or raise the app budget
403sender_not_approved / workspace_suspended
404not_foundNo such sender ID in your workspace
409sender_existsThat sender ID was already requested
422validation_failed / no_valid_recipients / stop_required / message_too_long
429rate_limitedBack 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.');