Short-lived Laravel Sanctum tokens with scoped permissions.
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.
One shared message or a different message for every recipient.
Idempotency keys prevent accidental duplicate submissions.
Poll status endpoints or receive signed completion callbacks.
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.
202 Accepted response means the request
was saved and queued. It does not yet mean every SMS
has been delivered.
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 --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 --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"
}
}'
Base URL and headers
https://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. |
Endpoints
/auth/tokenGenerate an access token
POST/sms/requestsSubmit single or bulk SMS
GET/sms/requests/{request_id}Get request summary
GET/sms/requests/{request_id}/recipientsGet recipient-level results
POST /webhooks/africastalking/delivery
is configured for Africa's Talking delivery reports.
Client applications should not call it.
/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. |
{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET"
}
{
"status": "success",
"access_token": "1|plain_text_token",
"token_type": "Bearer",
"expires_at": "2026-07-20T10:07:20.815252Z"
}
/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
{
"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
{
"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
{
"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
{
"status": "processing",
"message": "SMS request received and is being processed",
"request_id": "019f7ec9-5a06-71fa-965b-cf5c43c36667"
}
request_id. It is
required for both status endpoints and should be
associated with your own transaction or batch
record.
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. |
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.
failed. If every supplied
number is invalid, the API returns
422 and sends nothing.
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.
A new request is saved and queued.
The original request ID is returned. No duplicate is queued.
The changed request is rejected.
{
"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.
/sms/requests/{request_id}
Returns the request-level summary. The request
belongs to the authenticated client; another client
receives 403 Forbidden.
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'
{
"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"
}
}
completed means there are no pending
recipients. Inspect recipient results to distinguish
provider-accepted, delivered, and failed messages.
/sms/requests/{request_id}/recipients
Returns recipient-level status records, 100 per
page. Use ?page=2 for later pages.
{
"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.
|
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.
Callback headers
Content-Type: application/json
X-SMS-Gateway-Signature: <hex-hmac-sha256>
X-SMS-Gateway-Request-Id: 019f7ec9-5a06-71fa-965b-cf5c43c36667
Callback body
{
"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
$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
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);
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. |
{
"status": "error",
"message": "Invalid client credentials"
}
{
"status": "error",
"message": "No valid recipients were supplied.",
"failures": [
{
"phone": "12345",
"reason": "Invalid phone number format"
}
]
}
Rate limits and retry strategy
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.
Postman and integration testing
Import the included collection, configure environment variables, and run tests in this order:
-
1
Generate token
Confirm
200, saveaccess_token, and note expiry. -
2
Send one SMS
Use a controlled recipient and a new idempotency key.
-
3
Repeat the identical request
Confirm
200and the same request ID, with no duplicate SMS. -
4
Change the payload but keep the key
Confirm
409 Conflict. -
5
Poll request and recipients
Confirm processing completes and inspect every failure.
-
6
Verify callback handling
Confirm signature verification, idempotent processing, and 2xx acknowledgement.