Sending
Sending SMS
POST /v1/sms/send or /v1/sms/bulk with a sender, one or more recipients, the message and a route. The send is queued and answered with 202 and a batch: invalid, repeated and opted-out numbers are removed and counted, the cost is reserved, and each recipient is charged as it is handed to the network.
Endpoints
| Method and path | Scope | Purpose |
|---|---|---|
| POST /v1/sms/send | messages:send | Send to one or more recipients |
| POST /v1/sms/bulk | messages:send | The same send; the response is marked "type": "bulk" |
The two endpoints accept the same body and behave the same way; only the type field of the response differs. Reading status, batches and cancelling are covered in messages and batches.
Request
| Field | Required | Notes |
|---|---|---|
| sender | Yes | Your Sender ID, spelt exactly as approved, up to 11 characters. A live send must use a Sender ID approved for the route in the request. Sandbox does not check it. |
| recipient or recipients | Yes | One number in recipient, or several in recipients as a JSON array or a comma-separated string. Up to 1,000,000 per request. |
| message | Yes | The exact text to send. Up to 1000 characters in GSM-7, or 469 if it contains any character outside GSM-7. |
| route | Yes | "promotional" or "transactional". "promo" and "otp" are accepted, and sms_type is accepted in place of route. There is no default. |
| Header | Required | Notes |
|---|---|---|
| Authorization: Bearer <key> | Yes | Or X-API-Key. See authentication. |
| Content-Type: application/json | Yes | The body must be a JSON object, at most 32 MB. |
| Idempotency-Key | Transactional: yes | Up to 255 characters. Strongly recommended on every send. See idempotency. |
| X-Request-ID | No | Your own correlation id, 8 to 64 characters of A-Z, a-z, 0-9, _ or -. Echoed back as request_id. |
curl -X POST https://api.sendozi.com/v1/sms/bulk \
-H "Authorization: Bearer $SENDOZI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: term-two-reminder-2026-09-24" \
-d '{
"sender": "Greenfield",
"recipients": ["08012345678", "+2349012345678", "2347012345678"],
"message": "Term two resumes on Monday 5 October. Fees are due by Friday.",
"route": "promotional"
}'Response
A send that is accepted returns 202 with the batch it created. Nothing has been delivered yet: the batch is queued, and its recipients are handed to the network within moments (live promotional sends wait for the delivery window).
{
"success": true,
"data": {
"batch_id": "bat_9b1f2a4c6d8e0f13",
"channel": "sms",
"mode": "production",
"status": "queued",
"requested_count": 3,
"accepted_count": 3,
"duplicates_removed": 0,
"invalid_count": 0,
"suppressed_count": 0,
"dnd_skipped_count": 0,
"consent_rejected_count": 0,
"estimated_cost_kobo": 2100,
"reserved_cost_kobo": 2100,
"progress": { "queued": 3, "processing": 0, "sent": 0, "delivered": 0, "failed": 0, "skipped": 0 },
"type": "bulk"
},
"request_id": "req_6fddf1f66c77787f"
}| Field | Meaning |
|---|---|
| batch_id | The batch this send created. Follow it with GET /v1/message-batches/{batch_id}. |
| message_id | Present only when exactly one recipient was accepted. The id the message keeps for life. |
| status | queued once every recipient is written to the queue. |
| requested_count | Recipients in the request, before cleaning. |
| accepted_count | Recipients that will be sent to. |
| duplicates_removed, invalid_count, suppressed_count | Recipients removed as repeats, as invalid numbers, or because they opted out of your SMS. |
| dnd_skipped_count | Live promotional sends only: recipients skipped, and not charged, because they returned a Do Not Disturb block to your workspace in the last 90 days. |
| consent_rejected_count | Always 0. Kept for compatibility. |
| estimated_cost_kobo | The cost of the accepted recipients at the current rate. |
| reserved_cost_kobo | The amount held from the wallet for this send. 0 in sandbox. |
| progress | Recipients by state: queued, processing, sent, delivered, failed and skipped. |
How recipients are handled
Nigerian mobile numbers only. Each number is normalised to +234 form, then invalid numbers, repeats and opted-out numbers are removed and counted. The send goes ahead for everyone left; it is refused only if nobody is left.
- Accepted spellings: 08012345678, 8012345678, 2348012345678, +2348012345678, 002348012345678, and +234 0803... with the zero kept after the country code. Spaces, dashes and brackets are ignored. 0700 and 0800 numbers are not mobiles and are counted as invalid.
- A valid number is a Nigerian mobile number on a 070, 071, 080, 081, 090 or 091 prefix. Anything else, including international numbers, is counted in invalid_count.
- A number that appears twice, in any spelling, is sent once and counted in duplicates_removed.
- A number that has opted out of SMS from your workspace is removed and counted in suppressed_count.
- On a live promotional send, a number that returned a Do Not Disturb block to your workspace in the last 90 days is skipped and counted in dnd_skipped_count. The message that met the block was charged; the repeats are not sent.
- If no valid number is left, the send returns 400 invalid_recipient; if every valid number has opted out, 400 recipient_suppressed.
Routes and Sender IDs
- promotional is for campaigns, offers and announcements to your own audience. The Console sends promotional SMS only.
- transactional is for one person's own event: an OTP, a payment alert, a receipt. Through the API it takes exactly one recipient and an Idempotency-Key; the text is your own. See transactional SMS.
- A Sender ID is approved for one route. A transactional approval also sends promotional, except to Airtel until Airtel whitelists it: those numbers are left out and counted in sender_network_skipped_count. A promotional-only Sender ID used for transactional returns 403 sender_id_route_mismatch; one that is not approved at all returns 403 sender_id_not_approved.
- A Sender ID created before the routes were separated reports sms_route "unclassified" and returns 403 sender_id_unclassified until Sendozi records its route.
- Both routes cost the same.
Message length and pages
SMS is billed at ₦7.00 per page per recipient. A page is one message on the network: 160 characters in GSM-7, or 70 if the text contains any character outside GSM-7 - an emoji, a curly quote, a tone-marked letter. GSM-7 extension characters (^ { } \ [ ~ ] | €) count as two, and an emoji counts as two in Unicode.
| Pages | GSM-7 characters | Unicode characters |
|---|---|---|
| 1 | 1 to 160 | 1 to 70 |
| 2 | 161 to 306 | 71 to 134 |
| 3 | 307 to 459 | 135 to 201 |
| 4 | 460 to 612 | 202 to 268 |
| 5 | 613 to 765 | 269 to 335 |
| 6 | 766 to 918 | 336 to 402 |
| 7 | 919 to 1000 | 403 to 469 |
A longer message is refused with 400 invalid_request, never truncated. Messages are also checked against a short list of high-risk phrases; a match returns 400 message_policy_violation.
How a send is charged
- 1
Reserved on acceptance
A live send reserves estimated_cost_kobo from the wallet when it is accepted. If the balance does not cover it, the send returns 403 insufficient_balance and nothing is queued.
- 2
Charged per recipient
Each recipient is charged its price when it is handed to the network, and charged even if the network later reports it undelivered.
- 3
Not charged
A recipient refused before it reached the network, skipped because the number opted out after the send was accepted, or cancelled is not charged.
- 4
Released
When the batch finishes or is cancelled, whatever part of the reservation was not charged goes back to the wallet. actual_cost_kobo on the batch is the final charge.
Large sends
- One request carries up to 1,000,000 recipients and at most 32 MB. A larger audience is several requests, each with its own Idempotency-Key.
- A large request takes longer to accept, because every recipient is checked and written before the batch starts. Allow a client timeout of a few minutes, and retry with the same Idempotency-Key if it times out.
- Recipients are handed to the network at a steady platform-wide rate, and every workspace's batches share it fairly, so a large campaign does not hold up another customer's send and your transactional SMS is always sent first.
Frequently asked questions
- How do I send bulk SMS through the Sendozi API?
- POST to /v1/sms/bulk (or /v1/sms/send) with a sender, a recipients array, the message and route: "promotional". The response returns a batch_id; follow it with GET /v1/message-batches/{id} and list its recipients with GET /v1/message-batches/{id}/recipients.
- What is the maximum SMS length?
- 1000 characters in GSM-7, or 469 if the message contains any character outside GSM-7. Longer messages are rejected rather than truncated.
- How many recipients can one request take?
- Up to 1,000,000, in a body of at most 32 MB.
- Can I send SMS to a number outside Nigeria?
- No. Recipients must be Nigerian mobile numbers. Anything else is removed and counted in invalid_count.
- Is transactional SMS priced differently from promotional SMS?
- No. Both are ₦7.00 per page. The route decides how a message travels, not what it costs.
Related reading
Sending
Messages, batches and delivery status
Every send creates a batch of recipients, each becoming a message. How to look them up, what each status means, why a message failed, how to page through lists, and how to cancel a send.
Sending
Transactional SMS: OTPs, alerts and receipts
What a transactional send needs: a Transactional Sender ID, one recipient, your own message and an Idempotency-Key. No templates to register. With a working OTP example.
Reliability
Idempotency and safe retries
How the Idempotency-Key header works on Sendozi send endpoints, what a repeat returns, why the body must be byte-for-byte identical, how long keys last, and how to choose one.
Reliability
Webhooks
Register a webhook endpoint, the eight events and their payloads, verifying the signature over the raw body, how retries and pausing work, and a receiver that survives repeats and reordering.
Reliability
Errors and the response envelope
The Sendozi response envelope, every error code the API returns with its HTTP status and fix, which errors are safe to retry, Retry-After, and how request_id traces a request.