Overview
The TeraMouv API Gateway is a thin, stable REST layer that lets your application send A2P SMS messages and receive P2A replies without ever touching the underlying messaging infrastructure directly.
Protocol basics
| Property | Value |
|---|---|
| Protocol | HTTPS only — https://api.sms.teramouv.com. No HTTP fallback. |
| Content-Type | application/json on every request body and every response. |
| API Version | v1, embedded in the URL path (/api/v1/…). Breaking changes ship under a new path prefix; v1 stays stable. |
| Authentication | Single X-API-Key header on all protected requests. |
| Coverage | Burundi numbers only. Format: 257XXXXXXXX — country code 257 + 8 local digits = 11 digits total. Example: 25700000000. |
| Message limit | 160 characters hard cap. No multipart/concatenated SMS. |
Quick Start
Three calls to go from zero to a delivered SMS. Complete the steps below to send your first message.
-
Obtain an API key
Call
POST /auth/loginwith your credentials. Save the returnedapi_key— you'll send it as theX-API-Keyheader on every subsequent request.cURLcurl -X POST https://api.sms.teramouv.com/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"your_username","password":"your_password"}'
⚠️Production systems: do not call
/auth/loginfrom every process. Ask your TeraMouv administrator for a permanently provisioned API key instead. See API Key Types below. -
Send an SMS
Use the key from Step 1 in the
X-API-Keyheader. A200response means the message was accepted — not yet delivered. Save themessage_idfor status polling.cURLcurl -X POST https://api.sms.teramouv.com/api/v1/sms/send \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{"to":"25700000000","message":"Hello from TeraMouv","from":"TeraMouv"}'
-
Poll for delivery status
Use the
message_idfrom Step 2 to check whether the message reached the handset. The status moves fromsubmitted→pending→sent→delivered(orfailed).cURLcurl https://api.sms.teramouv.com/api/v1/sms/status/msg_901991 \ -H "X-API-Key: sk_live_..."
API Key Types
There are two ways to obtain an API key. Understanding the difference is critical for production deployments.
| Type | How obtained | Behaviour on re-login | Recommended for |
|---|---|---|---|
| Session key | Call POST /auth/login |
Any previous session key for the same account is immediately revoked | One-off scripts, manual testing, single-process applications |
| Permanent key | Provisioned directly by a TeraMouv administrator | Not affected by /auth/login calls |
Production servers, multi-process / multi-host deployments, server-to-server integrations |
Multi-process pitfall: if two of your own services each call /auth/login with the same credentials, the second login silently invalidates the first service's key. That service will start receiving 401 UNAUTHORIZED on every request without any warning. Use a permanent key — contact your administrator.
IP Whitelisting
A key can optionally be restricted to a list of source IP addresses. Requests from an unlisted IP are rejected with 403 IP_NOT_ALLOWED regardless of how valid the key is. Contact your administrator to view or update your key's IP whitelist.
Authentication
All protected endpoints require the X-API-Key header. The key is checked before any business logic runs — an invalid or missing key always returns 401 immediately.
Exchange your username and password for an API key. The key does not expire — it stays valid until you call /auth/logout or log in again with the same account (which revokes the previous key).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| usernameREQUIRED | string | Yes | Your account username, as provided by TeraMouv. |
| passwordREQUIRED | string | Yes | Your account password. |
Example request
curl -X POST https://api.sms.teramouv.com/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"your_username","password":"your_password"}'
const res = await fetch('https://api.sms.teramouv.com/api/v1/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'your_username', password: 'your_password' }) }); const { api_key, user } = await res.json(); // Store api_key securely — use it in X-API-Key on subsequent requests
$ch = curl_init('https://api.sms.teramouv.com/api/v1/auth/login'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode([ 'username' => 'your_username', 'password' => 'your_password' ]), ]); $data = json_decode(curl_exec($ch), true); $apiKey = $data['api_key'];
import requests resp = requests.post( 'https://api.sms.teramouv.com/api/v1/auth/login', json={'username': 'your_username', 'password': 'your_password'} ) api_key = resp.json()['api_key']
Response 200
{
"success": true,
"api_key": "sk_live_abc123...",
"user": {
"id": 2,
"username": "your_username",
"email": "you@example.com"
}
}Error responses
| HTTP | Error code | Cause |
|---|---|---|
| 422 | VALIDATION_FAILED | Missing or malformed fields in the request body |
| 401 | INVALID_CREDENTIALS | Username or password is incorrect |
Immediately revokes the API key sent in the X-API-Key header. The key cannot be reused after this call. Safe to retry freely (subsequent calls with the now-invalid key simply return 401).
Request
No request body needed — the key to revoke is identified by the X-API-Key header itself.
Response 200
{ "success": true, "message": "Logged out successfully." }Call this endpoint when you want to explicitly invalidate a session key. For permanently provisioned keys, contact your administrator for revocation instead.
SMS
Send outbound A2P messages, check delivery receipts, and retrieve inbound P2A messages.
Submits a single SMS message for delivery. A 200 response means the message was accepted into the send queue — it does not confirm delivery to the handset. Delivery is asynchronous; poll /sms/status/{message_id} for the final outcome.
One message, one destination per call. There is no batch/bulk send endpoint. To send to multiple recipients, issue one /sms/send call per destination number.
Request fields
| Field | Type | Required | Validation & notes |
|---|---|---|---|
| toREQUIRED | string | Yes | Destination number in Burundi format: 257XXXXXXXX or +257XXXXXXXX. Country code 257 followed by exactly 8 local digits — 11 digits total (e.g. 25700000000). The leading + is stripped automatically. Numbers with fewer or more than 8 local digits, or any other country code, are rejected with 422 VALIDATION_FAILED. |
| messageREQUIRED | string | Yes | Message text: 1–160 characters. Messages over 160 characters are rejected outright — there is no automatic splitting into multiple segments. |
| fromOPTIONAL | string | No | Sender ID (alphanumeric or numeric). Must be a value your account is authorised to use. An unrecognised sender ID is rejected by the upstream carrier — not by this gateway — so you will not receive a 422 for an invalid from value; the message will fail further downstream. |
| scheduleOPTIONAL | string | No | Future send time. Accepts ISO 8601 (2026-06-15T10:00:00) or MySQL datetime (2026-06-15 10:00:00) formats. Omit to send immediately. |
Example requests
curl -X POST https://api.sms.teramouv.com/api/v1/sms/send \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{ "to": "25700000000", "message": "Your OTP code is 482910. Valid for 5 minutes.", "from": "TeraMouv" }'
curl -X POST https://api.sms.teramouv.com/api/v1/sms/send \ -H "Content-Type: application/json" \ -H "X-API-Key: sk_live_..." \ -d '{ "to": "25700000000", "message": "Your appointment is tomorrow at 09:00.", "from": "TeraMouv", "schedule": "2026-06-20T08:00:00" }'
async function sendSMS(to, message, from) { const res = await fetch('https://api.sms.teramouv.com/api/v1/sms/send', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.TERAMOUV_API_KEY }, body: JSON.stringify({ to, message, from }) }); if (!res.ok) { const err = await res.json(); throw new Error(`SMS failed: ${err.error} — ${err.message}`); } return await res.json(); // { success, message_id, status, ... } }
function sendSMS(string $to, string $message, string $from): array { $ch = curl_init('https://api.sms.teramouv.com/api/v1/sms/send'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-API-Key: ' . getenv('TERAMOUV_API_KEY') ], CURLOPT_POSTFIELDS => json_encode([ 'to' => $to, 'message' => $message, 'from' => $from, ]), ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); return $data; }
import os, requests def send_sms(to: str, message: str, sender_id: str = None) -> dict: payload = {"to": to, "message": message} if sender_id: payload["from"] = sender_id resp = requests.post( "https://api.sms.teramouv.com/api/v1/sms/send", json=payload, headers={"X-API-Key": os.environ["TERAMOUV_API_KEY"]}, timeout=10 ) resp.raise_for_status() return resp.json() # { "success": True, "message_id": "msg_...", ... }
Response 200
{
"success": true,
"message_id": "msg_901991", // save this — use it to poll /sms/status
"status": "submitted",
"destination": "25700000000",
"credits_deducted": 1, // always 1 per accepted message
"timestamp": "2026-06-03T16:49:42+02:00"
}Error responses
| HTTP | Error code | Your problem? | Action |
|---|---|---|---|
| 401 | UNAUTHORIZED | ✅ Yes | Check your X-API-Key header. The key may be missing, revoked, or mistyped. |
| 403 | IP_NOT_ALLOWED | ✅ Yes | Your source IP is not in the whitelist for this key. Contact your administrator. |
| 422 | VALIDATION_FAILED | ✅ Yes | Invalid to format, empty message, or message over 160 chars. See the errors object in the response. |
| 402 | INSUFFICIENT_CREDIT | ✅ Yes | Your account has run out of SMS credits. Contact TeraMouv to top up. |
| 429 | RATE_LIMIT_EXCEEDED | ✅ Yes | Slow down. Wait Retry-After seconds before the next request. |
| 502 | SEND_FAILED | ❌ Gateway | The upstream carrier rejected the send as a structured error. Contact support if persistent. |
| 400 | SEND_MISSING_FIELDS | ❌ Gateway | Rare — the gateway's upstream reported empty fields despite passing local validation. |
| 401 | AUTH_FAILED / TOKEN_UNAVAILABLE / TOKEN_DISABLED | ❌ Gateway | The gateway's own connection to the upstream is misconfigured. Not your API key. Contact support. |
| 403 | TOKEN_IP_BLOCKED | ❌ Gateway | The gateway server's IP is blocked upstream. TeraMouv operational issue. Contact support. |
| 500 | INTERNAL_SERVER_ERROR | ❌ Gateway | Upstream unreachable or unparseable. Do not blindly retry — the message may already be queued. See Retries & Idempotency. |
Returns the last known delivery status for a message. This endpoint always succeeds (unless the message_id is not found) — if the upstream is temporarily unreachable, it falls back to the locally cached record rather than returning an error.
Message status lifecycle
| Status | Meaning |
|---|---|
| submitted | Accepted by this gateway and forwarded to the upstream. No further status received yet. |
| pending | In the upstream queue, not yet dispatched to the telco. |
| sent | Dispatched to the carrier SMSC, awaiting a delivery receipt (DLR). |
| delivered | A delivery receipt was received from the telco confirming handset delivery. Terminal — poll no further. |
| failed | The telco reported delivery as failed. Terminal — do not retry without investigating the cause. |
Path parameter
| Parameter | Type | Description |
|---|---|---|
| message_id | string | The message_id value returned by a previous /sms/send call. Example: msg_901991. |
Response 200
{
"message_id": "msg_901991",
"status": "delivered",
"destination": "25700000000",
"submitted_at": "2026-06-03 16:49:42", // YYYY-MM-DD HH:MM:SS, no timezone
"updated_at": "2026-06-03 16:49:47", // null if no live DLR check succeeded
"error_code": "0"
}Timezone note: submitted_at and updated_at are plain YYYY-MM-DD HH:MM:SS strings passed through as-is — unlike the ISO 8601 timestamp field on other endpoints, these carry no timezone offset. Treat them as server-local time.
Error responses
| HTTP | Error code | Cause |
|---|---|---|
| 404 | NOT_FOUND | The message_id does not exist, or belongs to a different API key. |
Polling recommendation
Poll at a reasonable interval — every 5–10 seconds is appropriate. Stop polling once the status reaches delivered or failed (both are terminal states). There is no push notification / webhook mechanism.
Returns a paginated list of inbound (person-to-application) SMS messages received on your account. Poll this endpoint to detect replies.
Query parameters
| Parameter | Default | Max | Description |
|---|---|---|---|
| limit | 10 | 100 | Number of messages to return per page. |
| offset | 0 | — | Zero-based pagination offset. Increment by limit to fetch the next page. |
Pagination caveat: the total field in the response is the count of rows returned in this page only — it is not the grand total of all inbox messages. You cannot use it to calculate "how many pages remain." Continue paginating until a page returns fewer messages than your limit.
Response 200
{
"success": true,
"total": 5, // count of rows in THIS page, not all-time total
"limit": 10,
"offset": 0,
"messages": [
{
"id": "1",
"from": "25700000000",
"message": "STOP",
"received_at": "2026-06-03T09:55:00+02:00"
}
]
}Error responses
| HTTP | Error code | Cause |
|---|---|---|
| 401 | AUTH_FAILED / TOKEN_UNAVAILABLE / TOKEN_DISABLED | Gateway-side upstream connection issue — not your API key. Contact support. |
| 403 | TOKEN_IP_BLOCKED | Gateway server IP blocked on the upstream side. Contact support. |
| 500 | INTERNAL_SERVER_ERROR | Upstream unreachable or returned unparseable response. |
Account
Check your credit balance and retrieve your full account profile from the gateway.
Returns the current credit balance on your account. Credits are denominated in SMS messages (1 credit = 1 SMS segment). Monitor this to avoid hitting 402 INSUFFICIENT_CREDIT on sends.
Response 200
{
"success": true,
"balance": 4989564,
"currency": "SMS", // always the literal string "SMS", not a monetary currency
"timestamp": "2026-06-03T16:49:42+02:00"
}Returns the full account record associated with your API key, including balance and message history pointers.
Response 200
{
"success": true,
"data": {
"username": "your_username",
"name": "Your Company",
"email": "you@example.com",
"mobile": "",
"balance": 4989564,
"last_outgoing_id": "901991", // highest sent message ID on record
"last_incoming_id": "0", // highest received message ID
"last_inbox_id": "0" // highest two-way inbox message ID
},
"timestamp": "2026-06-03T16:49:42+02:00"
}Field descriptions
| Field | Description |
|---|---|
| last_outgoing_id | The highest internal log ID of an outgoing message on this account. Useful for detecting new outbound activity. |
| last_incoming_id | The highest ID of an incoming (received) message. |
| last_inbox_id | The highest ID of a two-way inbox-style message. |
Public endpoint — no API key required. Returns 200 when the gateway process is running. Safe to poll as frequently as your load balancer or uptime monitor requires.
Scope: this endpoint only confirms the gateway process itself is alive. It does not check upstream connectivity, Redis, or database availability. A 200 here does not guarantee that /sms/send will succeed.
Response 200
{
"status": "ok",
"service": "TeraMouv API Gateway",
"timestamp": "2026-06-03T16:49:42+02:00"
}Request Validation
Every POST / PUT / PATCH request is validated before your API key is even checked. A bad request always returns 400, never 401.
| Rule | What happens if violated |
|---|---|
Content-Type: application/json must be present | 400 BAD_REQUEST — before auth check |
| Body must be valid JSON (if non-empty) | 400 BAD_REQUEST — before auth check |
| Empty body | Treated as {} — required fields then caught by 422 VALIDATION_FAILED |
| Required fields missing or invalid | 422 VALIDATION_FAILED with per-field details in errors |
{
"success": false,
"error": "VALIDATION_FAILED",
"errors": {
"to": ["The to format is invalid."],
"message": ["The message field is required."]
}
}Rate Limiting
Each API key has its own rate limit window. The default is 1,000 requests per 60-second window, configurable per key by an administrator.
Every protected endpoint response includes these headers so you can track consumption in real time:
X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 995
When the limit is exceeded, the gateway responds with:
HTTP/1.1 429 Too Many Requests Retry-After: 60 { "success": false, "error": "RATE_LIMIT_EXCEEDED", "message": "Rate limit of 1000 requests per minute exceeded." }
Wait the number of seconds specified in the Retry-After header before retrying. If your integration requires a higher limit, contact your TeraMouv administrator to have your key's threshold raised.
Retries & Idempotency
/sms/send is not idempotent. If you retry a send that timed out or returned 500, you may end up delivering the same message twice.
Do not blindly retry /sms/send after a timeout or 500 INTERNAL_SERVER_ERROR. The original message may already be queued upstream even though the response never reached you.
Recovery procedure
-
You received a
message_idbefore the failureCall
GET /sms/status/{message_id}first. If the status issubmittedor higher, the message is already in the queue — do not resend. -
You never received a
message_idThe request failed before a response was returned. There is currently no lookup mechanism for this case. Wait briefly and check your own application logs. If duplicate sends are a serious concern for your use case, contact TeraMouv support.
Safe to retry freely
All GET requests and /auth/logout are idempotent and can be retried without any risk of side effects.
Error Handling
All error responses share a consistent shape. The error field is the machine-readable code; message is a human-readable description.
Standard error shape
{
"success": false,
"error": "ERROR_CODE",
"message": "Human-readable description of what went wrong."
}Validation error shape (extra errors object)
{
"success": false,
"error": "VALIDATION_FAILED",
"errors": {
"to": ["The to format is invalid."],
"message": ["The message field is required."]
}
}Complete error code reference
| HTTP | Error code | Your integration? | When it occurs |
|---|---|---|---|
| 400 | BAD_REQUEST | ✅ Yes | Non-JSON body, malformed JSON, or wrong / missing Content-Type. |
| 400 | SEND_MISSING_FIELDS | ❌ Gateway | Upstream reports destination or message empty. Rare — gateway validates first. |
| 401 | UNAUTHORIZED | ✅ Yes | Missing, invalid, or revoked API key. |
| 401 | INVALID_CREDENTIALS | ✅ Yes | Wrong username or password on /auth/login. |
| 401 | AUTH_FAILED | ❌ Gateway | Gateway's own upstream authentication failed — not your API key. |
| 401 | TOKEN_UNAVAILABLE | ❌ Gateway | Gateway's upstream webservices token not available — not your API key. |
| 401 | TOKEN_DISABLED | ❌ Gateway | Gateway's upstream webservices token disabled — not your API key. |
| 402 | INSUFFICIENT_CREDIT | ✅ Yes | Not enough SMS credit on the account. Top up with your administrator. |
| 403 | IP_NOT_ALLOWED | ✅ Yes | Request source IP not in this key's IP whitelist. |
| 403 | TOKEN_IP_BLOCKED | ❌ Gateway | Gateway server IP blocked on the upstream side — TeraMouv operational issue. |
| 404 | NOT_FOUND | ✅ Yes | message_id not found or belongs to a different key. |
| 405 | METHOD_NOT_ALLOWED | ✅ Yes | Wrong HTTP method for the endpoint (e.g. GET on /sms/send). |
| 422 | VALIDATION_FAILED | ✅ Yes | Invalid or missing fields — see per-field errors object. |
| 429 | RATE_LIMIT_EXCEEDED | ✅ Yes | Rate limit window exceeded. Wait Retry-After seconds. |
| 500 | INTERNAL_SERVER_ERROR | ❌ Gateway | Unexpected gateway error or upstream unreachable / unparseable. |
| 502 | SEND_FAILED | ❌ Gateway | Upstream rejected the send as a structured failure. |
Any error marked ❌ Gateway indicates a problem with the gateway's own upstream connection — retrying from your end will not resolve it. Contact TeraMouv support if these errors persist.
Limits & Constraints
Hard boundaries enforced by this API version. These are not configurable per key.
| Constraint | Value | Notes |
|---|---|---|
| Country coverage | Burundi only | to must be 257XXXXXXXX or +257XXXXXXXX — country code 257 followed by exactly 8 local digits (11 digits total). No other country code is accepted. |
| Message length | 1–160 characters | Hard cap. Messages over 160 chars are rejected. There is no automatic splitting into multiple segments (no concatenated SMS). |
| Batch sending | Not supported | One message, one destination per /sms/send call. Loop on your side for multiple recipients. |
| Webhooks / push | Not supported | No push delivery receipts or inbound notifications. Poll /sms/status and /sms/inbox instead. |
| Idempotency key | Not supported | /sms/send has no deduplication. See Retries & Idempotency before retrying on errors. |
| Rate limit (default) | 1,000 req / 60 sec | Per API key. Configurable by administrator on request. |
Security Headers
Every response — including errors — carries these hardening headers. They don't affect server-to-server clients but are worth knowing when debugging via a browser or proxy.
Strict-Transport-Security: max-age=31536000; includeSubDomains X-Frame-Options: DENY X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Referrer-Policy: strict-origin-when-cross-origin Content-Security-Policy: default-src 'none'