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.

POST https://sozuri.net/api/v1/messaging

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.

FieldRequiredTypeDescription
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:

FieldTypeDescription
messageData.messagesNumberHow many recipients were queued for sending (or scheduled). Recipients refused with sender_not_registered are not counted.
recipientsArrayOne entry per recipient, in the order you sent them, after duplicates and opted-out numbers are removed.
recipients[].messageIdStringA 40-character ID unique to this message. Delivery callbacks for this message carry the same ID.
recipients[].toStringThe number the message is sent to: 254 followed by nine digits.
recipients[].statusStringWhat 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[].statusCodeStringThe numeric code that goes with status (11, 12, 14 or 24) — see API versions and the status code table.
recipients[].bulkIdStringAn identifier shared by every message in this request, useful for batch reporting.
recipients[].messagePartNumberHow many SMS parts the message uses. Each part is charged for each recipient.
recipients[].typeStringThe 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 recipientv1 status / statusCodev2 status / statusCode
Queued to be sent nowsent / 11accepted / 11
Scheduled (sendAt more than two minutes ahead)sent / 11scheduled / 14
The number has fewer than nine digits and cannot be read as a mobile numberunsupported_number / 12unsupported_number / 12
The sender ID is not registered for this type on the recipient’s network. Not sent, not charged.sender_not_registered / 24sender_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 $.

Message length — a part holds 160 characters. A message of up to 160 characters is one part, 161 to 320 is two, 321 to 480 is three, and so on. Each line break counts as two characters, and 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.

ConditionHTTPResponse body
The API key or project name is missing or wrong, or the key does not belong to that project. See Authentication. 401
{
    "error_code": "AUTHENTICATION_FAILED",
    "message": "Authentication failed. Check your API key or bearer token.",
    "retryable": false
}
type or message is missing. 422
{
    "error_code": "VALIDATION_FAILED",
    "message": "The submitted data failed validation.",
    "retryable": false,
    "errors": {
        "type": ["The type field is required."]
    }
}
Your project balance is too low to send the messages. 200
{
    "messageData": {
        "message": "Error. Insufficient balance. Top up and try again."
    }
}
No from was sent. There is no default sender ID. 200
{
    "messageData": {
        "message": "Error. Enter the sender ID (from) to send under. It must be a sender ID registered to this project."
    }
}
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
{
    "messageData": {
        "message": "Error. 'ABA' is not a registered transactional sender ID on this project. Sender IDs are matched exactly, case included, and per network: register 'ABA' as a transactional sender ID on each network you send to."
    }
}
Every recipient is on a network where the sender ID is not registered. 200
{
    "messageData": {
        "message": "Error. None of these messages was sent: 'Aba' (transactional) is registered on Safaricom only, and every recipient is on Airtel. Nothing was charged."
    }
}
No recipients are left to send to: to was empty, or every number is on your opt-out list for this sender ID. 200
{
    "messageData": {
        "message": "Error. No valid recipients found."
    }
}
No price is set up for your account. Contact support; topping up will not fix it. 200
{
    "messageData": {
        "message": "Error. No rate is configured for this account. Contact support."
    }
}
A problem on Sozuri’s side. Support has been alerted; try again later. 200
{
    "messageData": {
        "message": "Error. A system error was encountered. Sozuri support has been notified of the same."
    }
}
A short-lived problem on Sozuri’s side while queueing the messages. Try again. 200
{
    "messageData": {
        "message": "Error. A system error was encountered. Please try again."
    }
}
Too many requests from your project in the current minute. See Request rate. 429
{
    "error_code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Please retry after a short delay.",
    "retryable": true
}
An unexpected error on Sozuri’s side. 500
{
    "error_code": "SERVICE_UNAVAILABLE",
    "message": "An unexpected error occurred. Please retry.",
    "retryable": true
}
For the full numeric 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_codeHTTPRetryableAction
RATE_LIMITED429YesNo Retry-After header is sent. Wait a few seconds plus jitter and retry, and slow your request rate.
SERVICE_UNAVAILABLE500 / 503YesExponential backoff with jitter (see below).
VALIDATION_FAILED422NoFix the payload using the errors field. Do not retry the same request.
AUTHENTICATION_FAILED401NoCheck your API key or Bearer token and your project value.
NOT_FOUND / METHOD_NOT_ALLOWED404 / 405NoFix the URL or the HTTP method.
The API has no idempotency key. If a request timed out before you got a response, the messages may already have been queued and charged. Check the SMS log on your dashboard before you send them again, or you may send them twice.
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.

AttemptBackoff windowWait
1st retry0 – 1 srandom(0, 1000) ms
2nd retry0 – 2 srandom(0, 2000) ms
3rd retry0 – 4 srandom(0, 4000) ms
4th retry0 – 8 srandom(0, 8000) ms
5th retry0 – 16 srandom(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.

S
Sozuri
SMS · just now
Your verification code is 482910. It expires in 5 minutes. 10:24

Marketing campaigns

Send a promotional sender ID to thousands of contacts and follow the delivery reports as they come in.

D
DUKANET
Promo
Black Friday is HERE. Up to 70% off electronics. Shop now: dukanet.example/bf STOP*456*9*5# 08:00

Transactional alerts

Order confirmations, dispatch notifications, appointment reminders — the workhorse of every modern business.

G
GALAXION
Order update
Order #4821 confirmed. Total KES 2,450. Delivery tomorrow 9-11am. Track: galaxion.example/t/4821 14:12

Reminders & nudges

Loan repayments, clinic appointments, KYC follow-ups — a well-timed SMS gets things done.

M
MKOPO
Repayment
Hi Wanjiru, your KES 1,200 loan repayment is due tomorrow. Pay via Paybill 000000, account 254700000000. 09:00

Send your first SMS today.

Grab a code sample, drop in your API key, and watch a real SMS arrive on your phone.