Send Bulk SMS
Reach Safaricom, Airtel and Telkom numbers in Kenya with a single API call. Send to one recipient or thousands: Sozuri checks and queues the request, answers with an ID for every recipient, and posts a delivery callback to your webhook when the network reports back.
- Request parameters
- Response parameters
- API versions: v1 and v2
- Message content and length
- Sample request
- Delivery callback
- Error responses
- Retries & backoff
- Request rate and large runs
Request parameters
Send the fields as a JSON body (Content-Type: application/json), as a form-encoded body, or as query parameters on a
GET request to the same URL. Every field sits at the top level.
| Field | Required | Type | Description |
|---|---|---|---|
| project | Yes | String | Your project’s name, as shown on your dashboard. It is half of your credentials, so send it on every request, Bearer requests included. See Authentication. |
| apiKey | Yes, unless you send a Bearer token | String |
Your project’s API key, as a body or query field. We recommend sending it as Authorization: Bearer <key> instead — see request headers. A header named apiKey is not read.
|
| from | Yes | String |
The sender ID to send under, written exactly as it is registered to your project. The match is case-sensitive
(Aba and ABA are different sender IDs) and is made per network and per type. There is no default:
a request without a registered sender ID is refused. See How sender IDs are matched.
|
| to | Yes | String |
A comma-separated list of recipient numbers, e.g. 254700000001,254733000002. Spaces, dashes and a leading
+ are removed, and each number is read as 254 followed by its last nine digits, so
0700-000-001, +254700000001 and 254700000001 are the same recipient. A number that
appears twice is sent once. Only Kenyan mobile numbers can be delivered. Numbers on your project’s opt-out list for this
sender ID are left out and do not appear in the response.
|
| message | Yes | String | The text of the message. See Message content and length for the characters that can be sent and how parts are counted. |
| type | Yes | String |
transactional or promotional, in lower case. The sender ID must be registered for this type on each
network you send to. A name registered as both types is two sender IDs, one of each.
|
| campaign | No | String | A label that groups the messages in your reports, e.g. Promo Nai. A campaign with this name is created the first time you use it. Without it, messages are filed under default. |
| sendAt | No | String |
Schedule the messages: a date and time in East Africa Time, as YYYY-MM-DD HH:MM:SS
(e.g. 2026-12-01 08:00:00). A time more than two minutes ahead schedules the request; anything sooner, or in the past,
is sent straight away. Your balance is charged when the request is accepted, not when the messages go out.
|
| optout | No | Any |
Any non-empty value appends the opt-out instruction STOP*456*9*5# (with a space before it) to the message.
Leave the field out to send without it: a value such as false or 0 still counts as present.
|
How sender IDs are matched
Each recipient is sent under the sender ID registered to your project for their network, matched exactly on
three things: the name with its capitalisation, the type, and the network. A sender ID registered as
Aba transactional on Safaricom reaches Safaricom numbers only. An Airtel number in the same request is sent only if
Aba is also registered as transactional on Airtel; AbA transactional or Aba promotional on Airtel
do not count.
A recipient on a network where the sender ID is not registered is not sent and not charged. The rest of the request
goes ahead, and that recipient comes back with status sender_not_registered and statusCode
24. To send one name to every network, register that exact name, for each type you use, on Safaricom, Airtel and Telkom.
{
"messageData": {
"messages": 1
},
"recipients": [
{
"messageId": "8f3c2a1e9b7d4c6f0a5e2d1b3c4f5a6b7c8d9e0f",
"to": "254700000001",
"status": "sent",
"statusCode": "11",
"bulkId": "bulk6537f1c2a9b3e4.825912061698123456",
"messagePart": 1,
"type": "transactional"
},
{
"messageId": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d",
"to": "254733000002",
"status": "sender_not_registered",
"statusCode": "24",
"bulkId": "bulk6537f1c2a9b3e4.825912061698123456",
"messagePart": 1,
"type": "transactional"
}
]
}
A response from /api/v1/messaging. messages counts the recipients queued for sending, so the refused one is not counted. On /api/v2/messaging the first recipient reads accepted rather than sent; the refused one is the same in both versions.
Response parameters
An accepted request is answered with HTTP 200 and a JSON object listing what Sozuri did with each recipient:
| Field | Type | Description |
|---|---|---|
| messageData.messages | Number | How many recipients were queued for sending (or scheduled). Recipients refused with sender_not_registered are not counted. |
| recipients | Array | One entry per recipient, in the order you sent them, after duplicates and opted-out numbers are removed. |
| recipients[].messageId | String | A 40-character ID unique to this message. Delivery callbacks for this message carry the same ID. |
| recipients[].to | String | The number the message is sent to: 254 followed by nine digits. |
| recipients[].status | String | What happened to this recipient when the request was submitted — not whether it was delivered. The values depend on the API version: see API versions. |
| recipients[].statusCode | String | The numeric code that goes with status (11, 12, 14 or 24) — see API versions and the status code table. |
| recipients[].bulkId | String | An identifier shared by every message in this request, useful for batch reporting. |
| recipients[].messagePart | Number | How many SMS parts the message uses. Each part is charged for each recipient. |
| recipients[].type | String | The message type the recipient is sent as (transactional / promotional). |
API versions: v1 and v2
https://sozuri.net/api/v1/messaging and https://sozuri.net/api/v2/messaging take exactly the same fields,
by POST or GET, and send the same way. They differ only in the status and statusCode
each recipient comes back with. v1 keeps the values it has always returned, so existing integrations keep working; v2 tells you whether
a request was scheduled.
| What happened to the recipient | v1 status / statusCode | v2 status / statusCode |
|---|---|---|
| Queued to be sent now | sent / 11 | accepted / 11 |
Scheduled (sendAt more than two minutes ahead) | sent / 11 | scheduled / 14 |
| The number has fewer than nine digits and cannot be read as a mobile number | unsupported_number / 12 | unsupported_number / 12 |
| The sender ID is not registered for this type on the recipient’s network. Not sent, not charged. | sender_not_registered / 24 | sender_not_registered / 24 |
On v1, sent means the message was accepted for sending, not that it has reached the network. Whether a message was
delivered arrives later, in the delivery callback. A well-formed number on a network Sozuri does not
send to is accepted at submission with code 11, is not charged, and is then marked failed.
Message content and length
Messages are sent as plain text. These characters are kept: the letters A–Z and a–z,
the digits 0–9, spaces and line breaks, and
- / { } ^ | ( ) + < > [ ] $ " ' : ; . = , & @ * # ! % ? _.
Any other character is removed before the message is sent — for example accented letters, emoji, curly quotes, ~,
backticks, backslashes and currency symbols other than $.
optout adds 14. Every part is charged, for every
recipient; messagePart in the response tells you how many parts were counted.
Sample request
A single request can target one or thousands of recipients. Sozuri responds straight away with a messageId per recipient, then posts asynchronous delivery updates to your callback URL as the networks report them.
Required headers
POST /api/v1/messaging
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
Sample GET request
https://sozuri.net/api/v1/messaging?apiKey=YOUR_API_KEY&project=My%20Project&from=MySenderID&to=254700000001&message=Hello%20from%20Sozuri&type=transactional
URL-encode every value. A key in a URL ends up in browser history and server logs, so prefer a POST with a Bearer token outside quick tests.
Sample POST request
POST /api/v1/messaging HTTP/1.1
Host: sozuri.net
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
Accept: application/json
{
"project": "My Project",
"from": "MySenderID",
"to": "254700000001,254733000002",
"campaign": "Promo Nai",
"message": "Test SMS.",
"type": "promotional"
}
<?php
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => "https://sozuri.net/api/v1/messaging",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
"project" => "My Project",
"from" => "MySenderID",
"to" => "254700000001,254733000002",
"campaign" => "Promo Nai",
"message" => "Test SMS.",
"type" => "promotional",
]),
CURLOPT_HTTPHEADER => [
"Accept: application/json",
"Authorization: Bearer YOUR_API_KEY",
"Content-Type: application/json",
],
));
$response = curl_exec($curl);
curl_close($curl);
echo $response;
const response = await fetch("https://sozuri.net/api/v1/messaging", {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
project: "My Project",
from: "MySenderID",
to: "254700000001,254733000002",
campaign: "Promo Nai",
message: "Test SMS.",
type: "promotional"
})
});
console.log(await response.json());
require 'uri'
require 'net/http'
require 'json'
uri = URI("https://sozuri.net/api/v1/messaging")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer YOUR_API_KEY"
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request.body = {
project: "My Project",
from: "MySenderID",
to: "254700000001,254733000002",
campaign: "Promo Nai",
message: "Test SMS.",
type: "promotional"
}.to_json
puts http.request(request).body
import requests
response = requests.post(
"https://sozuri.net/api/v1/messaging",
headers={
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
"Accept": "application/json",
},
json={
"project": "My Project",
"from": "MySenderID",
"to": "254700000001,254733000002",
"campaign": "Promo Nai",
"message": "Test SMS.",
"type": "promotional",
},
)
print(response.json())
HttpResponse<String> response = Unirest.post("https://sozuri.net/api/v1/messaging")
.header("Authorization", "Bearer YOUR_API_KEY")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body("{\"project\":\"My Project\",\"from\":\"MySenderID\",\"to\":\"254700000001,254733000002\","
+ "\"campaign\":\"Promo Nai\",\"message\":\"Test SMS.\",\"type\":\"promotional\"}")
.asString();
var client = new RestClient("https://sozuri.net/api/v1/messaging");
var request = new RestRequest(Method.POST);
request.AddHeader("Accept", "application/json");
request.AddHeader("Content-Type", "application/json");
request.AddHeader("Authorization", "Bearer YOUR_API_KEY");
request.AddParameter("application/json",
"{\"project\":\"My Project\",\"from\":\"MySenderID\",\"to\":\"254700000001,254733000002\","
+ "\"campaign\":\"Promo Nai\",\"message\":\"Test SMS.\",\"type\":\"promotional\"}",
ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
curl -X POST https://sozuri.net/api/v1/messaging \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{
"project": "My Project",
"from": "MySenderID",
"to": "254700000001,254733000002",
"campaign": "Promo Nai",
"message": "Test SMS.",
"type": "promotional"
}'
Sample JSON response
From /api/v1/messaging, when MySenderID is registered as promotional on both Safaricom and Airtel:
{
"messageData": {
"messages": 2
},
"recipients": [
{
"messageId": "8f3c2a1e9b7d4c6f0a5e2d1b3c4f5a6b7c8d9e0f",
"to": "254700000001",
"status": "sent",
"statusCode": "11",
"bulkId": "bulk6537f1c2a9b3e4.825912061698123456",
"messagePart": 1,
"type": "promotional"
},
{
"messageId": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d",
"to": "254733000002",
"status": "sent",
"statusCode": "11",
"bulkId": "bulk6537f1c2a9b3e4.825912061698123456",
"messagePart": 1,
"type": "promotional"
}
]
}
The same request to /api/v2/messaging returns "status": "accepted" for both recipients.
Delivery callback
When a network reports whether a message was delivered, Sozuri sends a POST to your delivery callback URL. Callbacks may arrive out of order — use the messageId to match each one to the message it describes.
Configure your callback URL from your project at Manage API › Callback URLs.
{
"project": "My Project",
"messageId": "8f3c2a1e9b7d4c6f0a5e2d1b3c4f5a6b7c8d9e0f",
"channel": "sms",
"status": "success",
"network": "safaricom",
"type": "bulkDelivery",
"timestamp": 1698123500,
"authKey": "YOUR_AUTH_KEY"
}
timestamp is the Unix time at which Sozuri sent the callback. The status values, and the body each network’s callback carries, are listed under Delivery webhook statuses.
Telkom does not report delivery, so Telkom recipients get no delivery callback.
Callback authentication
Every delivery callback carries your project’s Auth Key in the body as authKey, plus an X-Sozuri-Signature HMAC header over the raw body. Check either one before you process a callback. Both are generated for you and live under Manage API › Callback URLs › Webhook security — see Verifying authenticity.
Error responses
Errors come in two shapes. A request the API cannot read or authenticate is answered with an HTTP error status and a body carrying
error_code, message and retryable. A request that was read but refused is answered with HTTP
200 and messageData.message starting Error. — so always check the body, not just the status.
When a request is refused, nothing in it is sent and nothing is charged.
| Condition | HTTP | Response body |
|---|---|---|
| The API key or project name is missing or wrong, or the key does not belong to that project. See Authentication. | 401 | |
type or message is missing. |
422 | |
| Your project balance is too low to send the messages. | 200 | |
No from was sent. There is no default sender ID. |
200 | |
The from is not registered to your project for this type on any network, exactly as written (here ABA when the project holds Aba). An unrecognised type is refused the same way. |
200 | |
| Every recipient is on a network where the sender ID is not registered. | 200 | |
No recipients are left to send to: to was empty, or every number is on your opt-out list for this sender ID. |
200 | |
| No price is set up for your account. Contact support; topping up will not fix it. | 200 | |
| A problem on Sozuri’s side. Support has been alerted; try again later. | 200 | |
| A short-lived problem on Sozuri’s side while queueing the messages. Try again. | 200 | |
| Too many requests from your project in the current minute. See Request rate. | 429 | |
| An unexpected error on Sozuri’s side. | 500 | |
statusCode table and how to interpret it across channels, see Status codes & response envelopes. For asynchronous delivery callbacks, see Delivery webhook statuses.
Retries & backoff
Network blips and brief transients are expected at any scale. For an HTTP error, branch your retry logic on error_code and
retryable — never on the human-readable message text. For an HTTP 200 refusal, only the two
A system error was encountered messages are worth retrying; every other refusal will be refused again until you change the request,
your balance or your sender IDs.
Which errors to retry
error_code | HTTP | Retryable | Action |
|---|---|---|---|
RATE_LIMITED | 429 | Yes | No Retry-After header is sent. Wait a few seconds plus jitter and retry, and slow your request rate. |
SERVICE_UNAVAILABLE | 500 / 503 | Yes | Exponential backoff with jitter (see below). |
VALIDATION_FAILED | 422 | No | Fix the payload using the errors field. Do not retry the same request. |
AUTHENTICATION_FAILED | 401 | No | Check your API key or Bearer token and your project value. |
NOT_FOUND / METHOD_NOT_ALLOWED | 404 / 405 | No | Fix the URL or the HTTP method. |
Recommended backoff curve
Exponential backoff with full jitter (uniform random between 0 and the upper bound). Full jitter prevents thundering-herd retries when many clients all hit a transient failure at the same instant.
| Attempt | Backoff window | Wait |
|---|---|---|
| 1st retry | 0 – 1 s | random(0, 1000) ms |
| 2nd retry | 0 – 2 s | random(0, 2000) ms |
| 3rd retry | 0 – 4 s | random(0, 4000) ms |
| 4th retry | 0 – 8 s | random(0, 8000) ms |
| 5th retry | 0 – 16 s | random(0, 16000) ms |
| Cap each wait at 30 s. After 5 failed retries, route the request to a dead-letter queue for human review, keeping the request body and the last response. | ||
Pseudocode
for attempt in 1..5:
response = POST /api/v1/messaging
if response.status == 200:
text = response.body.messageData.message
if text is empty or does not start with "Error.":
return success(response.body.recipients)
if text does not start with "Error. A system error was encountered":
return fail(text) # refused: fix the request first
else if response.body.retryable == false:
return fail(response.body.error_code)
sleep(min(30000, random(0, 1000 * 2^(attempt - 1))) ms)
return fail("MAX_RETRIES_EXCEEDED")
Request rate and large runs
Each project can make up to 20,000 API requests per minute, counted across all Sozuri API endpoints. Beyond that,
requests are refused with HTTP 429 and RATE_LIMITED until the minute is up. Every other response, apart from a 401, carries
X-RateLimit-Limit and X-RateLimit-Remaining headers, so you can see how much of the allowance is left.
There is no separate limit on messages per second at the API. One request can carry many recipients, and every message Sozuri accepts is queued and handed to the networks as fast as they take it — so a large run keeps being delivered for a while after the API has accepted it.
Sending large runs
- When every recipient gets the same text, put many recipients in one request rather than making one request per recipient.
- When each recipient gets a different text, send one request per message and keep well inside the request allowance.
- For a run of hundreds of thousands of messages, let [email protected] know before you start.
Probing platform readiness before a scheduled run
Before launching a large run, probe GET /api/v1/status (no authentication required). If the overall status is operational, proceed. If it is degraded, check carriers: when the network you are sending to is still operational, the run can go ahead. If it is down, defer the run.
What to do on RATE_LIMITED
The retry/backoff logic above will work, but the better fix is to make fewer requests: batch recipients who get the same text into one request. If you consistently need more than the allowance, contact [email protected].
Use cases
Bulk SMS is the workhorse channel in Kenya. Here’s where it shines.
OTPs & 2FA
Deliver one-time codes for login, signup, payment confirmation and password reset flows.
Marketing campaigns
Send a promotional sender ID to thousands of contacts and follow the delivery reports as they come in.
Transactional alerts
Order confirmations, dispatch notifications, appointment reminders — the workhorse of every modern business.
Reminders & nudges
Loan repayments, clinic appointments, KYC follow-ups — a well-timed SMS gets things done.
Send your first SMS today.
Grab a code sample, drop in your API key, and watch a real SMS arrive on your phone.