# UPESI SMS: integration guide for AI coding assistants

> Give this whole file to your AI assistant (Claude, ChatGPT, Cursor, Copilot…) and say what you want built,
> for example: "Add SMS receipts to my Laravel billing system using UPESI SMS", or
> "My WiFi billing platform resells SMS: open an UPESI SMS sub-account for each of my operators".
> Always include the "Connect UPESI SMS" settings page (section 2a), so users connect from the dashboard, not the server.
> Human-readable reference: https://upesisms.com/docs

## 1. What UPESI SMS is

A Tanzanian SMS gateway (Vodacom, Airtel, Yas/Tigo, Halotel, TTCL). A REST + JSON API sends single SMS,
bulk SMS and OTP codes, reports delivery, and posts signed webhooks. Messages go out under an approved
**sender ID** (the name the phone shows, 3–11 characters, e.g. `MRHAAS WIFI`).

- Base URL: `https://upesisms.com/v1`
- Auth header on every call: `Authorization: Bearer usms_live_...`
- Body: JSON, `Content-Type: application/json`
- Every response has `"success": true|false`. Errors: `{"success": false, "error": {"code": "...", "message": "..."}}`
- Every response carries an `X-Request-Id` header; log it.

## 2. Rules the integration MUST follow

1. **The owner connects from their dashboard, not by editing the server.** Build the settings page in section 2a:
   the owner pastes the key and presses "Save and test". The key is kept **on the server only**: saved encrypted in
   the database (the encryption secret lives in `.env`), never sent back to the browser, never in a mobile app,
   a public repo or a frontend bundle. An environment variable `UPESI_SMS_KEY` may be read as a fallback.
2. **Phone numbers:** send `255XXXXXXXXX` (12 digits, no `+`). `0754123456` and `+255 754 123 456` are also accepted.
   Validate before calling; invalid numbers are skipped (`skipped.invalid`) and not charged.
3. **Never send the same SMS twice on retry.** Always pass a unique `reference` (your invoice/order/voucher id) or an
   `Idempotency-Key` header. Re-sending the same reference returns the first result with `"duplicate": true` and charges nothing.
   On a network error or timeout, retry with the SAME reference.
4. **Do not retry on 4xx** except 429 (wait, then retry). Retry 5xx and timeouts with the same reference, with backoff.
5. **Credits are SMS parts; one message is at most 5 parts** (765 GSM characters / 335 Unicode; longer = 422 `too_long`). GSM text: 160 characters in 1 part, 153 per part when longer. One emoji or special
   character makes it Unicode: 70 in 1 part, 67 per part. Keep texts short and GSM (plain Kiswahili/English) to save credits.
6. **402 `insufficient_credits` means nothing was sent or charged.** Show the user "top up SMS"; do not loop.
7. **Use only approved sender IDs.** `GET /v1/sender-ids` lists them. A wrong one gives 422 `sender_not_approved`.
8. **The API key is enough.** `GET /v1/me` = test connection (use it for a "Save and test" button). Get delivery status by
   polling `GET /v1/sms/status?ids=...` (up to 100) or `?reference=...` every few minutes until final. Webhooks are OPTIONAL.
   If you do add one: **verify the signature** (section 6) and answer 2xx within 10 seconds; do slow work after answering.
9. **Promotional SMS** (`"type": "promotional"`) outside 06:00–21:00 Tanzania time wait until morning. OTPs and receipts are `transactional` (default).
10. Rate limit: 10 requests/second per account by default. Bulk endpoints count as one request.

## 2a. Build this: "Connect UPESI SMS" settings page

Most users of your system are not developers. They must be able to connect SMS in one minute, from the
dashboard, without touching the server. Build a page **Settings → SMS (UPESI SMS)** that only the owner/admin can open:

| Element | What it does |
|---|---|
| **API key** field (password type, with show/hide) | Owner pastes `usms_live_...` from upesisms.com → API & developers. |
| **Save and test** button | Server calls `GET /v1/me` with the pasted key. On success: save the key, show *"Connected · <account name> · <N> SMS left"* and fill the Sender ID list. On 401: *"Wrong key"*, save nothing. On a network error: *"Could not reach UPESI SMS, try again"*, save nothing. |
| **Sender ID** dropdown | From `GET /v1/sender-ids` (approved names only). Saved with the key. |
| **Send test SMS** button | Sends one SMS to the owner's own phone (unique `reference`, e.g. `test-<timestamp>`) and shows the status from `GET /v1/sms/status` until it is final. |
| Status line | *Connected since <date>* / *Not connected*; the SMS balance from `GET /v1/me`; a warning when it is low, with a link to buy SMS at upesisms.com. |
| **Disconnect** button | Deletes the saved key (asks first). |

Rules for this page:
- Save the key **server-side, encrypted** (e.g. AES-256-GCM; the encryption secret comes from `.env`, such as `APP_KEY`).
  After saving, the page shows only `usms_live_••••1a2b` (last 4 characters); the full key never goes back to the browser.
- Leaving the field empty on a later save **keeps** the saved key (so the owner can change only the Sender ID).
- Every send in your system reads the key from this saved setting (fallback: env `UPESI_SMS_KEY`).
- Never log the key, never put it in URLs, and only the owner/admin role may view or change this page.
- Write the page in the language your users use (for Tanzanian users, Kiswahili or English, one at a time).

## 3. Endpoints

| Method | Path | Use |
|---|---|---|
| GET | `/v1/me` | test connection: account, credits, approved sender IDs (any valid key) |
| POST | `/v1/sms/send` | 1 to 1,000 numbers, one text |
| POST | `/v1/sms/bulk` | up to 100,000 recipients with per-person `vars`, or up to 1,000 different texts |
| GET | `/v1/sms/{message_id}` | delivery status of one message |
| GET | `/v1/sms/status?ids=a,b,c` or `?reference=x` | status of up to 100 messages, or all sent with one reference (no webhook needed) |
| GET | `/v1/sms/history?status=&from=&to=&page=&per_page=` | message history |
| GET | `/v1/account/balance` | credits left |
| GET | `/v1/sender-ids` | sender IDs and their status |
| POST | `/v1/otp/send` | send an OTP (we create and store the code) |
| POST | `/v1/otp/verify` | check an OTP |
| POST | `/v1/webhooks` | register a webhook URL |
| POST | `/v1/events` | trigger a dashboard automation |

### Send one SMS

```http
POST /v1/sms/send
Authorization: Bearer usms_live_xxx
Content-Type: application/json

{ "to": "255754123456", "message": "Habari {name}, malipo ya TSh 5,000 yamepokelewa.",
  "vars": { "name": "Asha" }, "sender_id": "DUKA LETU", "reference": "invoice-1042" }
```

Response `201` (`200` with `"duplicate": true` when the reference was already used):

```json
{ "success": true, "duplicate": false, "batch_id": "6f1c…", "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 }] }
```

Store `message_id` against your record to match delivery webhooks later.

### Bulk with variables

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

A brand-new account's first bulk send to 50+ people is checked by UPESI SMS first (`status: "awaiting_approval"`).

### OTP

```json
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 }

POST /v1/otp/verify  { "otp_id": "otp_Xy…", "code": "482913" }
→ { "success": true, "verified": true }
→ { "success": true, "verified": false, "reason": "wrong_code" | "expired" | "too_many_attempts" | "already_used" }
```

Store only `otp_id`, never the code. Max 5 codes per number per 10 minutes. A code is never delivered after
`expiry_minutes`: if still queued it is not sent (status `expired`, credit returned).

## 4. Message statuses

`queued`/`scheduled` → `submitted`/`sent` → `delivered`. Final failures: `failed`, `rejected`, `expired`, `dnd`.
`unknown` = the network has not answered yet; we keep asking and never resend it.

## 5. Errors

| HTTP | code | What your code should do |
|---|---|---|
| 401 | `missing_token`, `invalid_token` | Fix configuration; do not retry |
| 402 | `insufficient_credits` | Tell the owner to buy credits; nothing was charged |
| 403 | `permission_denied`, `ip_not_allowed`, `account_suspended`, `reseller_suspended`, `not_reseller` | Do not retry; alert the owner |
| 404 | `not_found`, `account_not_found` | Check the id |
| 422 | `sender_not_approved`, `empty_message`, `no_recipients`, `nothing_to_send`, `too_long`, `too_many`, `invalid_phone`, `invalid_credits`, `documents_required` | Fix the request |
| 429 | `rate_limited`, `too_many_otps` | Wait 1–2 s, retry same reference |
| 5xx | `server_error` | Retry with backoff, same reference; log `X-Request-Id` |

## 6. Webhooks (optional)

Not required: polling `GET /v1/sms/status` with the key gives the same statuses. Use webhooks only for instant reports.

Events: `sms.submitted`, `sms.delivered`, `sms.failed`, `sms.incoming`, `otp.verified`, `sender_id.approved`, `sender_id.rejected` (data: `sender_id`, `name`, `status`, `reason`).

```http
POST https://your-app.example/upesi/webhook
X-Upesi-Event: sms.delivered
X-Upesi-Timestamp: 1730971200
X-Upesi-Signature: sha256=<hex HMAC_SHA256(secret, timestamp + "." + raw_body)>

{ "event": "sms.delivered", "created_at": "2026-11-10T09:00:00Z",
  "data": { "message_id": "msg_…", "to": "255754123456", "sender_id": "DUKA LETU", "status": "delivered",
            "parts": 1, "credits": 1, "network": "Vodacom", "error": null,
            "created_at": "…", "submitted_at": "…", "delivered_at": "…",
            "account_id": "only for resellers", "external_ref": "only for resellers" } }
```

`sms.incoming` data: `{ "from": "255754123456", "text": "…", "keyword": "STOP", "received_at": "…" }`.
`otp.verified` data: `{ "otp_id": "…", "to": "…" }`.

Verify on the RAW body (before JSON parsing), compare in constant time, reject timestamps older than 5 minutes,
answer 2xx fast. Retries: after 1, 5, 30, 120 and 360 minutes. Make handling idempotent by `message_id` + `event`.

## 7. Resellers (billing platforms that sell SMS to their own customers)

Use this when your platform (WiFi billing, school system, POS…) has many customers and each should send SMS
under its own name and pay you, not us. Ask UPESI SMS to make your account a **reseller**; your API keys then
have the `reseller` permission.

Model:
- You buy credits from us at a wholesale price (dashboard → Wallet, or bank top-up).
- For each of your customers you open a **sub-account** (`external_ref` = your own customer id).
- You move credits from your balance to a customer (and back). You charge them whatever you want on your system.
- You request the customer's sender ID with their documents; we check it and get it approved by the networks.
- You send AS that customer: same endpoints as above + header `X-Account-Id: <account_id or your external_ref>`.
  Their credits and sender IDs are used; delivery webhooks for them also reach YOUR webhooks with `account_id` and `external_ref`.

| Method | Path | Body / notes |
|---|---|---|
| POST | `/v1/reseller/accounts` | `{ "name", "external_ref", "low_credit_alert" }` → 201 created, or 200 with `"created": false` if that `external_ref` exists (safe to repeat) |
| GET | `/v1/reseller/accounts?q=&page=&per_page=` | your customers with `credits`, `parts_30d`, `senders_approved` |
| GET | `/v1/reseller/accounts/{id or external_ref}` | one customer |
| PATCH | `/v1/reseller/accounts/{id}` | `{ "name", "status": "active"|"suspended", "low_credit_alert" }` |
| POST | `/v1/reseller/accounts/{id}/credits` | `{ "credits": 500, "reference": "inv-22" }`; negative takes unused credits back. `reference` must be unique per move (retry-safe) |
| POST | `/v1/reseller/accounts/{id}/sender-ids` | `{ "name": "NEEMA WIFI", "purpose": "…", "documents": [{ "name": "licence.pdf", "data": "data:application/pdf;base64,…" }] }` (optional - no documents needed). Name: at most 11 characters INCLUDING spaces, kept exactly as typed |
| GET | `/v1/reseller/accounts/{id}/sender-ids` | statuses: `with_reseller` (waiting for YOU) → `pending` → `submitted` → `approved` / `rejected` |
| GET | `/v1/reseller/sender-ids?status=with_reseller` | all your customers' requests |
| POST | `/v1/reseller/sender-ids/submit` | `{ "ids": [...] }` or `{ "all": true }`: send waiting requests on to UPESI SMS |
| DELETE | `/v1/reseller/sender-ids/{sender_id}` | remove a request still waiting with you |
| GET | `/v1/reseller/usage?from=2026-11-01&to=2026-12-01` | parts and messages per customer |
| GET | `/v1/reseller/balance` | your credits, credits held by customers, number of customers |

Typical flow when your customer buys an SMS bundle on YOUR system:

```text
1. (once) POST /v1/reseller/accounts { name: "Mama Neema WiFi", external_ref: "OP-77" }
2. (once) POST /v1/reseller/accounts/OP-77/sender-ids { name: "Neema WiFi", documents: [...] }
          → it waits for YOU (with_reseller); then POST /v1/reseller/sender-ids/submit { ids: [...] }
            (or send "submit": true in step 2 if your system already checked the name)
3. customer pays you → POST /v1/reseller/accounts/OP-77/credits { credits: 1000, reference: "your-payment-id", amount_tzs: 20000 }   (amount_tzs optional: feeds GET /v1/reseller/earnings)
4. send for them     → POST /v1/sms/send  (header X-Account-Id: OP-77) { to, message, sender_id: "NEEMA WIFI", reference }
5. show their balance → GET /v1/account/balance (header X-Account-Id: OP-77)
```

If your reseller balance is too low for step 3 you get 402 `insufficient_credits`: top up with us first.
Suspended customers (by you) or a suspended reseller get 403 and nothing is sent.

## 8. Ready-made code

### Node.js (18+, no packages)

```js
const BASE = "https://upesisms.com/v1";
const KEY = process.env.UPESI_SMS_KEY;

async function upesi(path, { method = "GET", body, accountId } = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...(accountId ? { "X-Account-Id": accountId } : {}) },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(15000),
  });
  const data = await res.json();
  if (!data.success) {
    const err = new Error(data.error?.message || `HTTP ${res.status}`);
    Object.assign(err, { status: res.status, code: data.error?.code, requestId: res.headers.get("x-request-id") });
    throw err;
  }
  return data;
}

// one receipt, safe to retry with the same reference
await upesi("/sms/send", { method: "POST", body: { to: "255754123456", message: "Asante! Tumepokea TSh 5,000.", reference: "pay-1042" } });

// webhook (Express): keep the raw body for the signature
const crypto = require("crypto");
app.post("/upesi/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-Upesi-Timestamp"), sig = req.get("X-Upesi-Signature") || "";
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.UPESI_WEBHOOK_SECRET).update(`${ts}.${req.body}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  if (!fresh || sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.sendStatus(401);
  res.sendStatus(200);
  const event = JSON.parse(req.body);   // then update your record by event.data.message_id
});
```

### Python (requests)

```python
import os, hmac, hashlib, time, requests

BASE = "https://upesisms.com/v1"
KEY = os.environ["UPESI_SMS_KEY"]

def upesi(path, method="GET", body=None, account_id=None):
    headers = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
    if account_id:
        headers["X-Account-Id"] = account_id
    r = requests.request(method, BASE + path, json=body, headers=headers, timeout=15)
    data = r.json()
    if not data.get("success"):
        raise RuntimeError(f"{r.status_code} {data['error']['code']}: {data['error']['message']} ({r.headers.get('X-Request-Id')})")
    return data

upesi("/sms/send", "POST", {"to": "255754123456", "message": "Asante! Tumepokea TSh 5,000.", "reference": "pay-1042"})

def valid_webhook(raw_body: bytes, ts: str, sig: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return abs(time.time() - int(ts)) < 300 and hmac.compare_digest(expected, sig or "")
```

### PHP (cURL)

```php
function upesi(string $path, string $method = 'GET', ?array $body = null, ?string $accountId = null): array {
    $h = ['Authorization: Bearer ' . getenv('UPESI_SMS_KEY'), 'Content-Type: application/json'];
    if ($accountId) $h[] = 'X-Account-Id: ' . $accountId;
    $ch = curl_init('https://upesisms.com/v1' . $path);
    curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 15, CURLOPT_POSTFIELDS => $body ? json_encode($body) : null]);
    $data = json_decode(curl_exec($ch), true);
    if (empty($data['success'])) throw new RuntimeException(($data['error']['code'] ?? 'http_error') . ': ' . ($data['error']['message'] ?? ''));
    return $data;
}

upesi('/sms/send', 'POST', ['to' => '255754123456', 'message' => 'Asante! Tumepokea TSh 5,000.', 'reference' => 'pay-1042']);

// webhook
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $_SERVER['HTTP_X_UPESI_TIMESTAMP'] . '.' . $raw, getenv('UPESI_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_UPESI_SIGNATURE'] ?? '') || abs(time() - (int)$_SERVER['HTTP_X_UPESI_TIMESTAMP']) > 300) { http_response_code(401); exit; }
http_response_code(200);
```

## 9. Checklist before going live

- [ ] "Connect UPESI SMS" page (section 2a): the owner pastes the key in the dashboard; it is stored encrypted on the server and shown masked; IP allowlist set on the key if your server IP is fixed.
- [ ] Every send has a unique `reference`; retries reuse it.
- [ ] Sender ID approved (`GET /v1/sender-ids`).
- [ ] 402 shows a "buy SMS" message to the owner; 4xx are not retried in a loop.
- [ ] "Save and test" calls `GET /v1/me` and shows the account name and SMS left.
- [ ] Delivery status: poll `GET /v1/sms/status` until final, OR a webhook with its signature verified on the raw body (idempotent handler).
- [ ] `message_id` stored to match delivery reports.
- [ ] Resellers: `external_ref` = your customer id; credit moves use your payment id as `reference`.

Support: https://upesisms.com · API keys: dashboard → API & developers.
