Getting started
Introduction
The SMS Gateway exposes a REST API under the /v1 prefix. Requests and responses are JSON. Authenticate with an API token issued from the dashboard.
Base URL: https://sms.tag.mw/v1
Getting started
Authentication
Every request (except public health checks) must carry your API token as a bearer token in the Authorization header. Tokens are opaque 256-bit secrets (URL-safe Base64), shown once at creation.
Authorization: Bearer k7_Qx9mR2vN8pLwE4sT1uY0zA6bC3dF5gH9iJ| Scope | Behavior |
|---|---|
ALL_SENDERS | Send and list as any registered sender. |
SINGLE_SENDER | Only the bound sender. A mismatch returns 403 SENDER_SCOPE_DENIED. |
MULTI_SENDER | Only the listed senders (2–5). Create with sender_ids. A mismatch returns 403 SENDER_SCOPE_DENIED. |
What your token can call
/v1/messages* (including queued-send management), /v1/webhooks* (your own endpoints), /v1/templates (read-only), GET /v1/senders (list, read-only), and the sender-scoped reads /v1/senders/{id}/credit, /credits, /cost, /costs.Getting started
Rate limiting & quotas
Requests are limited with a global token bucket: capacity 300, refilled over 60s. When throttled you receive 429 RATE_LIMITED.
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
Retry-After: 12Per-token rate limits
Your token may also carry a rate_limit_per_minute set when it was created. Exceeding it returns the same 429 RATE_LIMITED error, independently of the global limiter.
Sender quotas
Each sender can be capped to a maximum number of recipients per calendar day and/or month. A send that would exceed a quota is rejected with 429 QUOTA_EXCEEDED:
{
"code": "QUOTA_EXCEEDED",
"message": "Daily quota exceeded for sender 'TAG'",
"request_id": "...",
"details": { "limit": 1000, "used": 998, "requested": 5 }
}Conventions
Lists & pagination
All collection endpoints return the same list envelope.
{
"data": [ ... ],
"pagination": {
"total": 137,
"page": 1,
"page_size": 50,
"has_more": true
}
}| Query param | Default | Notes |
|---|---|---|
page | 1 | 1-based page index |
page_size | 50 | Maximum 200 |
Conventions
Errors
Failures return a consistent error envelope. The X-Request-Id response header always matches request_id — include it when reporting issues.
{
"code": "SENDER_SCOPE_DENIED",
"message": "This API token cannot send as the requested sender_id",
"request_id": "9f3c2a1b-aaaa-bbbb-cccc-ddddeeeeffff",
"details": { "allowed_sender_id": 1 }
}| Case | Status | Code |
|---|---|---|
| Missing Authorization | 401 | UNAUTHORIZED |
| Bad / revoked / expired API token | 401 | INVALID_API_TOKEN |
| Sender scope mismatch | 403 | SENDER_SCOPE_DENIED |
| Sender blocked | 403 | SENDER_BLOCKED |
| Unknown resource / route | 404 | NOT_FOUND |
| Invalid JSON / bad params | 400 | BAD_REQUEST |
| Idempotency key reused with different payload | 409 | IDEMPOTENCY_CONFLICT |
| No credit / no active cost | 412 | INSUFFICIENT_CREDIT |
| Rate limited (global or per-token) | 429 | RATE_LIMITED |
| Daily/monthly sender quota exceeded | 429 | QUOTA_EXCEEDED |
| Send temporarily unavailable | 502 | UPSTREAM_ERROR |
| Unhandled server error | 500 | INTERNAL_ERROR |
Conventions
Idempotency
Both send endpoints (POST /v1/messages and POST /v1/messages/extended) accept an optional Idempotency-Key header so you can safely retry a request after a network failure without double-sending.
Idempotency-Key: order-8812-attempt-1The first successful (2xx) response is stored per caller + key. What happens on replay depends on the payload:
| Replay with… | Result |
|---|---|
| Same payload | The stored response is returned unchanged, with header Idempotency-Replayed: true. No message is sent. |
| Different payload | 409 IDEMPOTENCY_CONFLICT — pick a new key for a new request. |
Choosing keys
Endpoints
Health
Public probes — no authentication required.
/v1/healthPublic{ "status": "ok" }/v1/readyPublic{ "status": "ready" }/ready returns 503 with an error envelope if the database is unreachable.
Endpoints
Messages
Send a message
/v1/messagesAPI tokenPOST /v1/messages HTTP/1.1
Authorization: Bearer k7_Qx9mR2vN8pLwE4sT1uY0zA6bC3dF5gH9iJ
Content-Type: application/json
{
"message": "Hello",
"sender_id": "TAG",
"contacts": ["265888123456"]
}{
"id": 42,
"uuid": "...",
"status": "success",
"sender_id": "TAG",
"message": "Hello",
"recipient_count": 1,
"total_cost": 25.0,
"created_at": 1720368900000,
"recipients": [
{
"id": 100,
"phone": "265888123456",
"status": "success",
"cost": 25.0,
"text_id": "txt_abc"
}
]
}How costs are billed
coston each recipient is what you are charged (from the sender's active rate at send time). Remaining credit is reduced by that amount; rate changes never re-price history. text_id is the per-recipient tracking id used for delivery status.Optional request fields
| Field | Type | Description |
|---|---|---|
send_at | epoch ms | Future timestamp — the send is scheduled and the API returns 202 (see Queued & scheduled). |
template_id | number | Render a stored template instead of message. |
variables | object | Values for the template's {{placeholder}} markers. |
{
"sender_id": "TAG",
"contacts": ["265888123456"],
"template_id": 3,
"variables": { "name": "Alice", "amount": "K5,000" },
"send_at": 1720455300000
}Extended send
/v1/messages/extendedAPI tokenPer-recipient message bodies. The top-level message is null; the recipients array is authoritative. Also accepts send_at, template_id, and per-recipient variables:
{
"sender_id": "TAG",
"template_id": 3,
"messages": [
{ "phone": "265888123456", "variables": { "name": "Alice", "amount": "K5,000" } },
{ "phone": "265999654321", "variables": { "name": "Bob", "amount": "K1,200" } }
]
}List messages
/v1/messages?sender_id=1&page=1&page_size=50&from=&to=API token{
"data": [
{
"id": 42,
"uuid": "...",
"code": "200",
"msg": "Success",
"type": "success",
"sender_id": 1,
"message": "Hello",
"recipient_count": 3,
"delivered_count": 3,
"failed_count": 0,
"pending_count": 0,
"created_at": 1720368900000,
"updated_at": 1720368900000
}
],
"pagination": { "total": 120, "page": 1, "page_size": 50, "has_more": true }
}Message detail
/v1/messages/{id}API token{
"sms": { "id": 42, "uuid": "...", "sender_id": 1, "message": "Hello", "...": "..." },
"messages": [
{
"id": 100,
"sms_id": 42,
"msdn": "265888123456",
"status": 1,
"cost": 25.0,
"delivery_status": "DELIVERED",
"delivery_synced_at": 1720369200000,
"...": "..."
}
]
}Delivery status sync
Delivery status updates automatically for recent messages. Each recipient has delivery_status: PENDING, DELIVERED, FAILED, EXPIRED, or UNKNOWN. List responses include per-message rollups (delivered_count, failed_count, pending_count). Each status change emits the message.delivery_updated webhook event.
Endpoints
Queued & scheduled sends
Messages are queued and retried automatically when a send cannot complete immediately (backoff: 1m, 5m, 15m, then 1h; max 5 attempts). Scheduled sends (send_at in the future) go straight to the queue as SCHEDULED. In both cases the API responds 202:
{
"id": 7,
"uuid": "...",
"status": "queued",
"sender_id": "TAG",
"recipient_count": 1,
"send_at": null,
"next_attempt_at": 1720369000000,
"last_error": "Send temporarily unavailable",
"created_at": 1720368900000
}Queue entries move through: SCHEDULED → QUEUED → SENDING → SENT (or FAILED after max attempts). CANCELLED is terminal.
Manage the queue
/v1/messages/queued?status=QUEUED&sender_id=&page=1API token/v1/messages/queued/{id}/retryAPI token/v1/messages/queued/{id}API token| Action | Allowed when | Effect |
|---|---|---|
| Retry | FAILED | Requeues immediately (attempts reset) |
| Cancel | SCHEDULED, QUEUED, or FAILED | Marks the entry CANCELLED; it will never dispatch |
Credit is re-checked at dispatch
FAILED with a clear last_error. Terminal outcomes emit message.queued.sent / message.queued.failed webhook events.Endpoints
Senders & credit
List senders
/v1/senders?page=1&page_size=50API tokenRead-only. Use this to resolve a sender's numeric id for the credit/cost sub-resources below, which are keyed by numeric id, not the string sender_id.
Blocked senders
403 SENDER_BLOCKED.Sender-scoped credit & cost
Subject to your token's sender scope.
| Method | Path | Description |
|---|---|---|
| GET | /v1/senders/{id}/credit | Current remaining credit summary |
| GET | /v1/senders/{id}/credits | Credit history (paginated) |
| GET | /v1/senders/{id}/cost | Active cost |
| GET | /v1/senders/{id}/costs | Cost history (paginated) |
Endpoints
Templates
Reusable message bodies with {{placeholder}} variables. Integrators have read-only access within their sender scope. A template can be bound to one sender or shared across all (sender_id: null).
/v1/templates?page=1API token/v1/templates/{id}API tokenUsing a template in a send
Pass template_id instead of message on either send endpoint. On POST /v1/messages, supply one shared variables object; on POST /v1/messages/extended, supply variables per recipient for personalized bodies.
POST /v1/messages/extended HTTP/1.1
{
"sender_id": "TAG",
"template_id": 3,
"messages": [
{ "phone": "265888123456", "variables": { "name": "Alice", "amount": "K5,000" } },
{ "phone": "265999654321", "variables": { "name": "Bob", "amount": "K1,200" } }
]
}Endpoints
Webhooks
Signed HTTP callbacks pushed to your server when message states change — no polling required. API tokens manage their own endpoints (sender-bound tokens are automatically restricted to their sender).
| Event | Fired when |
|---|---|
message.delivery_updated | A recipient’s delivery status changes (delivered, failed, expired…) |
message.queued.sent | A queued/scheduled send finally dispatches successfully |
message.queued.failed | A queued send exhausts its retries or fails a credit check |
Register an endpoint
/v1/webhooksAPI token/v1/webhooksAPI token{
"url": "https://example.com/webhooks/sms",
"events": ["message.delivery_updated"],
"sender_id": 1
}Secret shown once
secret (whsec_…) exactly once. Store it — you need it to verify signatures./v1/webhooks/{id}API token/v1/webhooks/{id}/deliveries?page=1API tokenWhat you receive
POST <your url> HTTP/1.1
Content-Type: application/json
X-Webhook-Event: message.delivery_updated
X-Webhook-Delivery: 812
X-Signature: sha256=<hex HMAC-SHA256 of the raw body using your secret>
{
"event": "message.delivery_updated",
"timestamp": 1720369200000,
"data": {
"message_id": 100,
"sms_id": 42,
"sender_id": 1,
"delivery_status": "DELIVERED",
"text_id": "txt_abc"
}
}Verifying the signature
Compute an HMAC-SHA256 of the raw request body with your endpoint secret and compare it (constant time) to the hex digest in X-Signature.
import crypto from 'crypto'
function verify(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected),
)
}Retries