Production API · Malawi

Send reliable SMS from your applications.

A server-to-server REST API for single, bulk, and personalised SMS, with asynchronous processing, idempotency protection, status tracking, and signed callbacks.

Production flow verified on 20 July 2026
01Bearer authentication

Short-lived Laravel Sanctum tokens with scoped permissions.

02Bulk and personalised

One shared message or a different message for every recipient.

03Safe retries

Idempotency keys prevent accidental duplicate submissions.

04Delivery visibility

Poll status endpoints or receive signed completion callbacks.

Overview

How the gateway works

The Ctech SMS Gateway sits between your application and the SMS provider. Your system authenticates, submits a request, stores the returned request_id, then tracks processing through polling or a callback.

1AuthenticateExchange client credentials for a token
2SubmitSend recipients, content, and idempotency key
3ProcessQueued delivery through Africa's Talking
4ReconcilePoll status or verify the signed callback
Asynchronous by design. A 202 Accepted response means the request was saved and queued. It does not yet mean every SMS has been delivered.
Quickstart

Send your first message

Request an access token, then use it to submit an SMS request. Replace all placeholder values with credentials issued privately by Ctech.

1. Request a token

cURL
curl --request POST \
  'https://sms-gateway-api.ctechapp.com/api/v1/auth/token' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data '{
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET"
  }'

2. Submit the message

cURL
curl --request POST \
  'https://sms-gateway-api.ctechapp.com/api/v1/sms/requests' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  --header 'Idempotency-Key: appointment-1042-v1' \
  --data '{
    "recipients": [
      {
        "phone": "0991234567",
        "external_reference": "patient-1042"
      }
    ],
    "message": "Reminder: your appointment is tomorrow at 09:00.",
    "sender_id": "YOUR_APPROVED_SENDER_ID",
    "metadata": {
      "source": "hms",
      "campaign": "appointment-reminders"
    }
  }'
Connection

Base URL and headers

Productionhttps://sms-gateway-api.ctechapp.com/api/v1

All requests and callbacks use JSON over HTTPS. Protected endpoints require a Bearer token.

Header Required Purpose
Accept: application/json Yes Ensures JSON error and success responses.
Content-Type: application/json POST requests Declares a JSON request body.
Authorization: Bearer <token> Protected routes Authenticates the API client.
Idempotency-Key: <unique-key> Send endpoint Prevents duplicate submissions during retries.
Reference

Endpoints

Provider-only route: POST /webhooks/africastalking/delivery is configured for Africa's Talking delivery reports. Client applications should not call it.
Authentication
POST

/auth/token

Exchange an active client_id and client_secret for a Sanctum Bearer token. Tokens contain the send:sms and read:sms-status abilities.

Request body

Field Type Rules
client_id string Required; maximum 100 characters.
client_secret string Required; maximum 255 characters.
Request
{
  "client_id": "YOUR_CLIENT_ID",
  "client_secret": "YOUR_CLIENT_SECRET"
}
200 response
{
  "status": "success",
  "access_token": "1|plain_text_token",
  "token_type": "Bearer",
  "expires_at": "2026-07-20T10:07:20.815252Z"
}
Keep credentials server-side. Never place the client secret or access token in browser JavaScript, mobile-app source code, public documentation, screenshots, or Git repositories.
Messages
POST

/sms/requests

Submit one or more Malawi mobile numbers. The endpoint accepts either one shared top-level message or a message on every recipient.

Single recipient

JSON
{
  "recipients": [
    {
      "phone": "0991234567",
      "external_reference": "invoice-8001"
    }
  ],
  "message": "Your payment of MWK 25,000 was received.",
  "sender_id": "YOUR_APPROVED_SENDER_ID"
}

One message to many recipients

Shared bulk message
{
  "recipients": [
    { "phone": "0991234567", "external_reference": "patient-1001" },
    { "phone": "0881234567", "external_reference": "patient-1002" },
    { "phone": "+265981234567", "external_reference": "patient-1003" }
  ],
  "message": "The clinic will close at 15:00 today.",
  "sender_id": "YOUR_APPROVED_SENDER_ID",
  "callback_url": "https://your-system.example/api/sms/callback",
  "metadata": {
    "source": "hms",
    "batch": "clinic-notice-20260720"
  }
}

Personalised messages

Recipient-level messages
{
  "recipients": [
    {
      "phone": "0991234567",
      "external_reference": "appointment-1001",
      "message": "Hello Mary, your appointment is at 09:00."
    },
    {
      "phone": "0881234567",
      "external_reference": "appointment-1002",
      "message": "Hello James, your appointment is at 10:30."
    }
  ],
  "sender_id": "YOUR_APPROVED_SENDER_ID",
  "metadata": {
    "source": "hms",
    "batch": "personalised-reminders"
  }
}

When using recipient-level messages, omit the top-level message. If there is no top-level message, every recipient must contain a non-empty message.

Accepted response

202 Accepted
{
  "status": "processing",
  "message": "SMS request received and is being processed",
  "request_id": "019f7ec9-5a06-71fa-965b-cf5c43c36667"
}
Save the returned request_id. It is required for both status endpoints and should be associated with your own transaction or batch record.
Schema

SMS request fields

Field Type Required Description and limits
recipients array Yes At least one recipient object.
recipients[].phone string Yes Malawi mobile number in a supported format.
recipients[].external_reference string / null No Your reference; maximum 255 characters.
recipients[].message string / null Conditional Required per recipient when no top-level message exists; 1–1000 characters.
message string / null Conditional Shared message for all recipients; 1–1000 characters.
sender_id string / null No Provider-approved sender ID; maximum 20 characters.
callback_url URL / null No HTTPS endpoint for the completion callback; maximum 500 characters. If omitted, the client's configured default is used.
metadata object / null No Application context returned in status and callback payloads. Do not include secrets or sensitive patient data.
Message length and billing: the API accepts up to 1000 characters, but long text is split into multiple SMS segments by the provider and may cost more than one SMS.
Recipients

Supported phone-number formats

The gateway removes spaces and punctuation, validates Malawi mobile prefixes, and normalises accepted values to +265....

0991234567Local format
0881234567Local format
265991234567Country code
+265881234567International format

Numbers must resolve to +265 followed by an 8 or 9 and eight additional digits.

Mixed valid and invalid numbers: valid recipients are queued while invalid ones are saved as failed. If every supplied number is invalid, the API returns 422 and sends nothing.
Reliability

Idempotency and safe retries

Every SMS submission must contain an Idempotency-Key. Generate one stable key for one logical submission and retain it when retrying after a timeout.

New key202

A new request is saved and queued.

Same key + same payload200

The original request ID is returned. No duplicate is queued.

Same key + changed payload409

The changed request is rejected.

409 Conflict
{
  "status": "error",
  "message": "Idempotency-Key has already been used with a different request payload.",
  "request_id": "019f7ec9-5a06-71fa-965b-cf5c43c36667"
}

Good keys include an order, appointment, invoice, or campaign identifier plus a version, for example invoice-8001-reminder-v1.

Tracking
GET

/sms/requests/{request_id}

Returns the request-level summary. The request belongs to the authenticated client; another client receives 403 Forbidden.

cURL
curl --request GET \
  'https://sms-gateway-api.ctechapp.com/api/v1/sms/requests/REQUEST_ID' \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
200 response
{
  "status": "success",
  "data": {
    "request_id": "019f7ec9-5a06-71fa-965b-cf5c43c36667",
    "request_status": "completed",
    "total_count": 3,
    "success_count": 2,
    "failure_count": 1,
    "message_preview": "The clinic will close at 15:00 today.",
    "sender_id": "YOUR_APPROVED_SENDER_ID",
    "callback_url": "https://your-system.example/api/sms/callback",
    "metadata": {
      "source": "hms"
    },
    "accepted_at": "2026-07-20T09:09:12.000000Z",
    "completed_at": "2026-07-20T09:09:31.000000Z",
    "created_at": "2026-07-20T09:09:12.000000Z"
  }
}
processingcompleted

completed means there are no pending recipients. Inspect recipient results to distinguish provider-accepted, delivered, and failed messages.

Tracking
GET

/sms/requests/{request_id}/recipients

Returns recipient-level status records, 100 per page. Use ?page=2 for later pages.

200 response
{
  "status": "success",
  "data": [
    {
      "id": 3,
      "sms_request_id": 3,
      "phone": "0991234567",
      "normalized_phone": "+265991234567",
      "external_reference": "patient-1001",
      "message": null,
      "status": "sent",
      "provider": "africastalking",
      "provider_message_id": "ATXid_example",
      "provider_cost": "17.0000",
      "provider_status": "Success",
      "error_reason": null,
      "sent_at": "2026-07-20T09:09:31.000000Z",
      "delivered_at": null,
      "created_at": "2026-07-20T09:09:12.000000Z",
      "updated_at": "2026-07-20T09:09:31.000000Z"
    }
  ],
  "pagination": {
    "current_page": 1,
    "per_page": 100,
    "total": 1,
    "last_page": 1
  }
}
Status Meaning Recommended handling
pending Saved but not processed by the queue yet. Poll again with backoff.
sent The provider accepted the SMS. Await a delivery report if final delivery is required.
delivered The provider reported successful handset delivery. Mark delivery complete.
failed Validation, queue, provider, or delivery failed. Inspect error_reason; retry only when appropriate.
Webhooks

Signed completion callbacks

If the request or API client has a callback URL, the gateway posts a completion summary after queue processing finishes. Callback delivery is retried and signed with the client-specific callback secret.

MethodPOST
Content typeapplication/json
Timeout15 seconds
Job attempts5

Callback headers

HTTP
Content-Type: application/json
X-SMS-Gateway-Signature: <hex-hmac-sha256>
X-SMS-Gateway-Request-Id: 019f7ec9-5a06-71fa-965b-cf5c43c36667

Callback body

JSON
{
  "status": "completed",
  "request_id": "019f7ec9-5a06-71fa-965b-cf5c43c36667",
  "success_count": 2,
  "failure_count": 1,
  "failures": [
    {
      "phone": "+265881234567",
      "reason": "Invalid phone number format",
      "external_reference": "patient-1002"
    }
  ],
  "metadata": {
    "source": "hms",
    "batch": "clinic-notice-20260720"
  },
  "completed_at": "2026-07-20T09:09:31.000000Z"
}

Verify the signature in PHP

PHP
<?php

$rawBody = file_get_contents('php://input');
$payload = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
$received = $_SERVER['HTTP_X_SMS_GATEWAY_SIGNATURE'] ?? '';
$callbackSecret = getenv('SMS_GATEWAY_CALLBACK_SECRET');

// The gateway signs JSON_UNESCAPED_SLASHES output.
$canonicalJson = json_encode($payload, JSON_UNESCAPED_SLASHES);
$expected = hash_hmac('sha256', $canonicalJson, $callbackSecret);

if (! hash_equals($expected, $received)) {
    http_response_code(401);
    exit('Invalid signature');
}

// Process idempotently using payload.request_id.
http_response_code(200);

Verify the signature in Node.js

Node.js
import crypto from 'node:crypto';

const canonicalJson = JSON.stringify(req.body);
const expected = crypto
  .createHmac('sha256', process.env.SMS_GATEWAY_CALLBACK_SECRET)
  .update(canonicalJson)
  .digest('hex');

const received = req.get('X-SMS-Gateway-Signature') ?? '';
const valid = received.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));

if (!valid) return res.status(401).json({ message: 'Invalid signature' });
return res.sendStatus(200);
Respond quickly. Verify, enqueue internal processing, and return a 2xx response. The gateway retries failed callbacks using increasing delays.
Failures

HTTP errors

Status Cause What to do
400 Missing Idempotency-Key. Add a stable unique key and retry.
401 Invalid credentials, missing token, or expired token. Check credentials or request a new token.
403 Token lacks an ability or request belongs to another client. Use the correct client/token.
404 Unknown route or request ID. Verify the v1 path and stored request ID.
409 Idempotency key reused with a changed payload. Use the original payload or a new key.
422 JSON validation failed or all phone numbers are invalid. Inspect the validation errors and correct the payload.
429 Rate limit exceeded. Wait, then retry with exponential backoff.
500 Unexpected server failure. Do not blindly duplicate; retry with the same idempotency key.
Invalid credentials · 401
{
  "status": "error",
  "message": "Invalid client credentials"
}
No valid recipients · 422
{
  "status": "error",
  "message": "No valid recipients were supplied.",
  "failures": [
    {
      "phone": "12345",
      "reason": "Invalid phone number format"
    }
  ]
}
Usage

Rate limits and retry strategy

10/minuteToken requests per IP
60/minuteDefault authenticated API limit per client
ConfigurableEach client can have a custom API limit

Read the standard rate-limit response headers when present. On 429 or temporary 5xx failures, use exponential backoff with jitter. Reuse the same idempotency key when retrying the same SMS submission.

Tools

Postman and integration testing

Import the included collection, configure environment variables, and run tests in this order:

  1. 1
    Generate token

    Confirm 200, save access_token, and note expiry.

  2. 2
    Send one SMS

    Use a controlled recipient and a new idempotency key.

  3. 3
    Repeat the identical request

    Confirm 200 and the same request ID, with no duplicate SMS.

  4. 4
    Change the payload but keep the key

    Confirm 409 Conflict.

  5. 5
    Poll request and recipients

    Confirm processing completes and inspect every failure.

  6. 6
    Verify callback handling

    Confirm signature verification, idempotent processing, and 2xx acknowledgement.

Download Postman collectionPreconfigured endpoint templates and test scripts
Production

Go-live checklist

Copied to clipboard