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, /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.

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: SCHEDULEDQUEUED SENDINGSENT (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)
GET/v1/senders/{id}/costActive cost
GET/v1/senders/{id}/costsCost 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).

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

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"
  }
}

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