Send Bulk SMS
Reach any mobile number across Safaricom, Airtel and Telkom with a single API call. Send to one recipient or thousands; Sozuri queues, routes and delivers each message and pushes a status callback as it completes.
- Request parameters
- Response parameters
- Sample request
- Delivery callback
- Error responses
- Retries & backoff
- Submission rate (TPS)
Request parameters
The request body is JSON (or form-encoded). Every field below sits at the top level.
| Field | Required | Type | Description |
|---|---|---|---|
| project | Yes | String | The Sozuri project that owns the API key making this request. |
| 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 phone numbers in E.164 format,
e.g. 254700000001,254711000002. Numbers like 0722-503-129 are auto-formatted to 254722503129.
|
| message | Yes | String | The text body of the message. Required for SMS and WhatsApp text payloads. |
| type | Yes | String |
Either transactional or promotional. 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.
|
| channel | No | String | Channel to send on. Defaults to sms; set to whatsapp to use WhatsApp. |
| campaign | No | String | Optional label for grouping reports — e.g. Promo Nai. |
| apiKey | — | String |
Your project’s API key. Recommended: send as a Bearer token in the Authorization header instead — see request headers.
|
Postman screenshot:
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": "MSGBLK6012A7E8B90A21611835368",
"to": "2547251642xx",
"status": "accepted",
"statusCode": "11",
"bulkId": "bulk6012a7e8b904e1611835368",
"messagePart": 1,
"type": "transactional"
},
{
"messageId": "MSGBLK6012A7E8B90A41611835368",
"to": "2547326971xx",
"status": "sender_not_registered",
"statusCode": "24",
"bulkId": "bulk6012a7e8b904e1611835368",
"messagePart": 1,
"type": "transactional"
}
]
}
messages counts only the messages accepted for sending. On /api/v1/messaging an accepted recipient reads sent rather than accepted; the refused one is the same in both versions.
Response parameters
The response is a JSON object summarising what Sozuri accepted for delivery:
| Field | Type | Description |
|---|---|---|
| messageData | Object | Summary of the request, including the count of messages accepted. |
| recipients | Array | One entry per recipient, each with the fields below. |
| recipients[].messageId | String | Unique Sozuri ID for this message. Use it to correlate status callbacks. |
| recipients[].to | String | The recipient’s phone number. |
| recipients[].status | String | Acceptance status (e.g. accepted, unsupported_number, sender_not_registered). Not the final delivery status. |
| recipients[].statusCode | String | Numeric code — see the status code table. |
| recipients[].bulkId | String | Identifier shared by every message in this request — useful for batch reporting. |
| recipients[].messagePart | Number | How many SMS parts the message uses. One full GSM-7 message is 160 characters. |
| recipients[].type | String | Echoes back the message type (transactional / promotional). |
Sample response from Postman:
Sample request
A single request can target one or thousands of recipients. Sozuri responds synchronously with a unique messageId per recipient, then pushes asynchronous status updates to your callback URL as each message progresses.
Required headers
POST /api/v1/messaging
Content-Type: application/json
Authorization: Bearer Your_Project_API_KEY
Sample GET request
https://sozuri.net/api/v1/messaging?apiKey=Your_Project_API_KEY&project=Your_Project_name&channel=sms&from=MySenderID&to=254722xxx675&message=SozuriTestSMS
Sample POST request
POST /api/v1/messaging HTTP/1.1
Host: sozuri.net
Authorization: Bearer LOx5JPdqf0lvf.......R9X9XDJ4PFxRqVrt9dx83cWiwfTQMF
Content-Type: application/json
Accept: application/json
{
"project": "my project",
"from": "MySenderID",
"to": "2547251642xx,2547326971xx",
"campaign": "Promo Nai",
"channel": "sms",
"message": "Test SMS.",
"type": "promotional"
}
POST /api/v1/messaging HTTP/1.1
Host: sozuri.net
Authorization: Bearer LOx5JPdqf0lvf45EZAQMJ.......SUzyxR9X9XDJ4PFxRqVrt9dx83cWiwfTQMF
Content-Type: application/json
Accept: application/xml
<request>
<project>my project</project>
<from>MySenderID</from>
<to>2547251642xx,2547326971xx</to>
<campaign>Promo Nai</campaign>
<channel>sms</channel>
<message>Test SMS.</message>
<type>promotional</type>
</request>
<?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" => "2547251642xx,2547326971xx",
"campaign" => "Promo Nai",
"channel" => "sms",
"message" => "Test SMS.",
"type" => "promotional",
]),
CURLOPT_HTTPHEADER => [
"Accept: application/json",
"Authorization: Bearer LOx5JPdqf0lvf45EZAQMJm85OSUzyxR9X9XDJ4PFxRqVrt9dx83cWiwfTQMF",
"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 LOx5JPdqf0lvf45EZAQMJm85OSUzyxR9X9XDJ4PFxRqVrt9dx83cWiwfTQMF",
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({
project: "my project",
from: "MySenderID",
to: "2547251642xx,2547326971xx",
campaign: "Promo Nai",
channel: "sms",
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 LOx5JPdqf0lvf45EZAQMJm85OSUzyxR9X9XDJ4PFxRqVrt9dx83cWiwfTQMF"
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request.body = {
project: "my project",
from: "MySenderID",
to: "2547251642xx,2547326971xx",
campaign: "Promo Nai",
channel: "sms",
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 LOx5JPdqf0lvf45EZAQMJm85OSUzyxR9X9XDJ4PFxRqVrt9dx83cWiwfTQMF",
"Content-Type": "application/json",
"Accept": "application/json",
},
json={
"project": "my project",
"from": "MySenderID",
"to": "2547251642xx,2547326971xx",
"campaign": "Promo Nai",
"channel": "sms",
"message": "Test SMS.",
"type": "promotional",
},
)
print(response.json())
HttpResponse<String> response = Unirest.post("https://sozuri.net/api/v1/messaging")
.header("Authorization", "Bearer LOx5JPdqf0lvf45EZAQMJm85OSUzyxR9X9XDJ4PFxRqVrt9dx83cWiwfTQMF")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body("{\"project\":\"my project\",\"from\":\"MySenderID\",\"to\":\"2547251642xx,2547326971xx\","
+ "\"campaign\":\"Promo Nai\",\"channel\":\"sms\",\"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 LOx5JPdqf0lvf45EZAQMJm85OSUzyxR9X9XDJ4PFxRqVrt9dx83cWiwfTQMF");
request.AddParameter("application/json",
"{\"project\":\"my project\",\"from\":\"MySenderID\",\"to\":\"2547251642xx,2547326971xx\","
+ "\"campaign\":\"Promo Nai\",\"channel\":\"sms\",\"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 LOx5JPdqf0lvf45EZAQMJm85OSUzyxR9X9XDJ4PFxRqVrt9dx83cWiwfTQMF' \
-d '{
"project": "my project",
"from": "MySenderID",
"to": "2547251642xx,2547326971xx",
"campaign": "Promo Nai",
"channel": "sms",
"message": "Test SMS.",
"type": "promotional"
}'
Sample JSON response
{
"messageData": {
"messages": 2
},
"recipients": [
{
"messageId": "MSGBLK6012A7E8B90A21611835368",
"to": "2547251642xx",
"status": "accepted",
"statusCode": "11",
"bulkId": "bulk6012a7e8b904e1611835368",
"messagePart": 1,
"type": "promotional"
},
{
"messageId": "MSGBLK6012A7E8B90A41611835368",
"to": "2547326971xx",
"status": "accepted",
"statusCode": "11",
"bulkId": "bulk6012a7e8b904e1611835368",
"messagePart": 1,
"type": "promotional"
}
]
}
Delivery callback
Sozuri pushes a POST to your configured webhook each time a message changes state. Callbacks may arrive out of order — use the messageId and timestamp to update your records reliably.
Configure your callback URL from your project at Manage API › Callback URLs.
{
"project": "yourproject_name",
"messageId": "MSGBLK5F96B6A0CC2EB1603712672",
"channel": "sms",
"status": "success",
"network": "safaricom",
"type": "bulkDelivery",
"timestamp": "1603713484"
}
Optional 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
The most common errors you’ll see on the SMS endpoint. Each response uses the SMS error envelope — messageData.message.
A refused request is returned with HTTP 200 and a message starting Error., so check the message as well as the status. Authentication errors (Project not found., Unknown project.) are shared across channels and documented on the Authentication page.
| Condition | HTTP | Response body |
|---|---|---|
| Project credit balance is too low to send the message. | 200 | |
Custom bulk with template variables (e.g. {{fname}}) sent to recipients outside any contact group. |
400 | |
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). Nothing is sent or charged. |
200 | |
| Every recipient is on a network where the sender ID is not registered. Nothing is sent or charged. | 200 | |
Required field (type, message, to) missing or malformed. |
422 | |
Request did not match any valid SMS processing branch — usually means type is missing or unrecognised. |
400 | |
| Internal error during dispatch. Rare and transient — retry idempotently. | 200 | |
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 routing transients are expected at any scale. Building correct retry behaviour into your client makes the difference between a smooth bulk run and a hand-managed incident. Branch your retry logic on error_code (stable) and retryable — never on the human-readable message text.
Which errors to retry
error_code | HTTP | Retryable | Action |
|---|---|---|---|
RATE_LIMITED | 429 | Yes | Honour the Retry-After header if present, otherwise wait 5 seconds + jitter and retry. |
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 | Verify your API key / bearer token and project value. |
AUTHORIZATION_FAILED | 403 | No | Escalate — the token does not permit this operation. |
NOT_FOUND / BAD_REQUEST | 404 / 400 | No | Fix the URL or payload. |
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 for human review — log the request id for cross-referencing with Sozuri. | ||
Pseudocode
for attempt in 1..5:
response = POST /api/v1/messaging
if response.status == 200:
return success
if response.body.retryable == false:
return fail(response.body.error_code)
wait_ms = retry_after_header_ms
or min(30000, random(0, 1000 * 2^(attempt - 1)))
sleep(wait_ms)
return fail("MAX_RETRIES_EXCEEDED", request_id=response.body.id)
Submission rate (TPS)
Sozuri’s aggregate carrier capacity is 400 messages per second across all telcos combined (Safaricom + Airtel + Telkom). This is the platform ceiling; sustained submission above it triggers RATE_LIMITED (HTTP 429).
Within that aggregate, we recommend each project stay under 100 TPS sustained. That leaves headroom for several concurrent high-volume customers running large jobs at the same time, which is the normal pattern we see on the platform.
Pacing recommendations for bulk runs
| Run size | Recommended pacing | Sustained TPS |
|---|---|---|
| ≤ 10,000 | No pacing required — submit as a burst | up to 100 TPS |
| 10,000 – 50,000 | Spread over ≥ 8 minutes | ≈ 20–100 TPS |
| 50,000 – 200,000 | Spread over ≥ 35 minutes | ≈ 25–95 TPS |
| > 200,000 | Contact support to schedule a window | — |
Probing platform readiness before a scheduled run
Before launching a large bulk run, probe GET /api/v1/status (no authentication required). If the overall status is operational, proceed. If degraded with the relevant carrier still operational, the run will most likely succeed but consider deferring the start by a few minutes. If down, defer the run.
What to do on RATE_LIMITED
The retry/backoff logic above will work, but the better fix is pacing: repeatedly hitting 429 wastes round-trips and increases the chance some messages miss their delivery window. If you are consistently being rate-limited at the recommended pacing, your project may need a higher per-project allowance — contact support@sozuri.net.
Use cases
Bulk SMS is the workhorse channel in Kenya. Here’s where it shines.
OTPs & 2FA
Deliver one-time codes in seconds for login, signup, payment confirmation and password reset flows.
Marketing campaigns
Blast a promotional sender ID to thousands of contacts and watch the delivery rates in real time.
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 lifts conversion 20–40%.
Send your first SMS today.
Grab a code sample, drop in your API key, and watch a real SMS arrive on your phone.