UPESI SMS API

Send SMS from your own website, app or business system. The API is REST with JSON. Base URL:

/v1

Building with an AI assistant? Give it /ai.md: one file with every rule, endpoint and code sample it needs.

Phone numbers: international format without "+" (255754123456). Tanzanian numbers written as 0754 123 456 are also accepted. Times are ISO 8601.

Authentication

Create a key in the dashboard under API & developers → API keys. Copy it when it is created. Forgot it? Press Show next to the key and type your password. We check keys by their hash; the copy for Show is stored encrypted. Send it on every request:

Authorization: Bearer usms_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Content-Type: application/json

Keep the key on your server. Never put it in a web page or mobile app. You can limit a key to your server's IP addresses, restrict its permissions, revoke it or regenerate it at any time.

The key is all you need. Send with it, and ask for delivery status with it. Webhooks are optional.

Test connection

GET/v1/me

Put this behind your system's Save and test button. Any valid key works; a wrong or revoked key answers 401 invalid_token.

{ "success": true, "connected": true, "data": { "account": "Asha Shop", "account_id": "…", "credits": 4211, "unit": "sms_part", "sender_ids": ["ASHA SHOP", "UPESI SMS"], "key": { "name": "My system", "permissions": ["sms:send", "…"] } } }

Quick start

curl -X POST {HOST}/v1/sms/send \ -H "Authorization: Bearer $UPESI_SMS_KEY" \ -H "Content-Type: application/json" \ -d '{"to":"255754123456","message":"Habari, karibu!"}'

Send SMS

POST/v1/sms/send
FieldRequiredDescription
toyesOne number, or an array of up to 1,000 numbers.
messageyesText. Up to 5 SMS parts: 765 characters (160 in one SMS, 153 per SMS when longer), or 335 with an emoji or other special character (70, then 67 per SMS). {name}-style variables are replaced from vars.
sender_idnoAn approved sender ID of your account. Default: your default sender ID.
typenotransactional (default) or promotional. Promotional SMS outside 06:00–21:00 Tanzania time are scheduled for the morning.
schedule_atnoISO time to send later.
referencenoYour own unique id (or the Idempotency-Key header). Sending the same reference again returns the first result and is not charged twice.
varsnoObject of variables, e.g. {"name":"Asha"}.
{ "to": "255754123456", "message": "Habari {name}, malipo yako ya TSh 5,000 yamepokelewa.", "vars": { "name": "Asha" }, "sender_id": "UPESINET", "reference": "invoice-1042" }

Response 201:

{ "success": true, "duplicate": false, "batch_id": "6f1c3a2e-…", "status": "queued", "recipients": 1, "credits_deducted": 1, "balance": 4211, "skipped": { "invalid": 0, "duplicates": 0, "opted_out": 0 }, "messages": [{ "message_id": "msg_8Kx2Lq0aZ1rT", "to": "255754123456", "status": "queued", "parts": 1, "credits": 1 }], "timestamp": "2026-11-07T09:12:03.120Z" }

Bulk SMS

POST/v1/sms/bulk

Same text to many people (with variables), up to 100,000 recipients per call:

{ "message": "Habari {name}, deni lako ni TSh {amount}.", "sender_id": "SACCO YETU", "recipients": [ { "to": "255754123456", "vars": { "name": "Asha", "amount": "12,000" } }, { "to": "255688222333", "vars": { "name": "John", "amount": "4,500" } } ], "reference": "reminders-2026-11" }

Or different texts, up to 1,000 per call: {"messages":[{"to":"…","message":"…"}]}. Each item gets its own result.

A new account's first bulk send to 50 or more people is checked by UPESI SMS before it goes out (status awaiting_approval); later sends go out at once.

Delivery status

GET/v1/sms/{message_id}
{ "success": true, "data": { "message_id": "msg_8Kx2Lq0aZ1rT", "to": "255754123456", "sender_id": "UPESINET", "status": "delivered", "parts": 1, "credits": 1, "network": "Vodacom", "error": null, "created_at": "…", "submitted_at": "…", "delivered_at": "…" } }
StatusMeaning
queued / scheduledWaiting to go out.
submitted / sentAccepted by the network, waiting for the phone.
deliveredReached the phone.
failed / rejected / expiredNot delivered. error says why.
dndThe number blocks promotional SMS.
unknownThe network did not answer yet; we keep asking. It is never resent.

Status of many (no webhook needed)

GET/v1/sms/status?ids=msg_a,msg_b,msg_c
GET/v1/sms/status?reference=invoice-77

Up to 100 message ids per call, or every message sent with one reference. Ask every few minutes until each is final (delivered, failed, rejected, expired, dnd). We keep asking the network for up to 3 days.

{ "success": true, "data": [ { "message_id": "msg_a", "status": "delivered", … }, … ], "not_found": [] }

History

GET/v1/sms/history?status=delivered&from=2026-11-01&to=2026-11-07&page=1&per_page=50

Balance

GET/v1/account/balance
{ "success": true, "data": { "account": "Asha Shop", "credits": 4211, "unit": "sms_part" } }

Sender IDs

GET/v1/sender-ids

Your sender IDs with their status. Only approved ones can be used. Request new ones in the dashboard (documents are needed).

OTP verification

We create the code, send it and check it. You never store codes.

POST/v1/otp/send
{ "to": "0754123456", "brand": "Duka Letu", "length": 6, "expiry_minutes": 5 } → { "success": true, "otp_id": "otp_Xy…", "message_id": "msg_…", "expires_in": 300 }

Optional message with {code} to use your own words. At most 5 codes per number per 10 minutes. A code is never delivered after it expires: if it has not left our queue by then it is not sent (status expired, credit returned), and the network is told to stop trying at the same moment.

POST/v1/otp/verify
{ "otp_id": "otp_Xy…", "code": "482913" } → { "success": true, "verified": true } → { "success": true, "verified": false, "reason": "wrong_code", "attempts_left": 4 }

Reasons: wrong_code, expired, too_many_attempts (5), already_used.

Events (automations)

POST/v1/events

Create an automation in the dashboard (trigger "When my system sends an event"), then report the event; the matching message is sent.

{ "event": "payment.received", "to": "255754123456", "vars": { "name": "Asha", "amount": "5,000" } }

Resellers: sell UPESI SMS on your own platform

For billing platforms (WiFi billing, school, SACCO or POS systems) whose own customers want SMS under their own names. Each of your customers gets a sub-account; you give them credits from your balance and charge them your own price on your system. We never bill your customers.

  1. Ask UPESI SMS to make your account a reseller. You get a wholesale price, and your API keys get the reseller permission.
  2. Buy credits (dashboard → Wallet, or bank top-up).
  3. Open a sub-account for each customer, with external_ref = your own id for them.
  4. Request each customer's sender ID with their documents. We check it and get it approved by the networks (1–3 working days).
  5. When a customer pays you, move credits to them. Then send for them with the normal endpoints plus the header X-Account-Id.

The same is available on the website under My customers for resellers who do not want to code it.

Customers (sub-accounts)

POST/v1/reseller/accounts
{ "name": "Mama Neema WiFi", "external_ref": "OP-77", "low_credit_alert": 100 } → 201 { "success": true, "created": true, "data": { "account_id": "9b2f…", "name": "Mama Neema WiFi", "external_ref": "OP-77", "status": "active", "credits": 0, "created_at": "…" } }

Safe to repeat: the same external_ref returns the existing customer with "created": false (200).

GET/v1/reseller/accounts?q=&page=1&per_page=50
GET/v1/reseller/accounts/{account_id or external_ref}
PATCH/v1/reseller/accounts/{id}
{ "name": "Neema WiFi Ltd", "status": "suspended", "low_credit_alert": 200 }

A suspended customer cannot send. Wherever an {id} is asked, our account_id or your external_ref both work.

Moving credits

POST/v1/reseller/accounts/{id}/credits
{ "credits": 1000, "reference": "your-payment-8841", "amount_tzs": 20000, "note": "Bundle 1,000 SMS" } → 201 { "success": true, "duplicate": false, "moved": 1000, "reseller_credits": 18910, "customer_credits": 1000, "account_id": "9b2f…" }

amount_tzs (optional) is what your customer paid you; it only feeds your earnings report (GET /v1/reseller/earnings). A negative number takes unused credits back. reference must be unique for each move: repeating it moves nothing ("duplicate": true), so a retry after a timeout is safe. Not enough credits: 402 insufficient_credits.

Sending for a customer

Every endpoint above (send, bulk, status, history, balance, sender IDs, OTP, webhooks) works for one of your customers when you add:

curl -X POST {HOST}/v1/sms/send \ -H "Authorization: Bearer $RESELLER_KEY" \ -H "X-Account-Id: OP-77" \ -H "Content-Type: application/json" \ -d '{"to":"255754123456","message":"Vocha yako: 4821","sender_id":"NEEMA WIFI","reference":"voucher-4821"}'

The customer's credits and sender IDs are used. A customer may also use your own approved sender IDs. Their delivery reports reach your webhooks with account_id and external_ref.

Customer sender IDs

POST/v1/reseller/accounts/{id}/sender-ids
{ "name": "NEEMA WIFI", "purpose": "WiFi vouchers and receipts", "documents": [{ "name": "licence.pdf", "data": "data:application/pdf;base64,JVBERi0x…" }] } → 201 { "success": true, "data": { "sender_id": "…", "name": "Neema WiFi", "status": "with_reseller", "account_id": "…" } }

Your customers' requests come to you first. A new request waits as with_reseller until you submit it to UPESI SMS (on your dashboard under My customers → Sender ID requests, or with the calls below). If your system already checked the name, send "submit": true and it comes straight to us.

GET/v1/reseller/sender-ids?status=with_reseller

All your customers' requests (any status), with account_id, external_ref and reject_reason.

POST/v1/reseller/sender-ids/submit
{ "ids": ["sender_id", "…"] } or { "all": true } → { "success": true, "submitted": 3, "items": [{ "sender_id": "…", "name": "Neema WiFi" }] }
DELETE/v1/reseller/sender-ids/{sender_id}

Removes a request still waiting with you (never one already with us).

At most 11 characters, spaces included ('Upesi Lipa' = 10), kept exactly as typed. No documents are needed (optional: PDF, PNG or JPG, up to 4 MB each). Names of brands the customer does not own are refused.

GET/v1/reseller/accounts/{id}/sender-ids

Status: with_reseller (waiting for you) → pending (we are checking) → submitted (with the networks) → approved or rejected (with reject_reason). After a rejection, ask for another name the same way. Webhook events sender_id.approved and sender_id.rejected tell your system the moment it is decided.

Usage and balance

GET/v1/reseller/usage?from=2026-11-01&to=2026-12-01
{ "success": true, "data": { "from": "…", "to": "…", "total_parts": 5120, "accounts": [{ "account_id": "…", "name": "Mama Neema WiFi", "external_ref": "OP-77", "credits": 380, "parts": 620, "messages": 600, "delivered": 588 }] } }

Use it to bill your customers or to check your own invoices. Default period: this month.

GET/v1/reseller/earnings

Today, yesterday, this week, this month, last month and all time: SMS bought from us and what you paid, SMS given to your customers, money collected (from amount_tzs, or your usual selling price), your cost and profit, SMS your customers sent.

GET/v1/reseller/balance
{ "success": true, "data": { "credits": 18910, "held_by_customers": 1096, "customers": 3, "unit": "sms_part" } }

Webhooks (optional)

Not required: ask for status with your key instead. Use a webhook only if you want reports the second they arrive. Add a URL under API & developers → Webhooks (or POST /v1/webhooks {"url","events"}). Events: sms.submitted, sms.delivered, sms.failed, sms.incoming, otp.verified, sender_id.approved, sender_id.rejected.

POST https://yourdomain.com/sms/callback X-Upesi-Event: sms.delivered X-Upesi-Timestamp: 1730971200 X-Upesi-Signature: sha256=5f1c… { "event": "sms.delivered", "created_at": "…", "data": { "message_id": "msg_…", "to": "255754123456", "status": "delivered", … } }

Other events: sms.incoming → {"from","text","keyword","received_at"}; otp.verified → {"otp_id","to"}. For a reseller, events of its customers also arrive on the reseller's webhooks, with account_id and external_ref added to data.

Check the signature: HMAC_SHA256(secret, timestamp + "." + raw_body), hex, prefixed sha256=. Reject old timestamps. Answer with any 2xx within 10 seconds; otherwise we retry after 1, 5, 30, 120 and 360 minutes.

// PHP $expected = 'sha256=' . hash_hmac('sha256', $_SERVER['HTTP_X_UPESI_TIMESTAMP'] . '.' . file_get_contents('php://input'), $secret); if (!hash_equals($expected, $_SERVER['HTTP_X_UPESI_SIGNATURE'])) { http_response_code(401); exit; }

Errors

{ "success": false, "error": { "code": "insufficient_credits", "message": "Not enough SMS credits: this needs 120, you have 40", "needed": 120, "available": 40 } }
HTTPcodeWhen
401missing_token, invalid_tokenNo key, wrong key, revoked or expired key.
402insufficient_creditsNot enough credits. Nothing was sent or charged.
403permission_denied, ip_not_allowed, account_suspended, reseller_suspended, not_resellerThe key may not do this, the request came from another IP, or the account (or its reseller) is suspended.
404not_found, account_not_foundUnknown message id, endpoint, or customer under your reseller account.
422sender_not_approved, empty_message, no_recipients, nothing_to_send, too_long, too_many, invalid_phone, invalid_credits, invalid_sender, documents_requiredThe request cannot be sent as it is.
429rate_limited, too_many_otpsToo fast. Wait and retry.
500server_errorOur side. The X-Request-Id header helps support find it.

Rate limits

10 requests per second per account by default (ask for more). Bulk endpoints count as one request. Messages leave our queue at the speed the networks allow; large sends finish in minutes.

Billing and SMS parts

Credits are SMS parts. Plain text (Kiswahili, English): 160 characters in one part, 153 per part when longer. One emoji or other special character makes the whole message Unicode: 70 characters in one part, 67 per part. Credits are taken when you send; a message the network refuses before sending is refunded automatically. Every call is logged under API logs.

Code: Node.js, Python, PHP

Read the key from an environment variable on your server. Always pass a reference so a retry never sends twice.

// Node.js 18+ const r = await fetch("{HOST}/v1/sms/send", { method: "POST", headers: { Authorization: "Bearer " + process.env.UPESI_SMS_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ to: "255754123456", message: "Asante! Tumepokea TSh 5,000.", reference: "pay-1042" }), }); const data = await r.json(); if (!data.success) throw new Error(data.error.code + ": " + data.error.message);
# Python import os, requests r = requests.post("{HOST}/v1/sms/send", timeout=15, headers={"Authorization": "Bearer " + os.environ["UPESI_SMS_KEY"]}, json={"to": "255754123456", "message": "Asante! Tumepokea TSh 5,000.", "reference": "pay-1042"}) data = r.json() if not data["success"]: raise RuntimeError(data["error"]["code"])
// PHP $ch = curl_init('{HOST}/v1/sms/send'); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('UPESI_SMS_KEY'), 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['to' => '255754123456', 'message' => 'Asante! Tumepokea TSh 5,000.', 'reference' => 'pay-1042'])]); $data = json_decode(curl_exec($ch), true);

Retry only on timeouts, 429 and 5xx, with the same reference. Never retry other 4xx in a loop.

Building with AI

Using Claude, ChatGPT, Cursor or Copilot? Give it /ai.md and tell it what you want, for example "Add SMS receipts to my billing system using UPESI SMS". The file has every rule the integration must follow (key on the server, retry-safe references, sender IDs, webhook signatures, resellers) and ready code in Node.js, Python and PHP. AI tools that look for /llms.txt find it on their own.

Let your users connect from the dashboard

Your users should not edit a server file to turn SMS on. Build a Settings → SMS (UPESI SMS) page: the owner pastes the API key, presses Save and test (your server calls GET /v1/me), picks the Sender ID and sends a test SMS to their phone. The key is saved on your server, encrypted (the encryption secret stays in .env), and shown only as usms_live_••••1a2b. Full checklist in /ai.md, section 2a. To have an AI build it, paste:

Read https://upesisms.com/ai.md. Add UPESI SMS to my system: build the "Connect UPESI SMS" settings page from section 2a (API key, Save and test, Sender ID, Send test SMS, Disconnect; key stored encrypted on the server), then send SMS receipts after each payment with a unique reference.