SMS API
SMS API for Tanzania: send with one HTTPS request
A versioned REST API with scoped keys, idempotency, signed webhooks and delivery status for every message. Create a key in your dashboard and send your first SMS in minutes.
- Base URL
- https://api.lundosms.co.tz/v1
- Authentication
- Authorization: Bearer YOUR_API_KEY
- Format
- JSON over HTTPS
- Per request
- Up to 1,000 recipients
Quick start
Four steps from nothing to a delivered message.
- 1
Create an API key
In your dashboard, open the Developer section and create a key. Start with a test key (
lsk_test_…); live keys start withlsk_live_…. Copy the key when it is shown — it appears only once. - 2
Have a sender ID approved
Messages are sent with an approved sender ID. Request yours from the dashboard; the API returns
sender_id_not_approveduntil it is approved. - 3
Send a message
POST to
/v1/messageswith your sender ID, the recipients and the text. Numbers can be written as 0712 345 678, 255712345678 or +255712345678 — they come back in international format.POST /v1/messages
curl -X POST 'https://api.lundosms.co.tz/v1/messages' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Idempotency-Key: order-1001' \ -H 'Content-Type: application/json' \ -d '{ "sender_id": "YOURBRAND", "recipients": ["255712345678"], "message": "Your order has been confirmed.", "client_reference": "ORDER-1001", "type": "transactional" }'const response = await fetch('https://api.lundosms.co.tz/v1/messages', { method: 'POST', headers: { Authorization: `Bearer ${process.env.LUNDOSMS_API_KEY}`, 'Idempotency-Key': 'order-1001', 'Content-Type': 'application/json' }, body: JSON.stringify({ sender_id: 'YOURBRAND', recipients: ['255712345678'], message: 'Your order has been confirmed.', client_reference: 'ORDER-1001', type: 'transactional' }) }); const result = await response.json(); console.log(result.data.batch_id, result.data.status);<?php $ch = curl_init('https://api.lundosms.co.tz/v1/messages'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('LUNDOSMS_API_KEY'), 'Idempotency-Key: order-1001', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'sender_id' => 'YOURBRAND', 'recipients' => ['255712345678'], 'message' => 'Your order has been confirmed.', 'client_reference' => 'ORDER-1001', 'type' => 'transactional', ]), ]); $result = json_decode(curl_exec($ch), true); echo $result['data']['batch_id'];import os import requests response = requests.post( "https://api.lundosms.co.tz/v1/messages", headers={ "Authorization": f"Bearer {os.environ['LUNDOSMS_API_KEY']}", "Idempotency-Key": "order-1001", }, json={ "sender_id": "YOURBRAND", "recipients": ["255712345678"], "message": "Your order has been confirmed.", "client_reference": "ORDER-1001", "type": "transactional", }, timeout=10, ) print(response.json()["data"]["batch_id"])package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "sender_id": "YOURBRAND", "recipients": []string{"255712345678"}, "message": "Your order has been confirmed.", "client_reference": "ORDER-1001", "type": "transactional", }) req, _ := http.NewRequest("POST", "https://api.lundosms.co.tz/v1/messages", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("LUNDOSMS_API_KEY")) req.Header.Set("Idempotency-Key", "order-1001") req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() fmt.Println(res.Status) } - 4
Read the response
A 202 means the messages are accepted and their credits reserved. Each message has its own id: fetch its status from
GET /v1/messages/<id>, or let a webhook tell you when it changes.
What you get back
The 202 response lists every accepted message with its id and status. Recipients that cannot be accepted — an invalid number, an opted-out number, a duplicate — are returned in rejected_recipients with a reason, while the valid ones go ahead. If none is valid, the request fails with 422 no_valid_recipients; if there are not enough credits, with 402 insufficient_credits and nothing is sent.
{
"success": true,
"code": "messages_accepted",
"message": "1 message accepted.",
"data": {
"batch_id": "0192c0d1-7c3e-7a52-9f31-2b6c8a1d4e90",
"status": "queued",
"accepted": 1,
"rejected": 0,
"encoding": "gsm7",
"segments_per_message": 1,
"credits_reserved": 1,
"messages": [
{
"id": "0192c0d1-7c3e-7a52-9f31-2b6c8a1d4e91",
"to": "+255712345678",
"status": "queued",
"client_reference": "ORDER-1001"
}
],
"rejected_recipients": []
},
"request_id": "0192c0d1-7c3e-7a52-9f31-2b6c8a1d4e92"
}Endpoints
| Endpoint | What it does | Scope |
|---|---|---|
| POST /v1/messages | Send one or many messages | messages:send |
| POST /v1/messages/estimate | Encoding, segments and credits for a text | messages:send |
| GET /v1/messages/<id> | Status, segments and timestamps of one message | messages:read |
| GET /v1/messages | List and filter message history (cursor paging) | messages:read |
| GET /v1/balance | Available and reserved credits | balance:read |
| GET /v1/sender-ids | Your approved sender IDs | sender_ids:read |
| GET · POST · DELETE /v1/webhooks | Create, list and delete webhooks | webhooks:manage |
| GET /v1/openapi.yaml | The OpenAPI 3.1 contract | public |
Message statuses
"Submitted" means the operator network accepted the message. It is never reported as delivered until the handset confirms.
| Status | Meaning |
|---|---|
| scheduled | Waiting for its scheduled time. |
| queued | Accepted; credits reserved; waiting to be sent. |
| submitting | Being handed to the operator network. |
| submitted | Accepted by the operator network — not yet delivered. |
| delivered | The handset confirmed receipt. |
| undeliverable | The network could not deliver it (for example an invalid or unreachable number). |
| expired | The validity period ended before delivery. |
| rejected | Rejected by the network or before sending. |
| cancelled | Cancelled before it was sent; credits released. |
| unknown | No final receipt arrived in the waiting period. |
Idempotency keys
Send an Idempotency-Key header (up to 100 characters) with every send. A retry with the same key and body returns the original response, flagged Idempotent-Replayed: true; the same key with a different body returns 409 idempotency_key_reused. Keys are remembered for 24 hours, so a network timeout can never send an SMS twice.
Errors and limits
- Every error has the same JSON shape with a stable code — for example
insufficient_credits (402),validation_failed (422)orrate_limited (429). - Every response carries
X-Request-IdandX-RateLimit-*headers; a 429 addsRetry-After. - The default limit is 20 requests per second per key (burst 40), and message throughput is also capped per organisation.
- Unknown JSON fields are rejected, and request bodies are limited to 1 MiB.
Webhooks and signature verification
LundoSMS sends a signed POST to your server on every message status change. Verify the signature before you trust the body.
Headers on every delivery
| Field | Meaning |
|---|---|
| X-LundoSMS-Event | The event type, for example message.status. |
| X-LundoSMS-Delivery | A UUID that stays the same across retries — use it to ignore duplicates. |
| X-LundoSMS-Signature | t=<unix time>,v1=<hex HMAC-SHA256> |
How to verify a delivery
- Take the raw request body, before any JSON parsing.
- Read
tandv1from theX-LundoSMS-Signatureheader. - Compute HMAC-SHA256 of
<t>.<raw body>with your webhook secret and compare it withv1using a constant-time comparison. - Reject the request if
tis more than five minutes old.
X-LundoSMS-Signature
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyLundoSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false; // older than 5 minutes
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def verify_lundo_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if "t" not in parts or "v1" not in parts:
return False
if abs(time.time() - int(parts["t"])) > 300: # older than 5 minutes
return False
signed = parts["t"].encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])<?php
function verifyLundoSignature(string $rawBody, string $header, string $secret): bool
{
parse_str(str_replace(',', '&', $header), $parts);
if (!isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > 300) {
return false; // missing, or older than 5 minutes
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}A non-2xx response or a timeout over 10 seconds is retried after 10 s, 30 s, 2 min, 10 min, 30 min, 1 h, 3 h and 6 h — eight attempts — and then the delivery is marked dead and shown in your dashboard with a retry button.
Example event
{
"id": "evt_0192c0e5a1b2",
"type": "message.status",
"created_at": "2026-10-17T09:31:04Z",
"data": {
"message_id": "0192c0d1-7c3e-7a52-9f31-2b6c8a1d4e91",
"to": "+255712345678",
"status": "delivered",
"segments": 1,
"client_reference": "ORDER-1001",
"error_code": null,
"submitted_at": "2026-10-17T09:30:01Z",
"delivered_at": "2026-10-17T09:31:03Z"
}
}Test before you go live
Test keys (lsk_test_…) run the same validation and return real references, with simulated delivery receipts. No SMS leaves the platform and no credits are used. Switch to a live key when you are ready.
More inside your dashboard
The Developer section of your dashboard shows your own base URL, approved sender IDs and keys, the OpenAPI reference and ready-to-copy samples in cURL, Go, PHP, Node.js and Python.
API questions
Can I send from my own software?
Yes. The REST API accepts a single HTTPS request with an API key, supports idempotency keys so a retry never sends twice, and reports status changes by signed webhook. Test keys let you try everything without sending real messages.
Can I try it before buying credits?
Yes. Create an account, then use a test API key: it goes through the same validation and returns real references, but nothing is sent to a phone and no credits are used.
How do I know a message was delivered?
Every message has its own status: queued, sent to network, delivered, or failed. "Sent to network" means the operator accepted the message; "delivered" means the handset confirmed it. You can see statuses in the dashboard, export them, or receive them by webhook or SMPP. A phone that is switched off or out of coverage may receive a message late or never — no provider can guarantee delivery when the operator does not confirm it.
Ready to reach every phone in Tanzania?
Create your account, register your sender name and send your first message today. Buy the SMS credits you need — they never expire.
Prepaid credits only. Prices in TZS, VAT included.