GatewayGateway

Integrator 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.

Request header
Authorization: Bearer k7_Qx9mR2vN8pLwE4sT1uY0zA6bC3dF5gH9iJ
ScopeBehavior
ALL_SENDERSSend and list as any registered sender.
SINGLE_SENDEROnly the bound sender. A mismatch returns 403 SENDER_SCOPE_DENIED.
MULTI_SENDEROnly 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.

Response headers
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
Retry-After: 12

Per-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:

429 response
{
  "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.

List envelope
{
  "data": [ ... ],
  "pagination": {
    "total": 137,
    "page": 1,
    "page_size": 50,
    "has_more": true
  }
}
Query paramDefaultNotes
page11-based page index
page_size50Maximum 200

Conventions

Errors

Failures return a consistent error envelope. The X-Request-Id response header always matches request_id — include it when reporting issues.

Error envelope
{
  "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 }
}
CaseStatusCode
Missing Authorization401UNAUTHORIZED
Bad / revoked / expired API token401INVALID_API_TOKEN
Sender scope mismatch403SENDER_SCOPE_DENIED
Sender blocked403SENDER_BLOCKED
Unknown resource / route404NOT_FOUND
Invalid JSON / bad params400BAD_REQUEST
Idempotency key reused with different payload409IDEMPOTENCY_CONFLICT
No credit / no active cost412INSUFFICIENT_CREDIT
Rate limited (global or per-token)429RATE_LIMITED
Daily/monthly sender quota exceeded429QUOTA_EXCEEDED
Send temporarily unavailable502UPSTREAM_ERROR
Unhandled server error500INTERNAL_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.

Request header
Idempotency-Key: order-8812-attempt-1

The first successful (2xx) response is stored per caller + key. What happens on replay depends on the payload:

Replay with…Result
Same payloadThe stored response is returned unchanged, with header Idempotency-Replayed: true. No message is sent.
Different payload409 IDEMPOTENCY_CONFLICT — pick a new key for a new request.

Choosing keys

Use a stable identifier from your own system (an order ID, a job ID). Generate a new key for each logically distinct send.

Endpoints

Health

Public probes — no authentication required.

GET/v1/healthPublic
200 response
{ "status": "ok" }
GET/v1/readyPublic
200 response
{ "status": "ready" }

/ready returns 503 with an error envelope if the database is unreachable.

Endpoints

Messages

Send a message

POST/v1/messagesAPI token
Request
POST /v1/messages HTTP/1.1
Authorization: Bearer k7_Qx9mR2vN8pLwE4sT1uY0zA6bC3dF5gH9iJ
Content-Type: application/json

{
  "message": "Hello",
  "sender_id": "TAG",
  "contacts": ["265888123456"]
}
201 response
{
  "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

FieldTypeDescription
send_atepoch msFuture timestamp — the send is scheduled and the API returns 202 (see Queued & scheduled).
template_idnumberRender a stored template instead of message.
variablesobjectValues for the template's {{placeholder}} markers.
Scheduled, templated send
{
  "sender_id": "TAG",
  "contacts": ["265888123456"],
  "template_id": 3,
  "variables": { "name": "Alice", "amount": "K5,000" },
  "send_at": 1720455300000
}

Extended send

POST/v1/messages/extendedAPI token

Per-recipient message bodies. The top-level message is null; the recipients array is authoritative. Also accepts send_at, template_id, and per-recipient variables:

Request
{
  "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

GET/v1/messages?sender_id=1&page=1&page_size=50&from=&to=API token
200 response
{
  "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

GET/v1/messages/{id}API token
200 response
{
  "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:

202 response
{
  "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

GET/v1/messages/queued?status=QUEUED&sender_id=&page=1API token
POST/v1/messages/queued/{id}/retryAPI token
DELETE/v1/messages/queued/{id}API token
ActionAllowed whenEffect
RetryFAILEDRequeues immediately (attempts reset)
CancelSCHEDULED, QUEUED, or FAILEDMarks the entry CANCELLED; it will never dispatch

Credit is re-checked at dispatch

The sender's credit is verified again when the background dispatcher picks the entry up. A credit failure marks the row FAILED with a clear last_error. Terminal outcomes emit message.queued.sent / message.queued.failed webhook events.

Endpoints

Senders & credit

List senders

GET/v1/senders?page=1&page_size=50API token

Read-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

If a sender is blocked, any send as that sender returns 403 SENDER_BLOCKED.

Sender-scoped credit & cost

Subject to your token's sender scope.

MethodPathDescription
GET/v1/senders/{id}/creditCurrent remaining credit summary
GET/v1/senders/{id}/creditsCredit 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}/usageHow much credit has been spent so far
POST/v1/senders/{id}/topupsAdd credit (token needs can_topup)
GET/v1/senders/{id}/costActive cost
GET/v1/senders/{id}/costsCost history (paginated)

Usage so far

GET/v1/senders/{id}/usageAPI token

How 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.

200 response
{
  "sender_id": 1,
  "credited": 6500.0,
  "used": 85.0,
  "remaining": 6415.0,
  "messages": 3
}
FieldMeaning
creditedSum of every credit ever added to the sender (top-ups and credit added from the dashboard).
usedSum 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.
remainingcredited − used, never below zero. Matches the figure from GET …/credit.
messagesRecipient messages billed so far.

What counts as used

A message is billed when the gateway accepts it, including recipients that later fail to deliver. Usage is the total ever billed, not a time window.

Top up credit

POST/v1/senders/{id}/topupsAPI token with can_topup

Adds 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).

Request
{
  "amount": 5000,
  "reference": "chg_8f2c1d",
  "notes": "SMS top-up"
}
FieldRules
amountGreater than 0 and at most 10,000,000 (whole currency units).
referenceRequired, 1–128 characters, unique across all credits. Your payment id works well. It is what makes the call idempotent.
notesOptional, up to 500 characters.
201 response
{
  "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

A 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.
429 response
{
  "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

Repeating a call with the same 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).

GET/v1/templates?page=1API token
GET/v1/templates/{id}API token

Using 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.

Personalized bulk send
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).

EventFired when
message.delivery_updatedA recipient’s delivery status changes (delivered, failed, expired…)
message.queued.sentA queued/scheduled send finally dispatches successfully
message.queued.failedA queued send exhausts its retries or fails a credit check
batch.completedEvery recipient of one send has reached a final state (delivered, failed or expired). Fired once per send.

Register an endpoint

GET/v1/webhooksAPI token
POST/v1/webhooksAPI token
Request
{
  "url": "https://example.com/webhooks/sms",
  "events": ["message.delivery_updated"],
  "sender_id": 1
}

Secret shown once

The create response includes the signing secret (whsec_…) exactly once. Store it — you need it to verify signatures.
DELETE/v1/webhooks/{id}API token
GET/v1/webhooks/{id}/deliveries?page=1API token

What you receive

Delivery request
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.

Delivery body
{
  "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.

Node.js example
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

Respond with any 2xx quickly. Non-2xx responses are retried with backoff (1m, 5m, 15m, 1h, 3h, 6h, 12h; max 8 attempts).
All amounts are in MWK.

SMS Gateway · Dashboard