Build with TumaSend
A single, consistent API to send SMS, WhatsApp messages, transactional email, and OTP codes to customers across Malawi and beyond.
Authentication
Every request must include your API key in the x-api-key header. Each key is scoped to a single asset — an SMS Sender ID, WhatsApp number, Email domain, or OTP service.
Scopes
| Scope | Description |
|---|---|
| sms:send | Send SMS via an approved Sender ID |
| whatsapp:send | Send WhatsApp messages via a registered number |
| email:send | Send transactional email from a verified domain |
| otp:send | Issue and verify OTP codes via an OTP service |
Base URL & Versioning
All endpoints are prefixed with /api/v1/. We never break backward compatibility within a major version.
| Version | Status | Prefix |
|---|---|---|
| v1 | Current | /api/v1/ |
Rate Limits
Limits are per API key on a sliding window. Exceeding a limit returns 429.
| Window | Default limit |
|---|---|
| Per second | 10 |
| Per minute | 300 |
| Per day | 50,000 |
Environments
| Key prefix | Mode | Delivery | Credits |
|---|---|---|---|
| ts_live_ | Live | Delivered to real recipients | Deducted |
| ts_test_ | Test | Simulated — not delivered | Free |
Send SMS
Send one message to multiple recipients in a single request. All recipients are batched together and a batch_id is returned for delivery tracking.
Request Body
| Field | Type | Description | |
|---|---|---|---|
| from | string | required | Sender name matching the Sender ID bound to your key. |
| recipients | string[] | required | E.164 numbers — Malawi format: +265XXXXXXXXX. Max 1,000. |
| message | string | required | Message body. Max 160 chars single SMS, up to 918 chars concatenated. |
curl -X POST https://gateway.tumasend.com/api/v1/send/sms \
-H "Content-Type: application/json" \
-H "x-api-key: ts_live_your_key" \
-d '{
"from": "TumaSend",
"recipients": ["+265991234567", "+265881234567"],
"message": "Hello! Your order is ready for pickup."
}'
const res = await fetch('https://gateway.tumasend.com/api/v1/send/sms', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': 'ts_live_your_key',
},
body: JSON.stringify({
from: 'TumaSend',
recipients: ['+265991234567'],
message: 'Hello! Your order is ready.',
}),
});
const { batch_id, credits_remaining } = await res.json();
import requests
data = requests.post(
"https://gateway.tumasend.com/api/v1/send/sms",
headers={"x-api-key": "ts_live_your_key"},
json={
"from": "TumaSend",
"recipients": ["+265991234567"],
"message": "Hello! Your order is ready.",
},
).json()
print(data["batch_id"])
$ch = curl_init('https://gateway.tumasend.com/api/v1/send/sms');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ts_live_your_key',
],
CURLOPT_POSTFIELDS => json_encode([
'from' => 'TumaSend',
'recipients' => ['+265991234567'],
'message' => 'Hello! Your order is ready.',
]),
]);
$data = json_decode(curl_exec($ch), true);
Response 201
{
"batch_id": "550e8400-e29b-41d4-a716-446655440000",
"success": true,
"total_recipients": 2,
"queued": 2,
"invalid_recipients": [],
"credits_used": 2,
"credits_remaining": 998,
"environment": "live"
}
Send WhatsApp
Send a WhatsApp text to recipients with WhatsApp installed. Numbers must be E.164 format.
Request Body
| Field | Type | Description | |
|---|---|---|---|
| recipients | string[] | required | E.164 phone numbers. Max 1,000. |
| message | string | required | Message text. Supports *bold*, _italic_ WhatsApp formatting. |
curl -X POST https://gateway.tumasend.com/api/v1/send/whatsapp \
-H "Content-Type: application/json" \
-H "x-api-key: ts_live_your_key" \
-d '{"recipients":["+265991234567"],"message":"*Order #1234* is ready!"}'
await fetch('https://gateway.tumasend.com/api/v1/send/whatsapp', {
method: 'POST',
headers: { 'x-api-key': 'ts_live_your_key', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipients: ['+265991234567'], message: '*Order #1234* is ready!' }),
});
Send Email
Send transactional email from a TumaSend-verified domain. Supports HTML/text bodies, CC/BCC, reply-to, and analytics tags.
Request Body
| Field | Type | Description | |
|---|---|---|---|
| to | string | string[] | required | Recipient email(s). Max 50. |
| from | string | required | Sender address on your verified domain. Accepts Name <addr@domain.com>. |
| subject | string | required | Email subject line. |
| html | string | optional* | HTML body. At least one of html or text required. |
| text | string | optional* | Plain text fallback. |
| reply_to | string | optional | Reply-To address. |
| cc / bcc | string[] | optional | CC and BCC recipients. |
| tags | object | optional | Key/value tags for analytics, e.g. {"flow":"order-confirm"}. |
curl -X POST https://gateway.tumasend.com/api/v1/send/email \
-H "Content-Type: application/json" \
-H "x-api-key: ts_live_your_key" \
-d '{
"to": "customer@example.com",
"from": "orders@yourdomain.com",
"subject": "Your order is confirmed",
"html": "<h1>Confirmed!</h1><p>Thanks for your order.</p>"
}'
const res = await fetch('https://gateway.tumasend.com/api/v1/send/email', {
method: 'POST',
headers: { 'x-api-key': 'ts_live_your_key', 'Content-Type': 'application/json' },
body: JSON.stringify({
to: 'customer@example.com',
from: 'orders@yourdomain.com',
subject: 'Your order is confirmed',
html: '<h1>Confirmed!</h1>',
tags: { flow: 'order-confirm' },
}),
});
Send OTP
Generate and deliver a one-time password via SMS, WhatsApp, or Email. The code is stored server-side and expires automatically.
Request Body
| Field | Type | Description | |
|---|---|---|---|
| recipient | string | required | Phone (E.164) or email address. |
| channel | string | optional | sms (default) · whatsapp · email |
| expiry_seconds | integer | optional | OTP lifetime. Default 300 (5 min). Max 3600. |
| length | integer | optional | Digit count. Default 6. Range 4–8. |
curl -X POST https://gateway.tumasend.com/api/v1/otp/send \
-H "Content-Type: application/json" \
-H "x-api-key: ts_live_your_key" \
-d '{"recipient":"+265991234567","channel":"sms","length":6}'
const { otp_id } = await fetch('https://gateway.tumasend.com/api/v1/otp/send', {
method: 'POST',
headers: { 'x-api-key': 'ts_live_your_key', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient: '+265991234567', channel: 'sms' }),
}).then(r => r.json());
// Store otp_id — you'll need it for /otp/verify
Response 201
{ "otp_id": "otp_abc123def456", "channel": "sms", "expires_at": "2026-06-04T09:05:00Z" }
Verify OTP
Confirm a code submitted by the user. A verified OTP cannot be reused.
Request Body
| Field | Type | Description | |
|---|---|---|---|
| otp_id | string | required | The otp_id returned by Send OTP. |
| code | string | required | The 4–8 digit code the user entered. |
curl -X POST https://gateway.tumasend.com/api/v1/otp/verify \
-H "Content-Type: application/json" \
-H "x-api-key: ts_live_your_key" \
-d '{"otp_id":"otp_abc123def456","code":"482910"}'
const { verified } = await fetch('https://gateway.tumasend.com/api/v1/otp/verify', {
method: 'POST',
headers: { 'x-api-key': 'ts_live_your_key', 'Content-Type': 'application/json' },
body: JSON.stringify({ otp_id: 'otp_abc123def456', code: '482910' }),
}).then(r => r.json());
if (verified) { /* identity confirmed */ }
Get Balance
Retrieve the current credit balance for all channels on your tenant account.
curl https://gateway.tumasend.com/api/v1/balance \
-H "x-api-key: ts_live_your_key"
Response 200
{ "sms_credits": 1000, "whatsapp_credits": 500, "email_credits": 2000, "environment": "live" }
List Batches
List sent message batches, ordered by most recent. Use the batch_id from a send response to retrieve delivery status.
Query Parameters
| Param | Type | Description |
|---|---|---|
| page | integer | Page number. Default 1. |
| limit | integer | Results per page. Default 20, max 100. |
| channel | string | Filter: sms · whatsapp · email |
Webhooks
TumaSend sends signed HTTP POST requests to your endpoint when events occur. Configure your URL from Settings → Webhooks.
X-TumaSend-Signature header (HMAC-SHA256). Reject any request that fails validation.
Signature Verification
const crypto = require('crypto');
function verify(rawBody, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature), Buffer.from(expected)
);
}
import hmac, hashlib
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
Event Reference
| Event type | When it fires |
|---|---|
| message.delivered | SMS or WhatsApp confirmed delivered to handset |
| message.failed | Delivery failed (invalid number, network error) |
| message.bounced | Email hard-bounced |
| email.unsubscribed | Recipient clicked unsubscribe |
| email.domain.verified | All DNS records verified for an email domain |
| otp.verified | OTP code successfully verified |
Payload shape
{
"event_type": "message.delivered",
"created_at": "2026-06-04T08:32:00Z",
"payload": {
"batch_id": "550e8400-e29b-41d4-a716-446655440000",
"recipient": "+265991234567",
"channel": "sms",
"status": "delivered"
}
}
Error Codes
All errors return a consistent JSON shape. The error field is machine-readable; message is human-readable.
{ "error": "insufficient_credits", "message": "Not enough SMS credits", "credits_required": 5 }
HTTP Status Codes
| Status | Meaning |
|---|---|
| 200 | Request succeeded |
| 201 | Resource created (message sent / batch queued) |
| 400 | Validation error in request body |
| 401 | API key missing, invalid, or revoked |
| 402 | Insufficient credits |
| 403 | Key scope doesn't cover this endpoint |
| 429 | Rate limit exceeded |
| 500 | Internal server error |
Error Strings
| String | Status | Description |
|---|---|---|
| unauthorized | 401 | API key missing or invalid |
| scope_not_allowed | 403 | Key doesn't have required scope |
| insufficient_credits | 402 | Not enough credits for the operation |
| rate_limit_exceeded | 429 | Too many requests in current window |
| invalid_recipients | 400 | One or more phone numbers invalid |
| content_blocked | 400 | Message blocked by content moderation |
| kyc_required | 403 | KYC must be approved before sending |
| otp_expired | 400 | OTP expired or already used |
| otp_invalid | 400 | OTP code does not match |
SDKs & Quickstart
No SDK? No problem. The API is straightforward — here's a minimal Node.js wrapper to get started in seconds.
// Zero dependencies — just native fetch (Node 18+)
class TumaSend {
constructor(apiKey, base = 'https://gateway.tumasend.com') {
this._key = apiKey;
this._base = base;
}
async _post(path, body) {
const res = await fetch(this._base + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': this._key },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || data.error);
return data;
}
sms(from, recipients, message) {
return this._post('/api/v1/send/sms', { from, recipients, message });
}
whatsapp(recipients, message) {
return this._post('/api/v1/send/whatsapp', { recipients, message });
}
sendOtp(recipient, channel = 'sms') {
return this._post('/api/v1/otp/send', { recipient, channel });
}
verifyOtp(otpId, code) {
return this._post('/api/v1/otp/verify', { otp_id: otpId, code });
}
}
// Usage
const ts = new TumaSend('ts_live_your_key');
await ts.sms('TumaSend', ['+265991234567'], 'Hello from TumaSend!');
const { otp_id } = await ts.sendOtp('+265991234567');
const { verified } = await ts.verifyOtp(otp_id, userEnteredCode);