Quickstart
Your account needs approved KYC, an approved sender ID and credits. Then:
- 1Create an API key in the portal: API keys.
- 2Send a message. Examples use our default sender
BULK SMS TZ— use your own approved sender ID.
curl -X POST https://bulksms.oasistech.co.tz/v1/sms \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: otp-48213-1" \
-d '{"to": "+255712345678", "sender": "BULK SMS TZ", "message": "Your BulkSMS code is 482913."}'
Authentication
Send your key on every request: Authorization: Bearer YOUR_API_KEY. Keys are shown once, expire after 365 days by default, and can be limited to listed IP addresses.
| Scope | Allows |
|---|---|
messages:send | Send, schedule and quote — POST /v1/sms. Enough for OTP. |
messages:read | Message history — GET /v1/logs |
balance:read | Balance — GET /v1/balance |
/api/... works too and is the same endpoint as /v1/.... There is no /sms/send.
/v1/smsSend SMS
One request sends one message to one or many numbers.
| Field | Type | Description |
|---|---|---|
to required | string · array | A number, an array of numbers, or a comma-separated string. 0712345678 and +255712345678 both work. |
sender required | string | Your approved sender ID, exactly as registered. |
message required | string | The text. May contain {placeholders}. |
params | object | Values for placeholders, e.g. {"name": "Amina"}. |
scheduled_at | ISO-8601 | Send later: 30 seconds to 90 days ahead. Answers 202 scheduled. |
dry_run | boolean | true = show the cost, send nothing. |
Several recipients, personalised
{
"to": [
{"number": "0712345678", "params": {"name": "Amina"}},
{"number": "0754901234", "params": {"name": "Juma"}}
],
"sender": "BULK SMS TZ",
"message": "Habari {name}, your order is ready."
}
Responses
| HTTP | status | Meaning |
|---|---|---|
| 200 | sent | Accepted by the network gateway. |
| 202 | pending · processing | Gateway slow to confirm — most likely sent. Do not resend. |
| 202 | scheduled | Queued for scheduled_at. |
| 202 | processing + background | Over 10,000 recipients: accepted, going out now. Results in logs and webhooks. Do not resend. |
| 502 | failed · partial | Refused by the gateway. Failed recipients are not charged. |
One recipient — 200
{
"number": "+255712345678",
"status": "sent",
"sms_log_id": 123456,
"credits_used": 1,
"sms_response": {"msg_id": "1880000001", "number": "255712345678", "str_response": "Send Successful"},
"provider_ref": "1880000001",
"credit_info": {"total_recipients": 1, "credits_charged": 1, "balance_after": 6, "sandbox": false}
}
Several recipients: {"status", "sms_log_id", "recipients": [ ...one object per number... ], "credit_info"}. A failed recipient has "status": "failed" and an error.
More than 10,000 recipients — 202
{
"status": "processing",
"background": true,
"sms_log_id": 123460,
"recipients_count": 12000,
"background_notice": "Large send accepted: 12,000 recipients are going out now. ...",
"credit_info": {"total_recipients": 12000, "credits_charged": 12000, "balance_after": 3000, "sandbox": false}
}
Answered at once; the list goes out right after. Follow sms_log_id in GET /v1/logs or with webhooks. Refused numbers are refunded.
Idempotency-Key
Send one with every message, so a retry can never send twice.
| Header | Idempotency-Key (or body field idempotency_key) |
|---|---|
| Length | 1–64 characters (maximum 64), visible ASCII, no spaces — e.g. a UUID |
| Same key, same request | Returns the original response again with "idempotent_replay": true. Nothing is sent or charged twice. |
| Same key, different request | 422 — use a new key for every new message |
| Kept for | 30 days. After that the key answers 409 (already sent once). |
| Refused requests | 400 · 401 · 402 · 403 · 429 do not use up the key — fix and retry with the same key. |
Timeout or network error? Retry with the same key. Allow up to 180 seconds for a send.
Errors
Errors return {"error": "..."}, sometimes with a hint. Nothing is sent or charged.
| HTTP | Meaning | What to do |
|---|---|---|
| 400 | Missing field, invalid number, sender format or key format | Fix the request |
| 401 | Missing, invalid or expired API key | Check the key |
| 402 | Not enough credits | Top up |
| 403 | KYC not approved, sender not approved, scope missing or IP not allowed | See error |
| 409 | Idempotency-Key already used — the message was sent once | Do not resend |
| 415 | Missing Content-Type: application/json | Add the header |
| 422 | All numbers opted out, or key reused for a different request | See error |
| 429 | Too many requests | Wait Retry-After seconds, retry with the same key |
| 503 | Gateway temporarily unavailable | Retry later with the same key |
/v1/balancebalance:readBalance
{"credits": 1234}
/v1/logs?type=smsmessages:readMessages
Your sent messages, newest first. Filters: page, limit (max 100), search, status, from, to (YYYY-MM-DD).
{
"page": 1, "limit": 50, "total": 1, "total_pages": 1,
"data": [
{"id": 123456, "recipient": "+255712345678", "sender": "BULK SMS TZ", "message": "Your BulkSMS code is 482913.",
"status": "sent", "credits_used": 1, "segments": 1, "source": "api", "created_at": "2026-10-08 09:15:02"}
]
}
id is the sms_log_id from the send. A send to several numbers is one row; its per-number results are in recipients_json.
/v1/sender-idsSender IDs
Your sender ID requests. Request new ones in the portal.
{"sender_ids": [{"id": 42, "sender": "BULK SMS TZ", "status": "approved", "live": 1, "created_at": "2026-10-01 10:00:00"}],
"page": 1, "limit": 25, "total": 1, "total_pages": 1}
Webhooks
Get an HTTP POST on your server when a message is sent or fails.
POST /v1/webhooks?action=create
{"url": "https://example.com/bulksms-hook", "events": "sms.delivered,sms.pending,sms.failed,credits.low"}
→ 201 {"id": 7, "url": "https://example.com/bulksms-hook", "events": "...", "secret": "save-this-secret"}
| Event | When |
|---|---|
sms.delivered | A recipient was accepted by the gateway |
sms.pending | Gateway slow to confirm — most likely sent |
sms.failed | A recipient was refused (not charged) |
credits.low | Balance fell below 500 credits |
Payload
{"id": "evt_3f9c1a2b7d4e5f6a7b8c9d0e", "type": "sms.delivered", "created_at": "2026-10-08T09:15:03+03:00", "tenant_id": 101,
"data": {"sms_log_id": 123456, "recipient": "+255712345678", "sender": "BULK SMS TZ", "status": "sent",
"credits_used": 1, "provider_ref": "1880000001", "error": null}}
Verify the signature
Header X-BulkSMS-Signature = HMAC-SHA256 (hex) of the raw body, keyed with your secret.
const crypto = require("crypto");
const sig = crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(req.get("X-BulkSMS-Signature") || ""));
Answer 2xx within 10 seconds. Otherwise we retry after 1 min, 5 min, 30 min, 2 h and 12 h. Use X-BulkSMS-Event-Id to skip duplicates.
Limits & cost
| Rate limit | 300 send requests per minute per account. Put many numbers in one request. |
|---|---|
| Client timeout | Allow 180 seconds for a send |
| Large sends | Over 10,000 recipients: answered at once (202, background: true), sent right after |
| Standard text | 160 characters per SMS part; 153 per part when longer |
| Unicode | 70 per part; 63 when longer — emoji, accents, curly quotes ‘ ’ “ ”, dashes – — |
| Cost | 1 credit × parts × recipients. dry_run shows it first. |
AI assistants
Give your coding assistant (Claude, ChatGPT, Copilot, Cursor) one of these files — they describe the whole API.
https://bulksms.oasistech.co.tz/llms-full.txt
Changelog
- 2026-10-08 — Over 10,000 recipients: answered at once (202,
background: true). Allow 180 s for a send (was 100). Webhooks now go out after the reply. - 2026-10-08 — Same Idempotency-Key with a different request → 422. Keys 1–64 characters. Unconfirmed sends answer 202
pendingand firesms.pending. Sends to several numbers returnsms_log_id. - 2026-09 — A repeated Idempotency-Key returns the original response. Limit raised to 300 requests per minute.