https://sms.tag.mw/v1Integrator reference
SMS Gateway API
Integrate SMS sending into your product with an API token. This page covers only the endpoints available to integrators — send messages, track delivery, manage webhooks, and check credit.
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, /usage, /cost, /costs. A token created with can_topup may also add credit to its senders with POST /v1/senders/{id}/topups. Every other credit change is admin-only.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, newest first). Each row carries an external_ref: the reference of the top-up that created it, or null for credit added from the dashboard. |
| GET | /v1/senders/{id}/usage | How much credit has been spent so far |
| POST | /v1/senders/{id}/topups | Add credit (token needs can_topup) |
| GET | /v1/senders/{id}/cost | Active cost |
| GET | /v1/senders/{id}/costs | Cost history (paginated) |
Usage so far
/v1/senders/{id}/usageAPI tokenHow much of a sender's credit has been spent, and what is left. Use it to show customers “used so far” next to their balance.
{
"sender_id": 1,
"credited": 6500.0,
"used": 85.0,
"remaining": 6415.0,
"messages": 3
}| Field | Meaning |
|---|---|
credited | Sum of every credit ever added to the sender (top-ups and credit added from the dashboard). |
used | Sum of what each recipient message was billed. Every SMS is counted at the price in force when it was sent, so changing the sender’s price later never rewrites past usage. |
remaining | credited − used, never below zero. Matches the figure from GET …/credit. |
messages | Recipient messages billed so far. |
What counts as used
Top up credit
/v1/senders/{id}/topupsAPI token with can_topupAdds credit to a sender, for example after your own payment provider confirms a customer's payment. Your token must have been created with can_topup: true and be allowed to use the sender (otherwise 403 FORBIDDEN).
{
"amount": 5000,
"reference": "chg_8f2c1d",
"notes": "SMS top-up"
}| Field | Rules |
|---|---|
amount | Greater than 0 and at most 10,000,000 (whole currency units). |
reference | Required, 1–128 characters, unique across all credits. Your payment id works well. It is what makes the call idempotent. |
notes | Optional, up to 500 characters. |
{
"credit_id": 311,
"sender_id": 1,
"amount": 5000.0,
"reference": "chg_8f2c1d",
"created": true,
"remaining_credit": 5120.0,
"provider_shortfall": false,
"provider_balance_known": true,
"provider_balance": 16116.07,
"provider_outstanding": 7668.57,
"created_at": 1720369200000
}Daily limit per token
can_topup token can add only so much credit per rolling 24 hours: its own topup_daily_limit, or the gateway default. A top-up that would pass it is refused with 429 QUOTA_EXCEEDED and nothing is credited. The response says how much is remaining. Retrying a reference that is already credited never counts against the limit.{
"code": "QUOTA_EXCEEDED",
"message": "This token has reached its 24-hour top-up limit",
"details": { "limit": 10000.0, "used_24h": 10000.0, "remaining": 0.0 }
}Safe to retry
reference returns 200 with "created": false and the original credit. It never adds a second credit, even if the amount differs. Use the same reference for webhook redeliveries and retries. A reference that was already used for a different sender returns 409.Provider balance
provider_shortfall is truewhen the SMS provider's balance (provider_balance) is below the wholesale cost of all credit now held by every sender (provider_outstanding). The credit is still booked. While the provider cannot pay for a send, new sends are held in the queue (202) and released automatically once the provider is topped up. If the provider balance cannot be read, sends are never held and provider_balance_known is false.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 |
batch.completed | Every recipient of one send has reached a final state (delivered, failed or expired). Fired once per send. |
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"
}
}Batch completed
Tells you a whole send is finished, so you can notify your own users once instead of tracking every recipient. sms_id is the id returned when you sent. failed counts recipients that failed outright and expired those that never confirmed.
{
"event": "batch.completed",
"timestamp": 1720369800000,
"data": {
"sms_id": 42,
"sender_id": 1,
"total": 50,
"delivered": 48,
"failed": 2,
"expired": 0,
"completed_at": 1720369800000
}
}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