BulkSMS Integration API v1
The customer-facing SMS API, at /api/v1/bulksms. This is the surface an
external integrator writes code against: six endpoints, signed requests, one
envelope, machine-readable error codes.
Getting started
Read this section once and the rest of the page is reference: what the API looks like, how a request is signed, and one worked call you can run before writing any code.
| Base path | /api/v1/bulksms |
| Credential | An API key, used to sign each request (HMAC-SHA256) |
| Capability required | The SMS API capability on that key |
| Envelope | success / message / data / errors / meta / trace_id, plus error on failures |
| Rate limit | 60 requests per minute, per API key |
| Idempotency | Required on both POSTs, ignored on GETs |
| Webhooks | Opt-in, so you need not poll — registered in the dashboard, signed HMAC-SHA256. See Webhooks |
The six endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/bulksms/sms |
Send one message |
POST |
/api/v1/bulksms/sms/bulk |
Send one message to many recipients |
GET |
/api/v1/bulksms/sms/{reference} |
Read a single message's status and timeline |
GET |
/api/v1/bulksms/campaigns/{reference} |
Read a bulk send's progress and its per-message outcomes |
GET |
/api/v1/bulksms/balance |
Units available to spend, per category |
GET |
/api/v1/bulksms/sender-ids |
The account's own sender ID registry |
Those six are the whole API. There is no seventh endpoint, published or otherwise.
The two {reference} endpoints resolve different things and do not overlap.
/sms/{reference} resolves messages only; /campaigns/{reference} resolves
campaigns only. A reference of the wrong kind returns 404 on either.
This is not the only SMS API
Three SMS APIs exist and each has its own kind of credential. They are not versions or migration paths of one another; they exist for different callers, and a credential for one does not work on another.
| Surface | Caller | Credential |
|---|---|---|
/api/v1/bulksms |
External integrators — this document | An API key, used to sign each request |
/api/v1/sms |
Legacy server-to-server | X-Api-Key / X-Api-Secret pair |
/api/v1/app/sms |
The first-party mobile app | A person's bearer token |
If you are integrating from your own system, /api/v1/bulksms is the one you
want. The other two are closed surfaces and a key for this API will not open
them.
Authentication
Every request is signed with your API key. The key's public half travels in a header, the secret never leaves your server, and the signature covers the method, the path, the body and a timestamp.
Three headers, on every request:
| Header | Value |
|---|---|
X-Egab-Key |
The connection's public key |
X-Egab-Timestamp |
Unix time in seconds |
X-Egab-Signature |
Lowercase hex HMAC-SHA256 of the canonical string, keyed by your secret |
X-Egab-Signature is hex(hmac_sha256(canonical_string, secret)). The string
the signature is computed over is four lines joined by \n:
{HTTP method, uppercase}
{path, with a leading slash}
{sha256 hex of the raw request body}
{unix timestamp, in seconds}
Signing GET /api/v1/bulksms/balance with no body at timestamp 1759492800,
the canonical string is exactly:
GET
/api/v1/bulksms/balance
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1759492800
e3b0c44298... is sha256(''). A request with no body still hashes the
empty string — that third line is never omitted and never blank.
The path is normalised to a single leading slash before signing, so signing
api/v1/bulksms/balance and /api/v1/bulksms/balance produce the same string
rather than one of them failing for no visible reason.
A timestamp more than 300 seconds from the server's clock is rejected, in either direction. Keep your clocks on NTP.
Three things cost people their first day, and all three are silent 401s:
- The timestamp is in seconds, not milliseconds. A millisecond timestamp is thousands of years in the future and is rejected as skew.
- Signatures are lowercase hex. Uppercase will not match.
- The body is hashed, not signed directly, and it is the raw bytes you send on the wire — not a re-serialised copy. Sign the exact string you transmit.
The capability
Your API key needs the SMS API capability, which your account administrator
grants when the key is created. A key that carries other capabilities but not
this one authenticates successfully and is then refused 403 on every endpoint
here, rather than spending your units.
Capabilities are fixed when a key is created and cannot be edited afterwards. A key that was not granted the SMS API capability cannot be given it later; ask your administrator for a new key.
What an auth failure looks like
A request that fails authentication or authorisation is refused before it reaches the SMS API, so its body is not the envelope shown above:
401 — the signature, key, or timestamp did not check out:
{"error": "invalid_api_key", "message": "The request could not be authenticated."}
403 — a live key without the SMS API capability:
{"error": "insufficient_scope"}
Note that the 403 has no message key at all. Both bodies are stable in
this shape: a payload that already carries an error key is returned
untouched, so neither is wrapped in the envelope the rest of the API uses.
Both still carry an X-Request-Id response header, so a failed authentication
is still quotable in a support ticket.
401 means we do not know who you are; 403 means we know, and that is not permitted. Re-signing never fixes a 403.
A signer you can reuse
Write this once per codebase and never sign a request by hand again. All four take the same four arguments — method, path, an optional payload, an optional idempotency key — and all four return the HTTP status alongside the decoded body, so the call sites on the rest of this page read the same in every language.
Three environment variables are assumed throughout: EGAB_BASE is your base
URL, EGAB_KEY is your key's public half, and EGAB_SECRET is the secret.
The Node tabs are ES modules — a .mjs file, or "type": "module" in your
package.json. That is not a style preference: the client is async, and
calling it the way the Python and PHP tabs call theirs needs top-level await,
which CommonJS does not have.
cURL
#!/usr/bin/env bash
# Source this file, then call egab:
# egab GET /api/v1/bulksms/balance
# egab POST /api/v1/bulksms/sms "$BODY" "$KEY"
set -euo pipefail
: "${EGAB_BASE:?set it to your base URL}"
: "${EGAB_KEY:?}"
: "${EGAB_SECRET:?}"
egab() {
local method path body idem ts body_hash canonical sig
method=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')
path="$2"
body="${3-}"
idem="${4-}"
# The query string is requested but not signed, so it is stripped here and
# nowhere else. Signing it is a 401 that looks like a clock problem.
signed_path="${path%%\?*}"
ts=$(date +%s)
body_hash=$(printf '%s' "$body" | openssl dgst -sha256 -hex | awk '{print $NF}')
# method, path, sha256 of the raw body, unix seconds - four lines, "\n" between.
canonical=$(printf '%s\n%s\n%s\n%s' "$method" "$signed_path" "$body_hash" "$ts")
sig=$(printf '%s' "$canonical" \
| openssl dgst -sha256 -hmac "$EGAB_SECRET" -hex | awk '{print $NF}')
local -a args
args=(-sS -X "$method" "${EGAB_BASE}${path}"
-H 'Accept: application/json'
-H "X-Egab-Key: ${EGAB_KEY}"
-H "X-Egab-Timestamp: ${ts}"
-H "X-Egab-Signature: ${sig}")
if [ -n "$body" ]; then
args+=(-H 'Content-Type: application/json')
# --data-raw, never -d: -d strips newlines and you would sign bytes you
# did not send.
args+=(--data-raw "$body")
fi
if [ -n "$idem" ]; then
args+=(-H "Idempotency-Key: ${idem}")
fi
curl "${args[@]}"
}Python
import hashlib
import hmac
import json
import os
import time
import requests
BASE = os.environ["EGAB_BASE"]
KEY = os.environ["EGAB_KEY"]
SECRET = os.environ["EGAB_SECRET"]
def egab(method, path, payload=None, idempotency_key=None):
"""Sign and send one request. Returns (status, decoded_body)."""
method = method.upper()
body = b"" if payload is None else json.dumps(
payload, separators=(",", ":"), ensure_ascii=False
).encode()
# The query string is requested but not signed. Strip it here and nowhere
# else: signing it is a 401 that looks like a clock problem.
signed_path = path.split("?", 1)[0]
timestamp = str(int(time.time()))
# method, path, sha256 of the raw body, unix seconds - joined by "\n".
canonical = "\n".join(
[method, signed_path, hashlib.sha256(body).hexdigest(), timestamp]
).encode()
signature = hmac.new(SECRET.encode(), canonical, hashlib.sha256).hexdigest()
headers = {
"Accept": "application/json",
"X-Egab-Key": KEY,
"X-Egab-Timestamp": timestamp,
"X-Egab-Signature": signature,
}
if body:
headers["Content-Type"] = "application/json"
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
# data=body, never json=payload: requests would re-encode it and the
# signature would stop covering the bytes that went out.
response = requests.request(
method, BASE + path, headers=headers, data=body or None, timeout=30
)
return response.status_code, response.json()Node
import crypto from 'node:crypto';
const BASE = process.env.EGAB_BASE;
const KEY = process.env.EGAB_KEY;
const SECRET = process.env.EGAB_SECRET;
// Sign and send one request. Returns { status, body }.
// Node 18 or newer, for global fetch.
async function egab(method, path, payload = null, idempotencyKey = null) {
const verb = method.toUpperCase();
const body = payload === null ? '' : JSON.stringify(payload);
// The query string is requested but not signed. Strip it here and nowhere
// else: signing it is a 401 that looks like a clock problem.
const signedPath = path.split('?')[0];
const timestamp = String(Math.floor(Date.now() / 1000));
// method, path, sha256 of the raw body, unix seconds - joined by "\n".
const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
const canonical = [verb, signedPath, bodyHash, timestamp].join('\n');
const signature = crypto.createHmac('sha256', SECRET).update(canonical).digest('hex');
const headers = {
Accept: 'application/json',
'X-Egab-Key': KEY,
'X-Egab-Timestamp': timestamp,
'X-Egab-Signature': signature,
};
if (body !== '') headers['Content-Type'] = 'application/json';
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
// The exact string that was hashed is the string that goes out. Handing
// fetch the object instead would sign bytes you did not send.
const response = await fetch(BASE + path, {
method: verb,
headers,
body: body === '' ? undefined : body,
});
return { status: response.status, body: await response.json() };
}PHP
<?php
final class EgabClient
{
public function __construct(
private readonly string $baseUrl,
private readonly string $publicKey,
private readonly string $secret,
) {}
/**
* Sign and send one request.
*
* @param array<string, mixed>|null $payload
* @return array{status: int, body: array<string, mixed>}
*/
public function send(
string $method,
string $path,
?array $payload = null,
?string $idempotencyKey = null,
): array {
$method = strtoupper($method);
$path = '/'.ltrim($path, '/');
$body = $payload === null
? ''
: json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
// The query string is requested but not signed. Stripped here and
// nowhere else: signing it is a 401 that looks like a clock problem.
$signedPath = explode('?', $path, 2)[0];
$timestamp = (string) time();
// method, path, sha256 of the raw body, unix seconds - joined by "\n".
$canonical = implode("\n", [$method, $signedPath, hash('sha256', $body), $timestamp]);
$signature = hash_hmac('sha256', $canonical, $this->secret);
$headers = [
'Accept: application/json',
'X-Egab-Key: '.$this->publicKey,
'X-Egab-Timestamp: '.$timestamp,
'X-Egab-Signature: '.$signature,
];
if ($body !== '') {
$headers[] = 'Content-Type: application/json';
}
if ($idempotencyKey !== null) {
$headers[] = 'Idempotency-Key: '.$idempotencyKey;
}
$options = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
];
// Set only when there is one. Handing cURL an empty body makes it add
// a Content-Type of its own to a GET, which this API does not want.
// A string, never an array: an array makes cURL re-encode the body and
// the signature would stop covering what was sent.
if ($body !== '') {
$options[CURLOPT_POSTFIELDS] = $body;
}
$curl = curl_init($this->baseUrl.$path);
curl_setopt_array($curl, $options);
$response = (string) curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
return [
'status' => $status,
'body' => json_decode($response, true, 512, JSON_THROW_ON_ERROR),
];
}
}
$egab = new EgabClient(getenv('EGAB_BASE'), getenv('EGAB_KEY'), getenv('EGAB_SECRET'));Four details in there are the ones that go wrong, and they go wrong the same way in every language:
- Sign the bytes you send. Build the body string once, hash that string,
and put that same string on the wire. Every HTTP library will happily
re-encode a structure for you —
requests'json=,fetch's object body, cURL's arrayPOSTFIELDS— and re-encoding is what silently breaks a signature that looks correct in your logs. - Hash the empty body too. A
GETsigns the SHA-256 of the empty string, which is a real 64-character hex value, not an empty line. None of the four special-case it, and nor should yours. - Uppercase the method, and sign the concrete path.
postis notPOST, and/api/v1/bulksms/sms/7c1b9a40-…is not/api/v1/bulksms/sms/{reference}. - The idempotency key is the caller's, not the signer's. None of the four mints one, because a key minted inside the request is a different key on the retry — which is the one thing it must not be. See Idempotency.
A query string is not signed. ?per_page=100 can be added, removed or
reordered without re-signing.
Base URL and content types
Every path on this page is relative to the base URL you were given, and
every one of them begins /api/v1/bulksms.
Every example on this page writes the base URL as https://egabsms.com.
Confirm the host for your own account against whatever was issued alongside
your API key: a sandbox is not the same host as production, so an example
copied as-is can be pointed at the wrong one. If you are not sure which host
your key belongs to, ask whoever issued it.
Change the host and nothing else changes: the path, the signing, and the headers are identical on every host.
https://egabsms.com/api/v1/bulksms/sms
└──── base URL ───┘└────── path ─────┘
JSON is the only format, in both directions. There is no form-encoded or
XML variant of this API, and there is no .json suffix to add.
| Header | On | Value |
|---|---|---|
X-Egab-Key |
every request | Your key's public half |
X-Egab-Timestamp |
every request | Unix time in seconds |
X-Egab-Signature |
every request | See Authentication |
Accept |
every request | application/json |
Content-Type |
the two POSTs | application/json |
Idempotency-Key |
the two POSTs | A fresh UUID per send. See Idempotency |
A GET takes neither Content-Type nor Idempotency-Key. Sending them is
harmless and ignored.
A body that is not valid JSON is a 422, reported like any other
validation failure. Note that an invalid body still has its bytes hashed into
the signature, so a malformed payload fails validation rather than
authentication — if you are getting a 401 on a request you believe is
well-formed, the problem is the signature, not the JSON.
The envelope
Every answer, success or failure, has the same shape.
A success:
{
"success": true,
"message": "OK",
"data": {},
"errors": {},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d"
}
A failure adds a sixth key, error:
{
"success": false,
"message": "The given data was invalid.",
"data": null,
"errors": { "to": ["The to field is required."] },
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d",
"error": {
"code": "VALIDATION_FAILED",
"message": "The given data was invalid.",
"fields": { "to": ["The to field is required."] }
}
}
Three things to know about it:
- Branch on
error.code, not onmessage. The codes are a contract; the messages are prose and may be reworded. The codes are listed under Error codes. errorsanderror.fieldshold the same value on a failure, anderrorsis{}on a success. The duplication is deliberate:errorsis the key a client written against any of this platform's APIs already reads, anderror.fieldsis the same value under theerrorobject.meta.request_idandtrace_idare the same identifier, and it is also theX-Request-Idresponse header and the value every log line for the request carries. Quote it in a support ticket.
Empty maps serialise as {} rather than [] — errors, meta and
error.fields are cast to objects before encoding.
Two 401 and 403 bodies do not use this envelope. Authentication runs in front of it, and its refusals keep the shape the platform's API-key scheme publishes everywhere. See Authentication.
Your first request
GET /balance is the request to make first. It costs nothing, needs no sender
ID, and if it answers 200 then your key, your clock and your signing are all
correct — which is most of what goes wrong on a first integration.
Throughout this page, $EGAB_KEY and $EGAB_SECRET are the public key and
the secret from your own key, and https://egabsms.com is your base URL. The
secret is shown once, when the key is created, and cannot be shown again.
Export both, then run this as it stands:
#!/usr/bin/env bash
set -euo pipefail
export EGAB_KEY="pk_live_your_key_here"
export EGAB_SECRET="sk_live_your_secret_here"
BASE="https://egabsms.com"
PATH_="/api/v1/bulksms/balance"
TS=$(date +%s)
BODY_HASH=$(printf '%s' '' | openssl dgst -sha256 -hex | awk '{print $NF}')
CANONICAL=$(printf 'GET\n%s\n%s\n%s' "$PATH_" "$BODY_HASH" "$TS")
SIG=$(printf '%s' "$CANONICAL" \
| openssl dgst -sha256 -hmac "$EGAB_SECRET" -hex | awk '{print $NF}')
curl -sS "${BASE}${PATH_}" \
-H 'Accept: application/json' \
-H "X-Egab-Key: ${EGAB_KEY}" \
-H "X-Egab-Timestamp: ${TS}" \
-H "X-Egab-Signature: ${SIG}"
A new account answers:
{
"success": true,
"message": "OK",
"data": {
"balances": [
{ "category": "promotional", "country_code": "NG", "balance": 0, "reserved": 0, "available": 0, "updated_at": null },
{ "category": "transactional", "country_code": "NG", "balance": 0, "reserved": 0, "available": 0, "updated_at": null }
]
},
"errors": {},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d"
}
If instead you get a 401, the body is not the envelope above — it is
{"error": "invalid_api_key", ...} — and the cause is almost always one of the
three in Authentication. A 403 means the key is good but
was not granted the SMS API capability.
Once that works, send a single message.
A sensible client loop
GET /balanceand check the relevant category'savailablebefore a large batch. A402mid-run is avoidable.GET /sender-idsonce at startup, cache it, and pick anapprovedsender whosetypematches what you are about to send.POSTthe send with a freshIdempotency-Keyper logical message or batch, and persist that key alongside your own record before you make the call — a key you did not store cannot be used to retry.- On
429, sleepmeta.retry_afterseconds and retry the same request with the same key. On5xx, retry with backoff, same key. - Store the
referencefrom the201. - Poll
GET /sms/{reference}for single sends, and stop atdeliveredorfailed. WatchX-RateLimit-Remainingif you are polling many references. - For a bulk send, poll the
campaign_urlfrom the201instead — one request covers the whole batch, where one per message would not. Stop whenpending_countreaches0, then page?status=failedfor the numbers that need attention.
Send an SMS
Two endpoints send: one message to one recipient, or one message to many. Both
are POST, both require an Idempotency-Key, and both answer 201 with a
reference you can read status back from.
Choose by shape, not by volume. One recipient is the single endpoint even
if you call it in a loop; many recipients in one logical send is the bulk
endpoint even if there are only three of them, because the bulk endpoint gives
you one reference covering the batch instead of one per message.
Send a single message
One message, one recipient, sent now.
POST /api/v1/bulksms/sms
Requires an Idempotency-Key header. See Idempotency.
Request body:
| Field | Rules | Notes |
|---|---|---|
to |
required, string, max 20 | The recipient. Normalised to E.164 — send 08031234567 and the message is sent to +2348031234567 |
message |
required, string, max 1000 | Billed in segments, not characters — see What a message costs |
sender_id |
optional, string, max 20 | Omit to let the platform resolve one. See Sender IDs |
message_category |
optional, transactional or promotional |
Omit to infer it from the resolved sender's type |
{
"to": "08031234567",
"message": "Your verification code is 123456",
"sender_id": "BRANDX",
"message_category": "transactional"
}
201 Created:
{
"success": true,
"message": "OK",
"data": {
"reference": "7c1b9a40-3f2e-4d58-9b61-0a2c8d4e5f36",
"status": "pending",
"sender_id": "BRANDX",
"message_category": "transactional"
},
"errors": {},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d"
}
referenceis the message's UUID. Store it; it is the only handle you get on the message.statusat creation ispending. Neverqueued— that is a later state, and reporting it at creation would be claiming something that has not happened yet. This endpoint takes no scheduling parameter, so a send made through it is never createdscheduled.sender_idis the sender the platform actually used. If you named one, it is the one you named. If you named none, it is the one resolved for you — so read it back rather than assuming.message_categoryis likewise the category the send was actually billed under, resolved from that sender's type when you did not declare one.- You send
to; aGETof the message answers withrecipient. The two name the same number. Expect the rename when you read a message back.
A send can be refused for money (402), for the sender (422
sender_id_not_approved / sender_id_type_mismatch), for content (422
spam_keyword_blocked) or for the payload (422 VALIDATION_FAILED). All of
them are in Error codes. Nothing is reserved or charged when a
send is refused.
What a message costs
message has a 1000-character ceiling, but that is not the billing unit. A
message is split into segments, and you are billed per segment per
recipient. The split depends on which characters you used, not only on how many:
| Your message | One segment holds | Beyond one segment, each holds |
|---|---|---|
| Uses only the basic GSM character set — ASCII letters, digits, punctuation, and a short list of accented and currency characters | 160 characters | 153 characters |
| Uses anything else — emoji, curly quotes, most non-Latin scripts | 70 characters | 67 characters |
Two consequences worth designing around:
- A single unexpected character can halve your capacity. One emoji, or a
curly
’pasted from a word processor in place of', moves the whole message to the 70-character measure. A 150-character message that cost one segment costs three the moment that apostrophe changes. - The characters
^ { } \ [ ] ~ |and€count as two even though they are in the basic set. They do not force the smaller measure; they just take two places each.
A message at or under the one-segment limit is one segment. Above it, the count is the character total divided by the smaller per-segment figure, rounded up — so the limits are 160, 306, 459 for the basic set and 70, 134, 201 otherwise.
Segments are not the same as units: how many units a segment costs is set by
your billing profile, which is why
Check your balance reports units and this page does not
convert between the two. Count segments to predict the shape of a bill; read
available to find out whether a send will clear.
Request example. Mint the idempotency key before the call and store it with your own record of the message. A key you did not persist cannot be used to retry.
cURL
# Needs the egab function from Authentication.
BODY='{"to":"08031234567","message":"Your verification code is 123456","sender_id":"BRANDX","message_category":"transactional"}'
IDEM=$(uuidgen)
egab POST /api/v1/bulksms/sms "$BODY" "$IDEM"Python
import uuid
# Needs egab() from Authentication.
idempotency_key = str(uuid.uuid4())
status, body = egab(
"POST",
"/api/v1/bulksms/sms",
{
"to": "08031234567",
"message": "Your verification code is 123456",
"sender_id": "BRANDX",
"message_category": "transactional",
},
idempotency_key=idempotency_key,
)
if status == 201:
reference = body["data"]["reference"]
else:
# Branch on the code, never on the message. The auth refusals are the one
# exception to the envelope: there, "error" is a flat string.
error = body.get("error")
code = error.get("code") if isinstance(error, dict) else (error or "UNKNOWN")Node
import { randomUUID } from 'node:crypto';
// Needs egab() from Authentication.
const idempotencyKey = randomUUID();
const { status, body } = await egab(
'POST',
'/api/v1/bulksms/sms',
{
to: '08031234567',
message: 'Your verification code is 123456',
sender_id: 'BRANDX',
message_category: 'transactional',
},
idempotencyKey,
);
let reference;
if (status === 201) {
reference = body.data.reference;
} else {
// Branch on the code, never on the message. The auth refusals are the one
// exception to the envelope: there, `error` is a flat string.
const code =
typeof body.error === 'object' && body.error !== null
? body.error.code
: body.error ?? 'UNKNOWN';
}PHP
<?php
// Needs $egab from Authentication.
$idempotencyKey = bin2hex(random_bytes(16));
$result = $egab->send('POST', '/api/v1/bulksms/sms', [
'to' => '08031234567',
'message' => 'Your verification code is 123456',
'sender_id' => 'BRANDX',
'message_category' => 'transactional',
], $idempotencyKey);
if ($result['status'] === 201) {
$reference = $result['body']['data']['reference'];
} else {
// Branch on the code, never on the message. The auth refusals are the one
// exception to the envelope: there, `error` is a flat string.
$error = $result['body']['error'] ?? null;
$code = is_array($error) ? $error['code'] : ($error ?? 'UNKNOWN');
}The cURL tab stops at the response and the other three read it. That is a
real difference and not an oversight: branching on error.code in shell means
piping to jq, and a reader who wants that will write it better than this page
can. Take the shell tab as the request and the other three as the integration.
Send a bulk message
One message, many recipients, sent now, as a campaign.
POST /api/v1/bulksms/sms/bulk
Requires an Idempotency-Key header. See Idempotency.
Request body:
| Field | Rules | Notes |
|---|---|---|
recipients |
required, array, at least 1, at most the platform's per-request ceiling | See the note on the cap below |
recipients.* |
string, max 20 | A number that is invalid, repeated, or blacklisted is dropped rather than refusing the request — see recipient_count below |
message |
required, string, max 1000 | Billed in segments per recipient — see What a message costs |
sender_id |
optional, string, max 20 | |
message_category |
optional, transactional or promotional |
{
"recipients": ["08031234567", "08069876543", "07011122233"],
"message": "Flash sale ends tonight.",
"sender_id": "BRANDX",
"message_category": "promotional"
}
201 Created:
{
"success": true,
"message": "OK",
"data": {
"reference": "1d4e7f20-8b35-4c9a-a0d2-6e5f3b1c9a84",
"status": "pending",
"recipient_count": 3,
"sender_id": "BRANDX",
"message_category": "promotional",
"campaign_url": "/api/v1/bulksms/campaigns/1d4e7f20-8b35-4c9a-a0d2-6e5f3b1c9a84"
},
"errors": {},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d"
}
referencehere is a campaign UUID, not a message UUID, and the two namespaces do not overlap. The individual message references are not returned by this endpoint. The response includes acampaign_urlpointing toGET /api/v1/bulksms/campaigns/{reference}, which returns per-message status.campaign_urlis a path on this host, not an absolute URL. Join it to the base URL you already use. It is the only endpoint that will accept thisreference: a single send'sreferenceis a message, and the campaign read answers404for one.recipient_countis the count that was accepted, not the count you sent. Invalid, duplicate and blacklisted numbers are dropped during preparation, and this figure is what will be billed and delivered. Compare it against the length of the array you sent if you need to know that some were dropped.- A refusal refuses the whole batch. The sender is resolved and checked
against the message category before anything is reserved, so a sender that
does not clear refuses all of it with nothing spent. There is no outcome
where some recipients are charged and the call still fails. Dropped numbers
are not that case: they are reported in
recipient_counton a201.
The recipient cap is a platform-wide figure, not a per-account one. It is
10,000 recipients per request, and the same ceiling applies however the send is
submitted.
Exceeding it is a 422 VALIDATION_FAILED keyed on recipients, refused
before anything is reserved, and the message names both figures:
{
"success": false,
"message": "The given data was invalid.",
"data": null,
"errors": {
"recipients": ["You supplied 12,000 recipients. The most that can be sent in a single request is 10,000."]
},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d",
"error": {
"code": "VALIDATION_FAILED",
"message": "The given data was invalid.",
"fields": {
"recipients": ["You supplied 12,000 recipients. The most that can be sent in a single request is 10,000."]
}
}
}
Do not hard-code 10000 against this endpoint: read the refusal, or batch to a
size you have agreed with the operator.
Every refusal a single send can give, a bulk send can give too, and it
refuses the whole batch rather than part of it: 402 for units, 422
sender_id_not_approved or sender_id_type_mismatch for the sender, 422
spam_keyword_blocked for the content, 422 VALIDATION_FAILED for the
payload — plus the recipients cap above, which is a VALIDATION_FAILED keyed
on a field rather than a code of its own. The shared set applies as everywhere:
401, 403, 429, 500. All of them are in
Error codes. Nothing is reserved or charged when a bulk send is
refused.
Request example. Mint the idempotency key before the call, exactly as for a single send, and store it with your own record of the campaign. A bulk send is the call you least want to make twice.
cURL
# Needs the egab function from Authentication.
BODY='{"recipients":["08031234567","08069876543","07011122233"],"message":"Flash sale ends tonight.","sender_id":"BRANDX","message_category":"promotional"}'
IDEM=$(uuidgen)
egab POST /api/v1/bulksms/sms/bulk "$BODY" "$IDEM"Python
import uuid
# Needs egab() from Authentication.
recipients = ["08031234567", "08069876543", "07011122233"]
idempotency_key = str(uuid.uuid4())
status, body = egab(
"POST",
"/api/v1/bulksms/sms/bulk",
{
"recipients": recipients,
"message": "Flash sale ends tonight.",
"sender_id": "BRANDX",
"message_category": "promotional",
},
idempotency_key=idempotency_key,
)
if status == 201:
campaign = body["data"]["reference"]
accepted = body["data"]["recipient_count"]
dropped = len(recipients) - accepted
else:
error = body.get("error")
code = error.get("code") if isinstance(error, dict) else (error or "UNKNOWN")
# The recipient cap arrives as a field error, not as its own code.
too_many = body.get("errors", {}).get("recipients")Node
import { randomUUID } from 'node:crypto';
// Needs egab() from Authentication.
const recipients = ['08031234567', '08069876543', '07011122233'];
const idempotencyKey = randomUUID();
const { status, body } = await egab(
'POST',
'/api/v1/bulksms/sms/bulk',
{
recipients,
message: 'Flash sale ends tonight.',
sender_id: 'BRANDX',
message_category: 'promotional',
},
idempotencyKey,
);
if (status === 201) {
const campaign = body.data.reference;
const accepted = body.data.recipient_count;
const dropped = recipients.length - accepted;
} else {
const code =
typeof body.error === 'object' && body.error !== null
? body.error.code
: body.error ?? 'UNKNOWN';
// The recipient cap arrives as a field error, not as its own code.
const tooMany = body.errors?.recipients;
}PHP
<?php
// Needs $egab from Authentication.
$recipients = ['08031234567', '08069876543', '07011122233'];
$idempotencyKey = bin2hex(random_bytes(16));
$result = $egab->send('POST', '/api/v1/bulksms/sms/bulk', [
'recipients' => $recipients,
'message' => 'Flash sale ends tonight.',
'sender_id' => 'BRANDX',
'message_category' => 'promotional',
], $idempotencyKey);
if ($result['status'] === 201) {
$campaign = $result['body']['data']['reference'];
$accepted = $result['body']['data']['recipient_count'];
$dropped = count($recipients) - $accepted;
} else {
$error = $result['body']['error'] ?? null;
$code = is_array($error) ? $error['code'] : ($error ?? 'UNKNOWN');
// The recipient cap arrives as a field error, not as its own code.
$tooMany = $result['body']['errors']['recipients'] ?? null;
}Answers 201 with a campaign reference and "recipient_count": 3 — or
fewer, if one of the three was a duplicate, invalid, or blacklisted. The three
non-shell tabs compute dropped from the array they sent, because that
subtraction is the only way to learn that something was dropped: nothing in the
response names the numbers that did not make it.
Read it back with the campaign_url from that response — see
Check delivery status.
Check delivery status
Two endpoints read status, and which one you use depends on which kind of
reference you hold: a single send gives you a message reference, a bulk send
gives you a campaign reference. The two namespaces do not overlap, and a
reference of the wrong kind answers 404 rather than redirecting.
Neither takes an Idempotency-Key. Both are GETs, so the body you hash into
the signature is the empty string.
A reference that is not yours is not found
A reference that exists but belongs to another account answers 404, not
403. The lookup is scoped to your account before it is scoped to the
reference, so from outside there is no difference between "no such message" and
"not your message" — which is the point: a 403 would confirm the reference
exists.
404 is the only refusal either read has of its own. Neither takes a body,
so neither can return a 422. Everything else they can answer is the set every
endpoint shares: 401 for a signature or clock problem, 403 for a key
without the capability, 429 over the rate limit, 500 for a fault at our
end. All of them are in Error codes. A 404 is not retryable —
a reference does not become yours later.
Polling a single message
GET /api/v1/bulksms/sms/{reference} — a single message's status and its
lifecycle.
No Idempotency-Key is required, and none is checked. See
Idempotency.
Takes no body and no query string: the reference in the path is the whole request.
data fields:
| Field | Type | Notes |
|---|---|---|
reference |
string | The message reference you asked for, echoed back |
recipient |
string | The normalised number, in E.164 — generally not the string you sent |
status |
string | One of the nine values under Status vocabulary |
message_category |
string | transactional or promotional, as actually billed |
sender_id |
string | The sender actually used, which may be one you did not name |
provider_status |
string or null |
The provider's own last word, in the provider's vocabulary. null until the provider has said anything |
timeline |
array | One entry per state that actually happened, ordered by occurred_at |
timeline[].status |
string | Same vocabulary as the top-level status |
timeline[].occurred_at |
string | ISO-8601 |
200 OK:
{
"success": true,
"message": "OK",
"data": {
"reference": "7c1b9a40-3f2e-4d58-9b61-0a2c8d4e5f36",
"recipient": "+2348031234567",
"status": "delivered",
"message_category": "transactional",
"sender_id": "BRANDX",
"provider_status": "DELIVRD",
"timeline": [
{ "status": "pending", "occurred_at": "2026-10-04T09:12:44+00:00" },
{ "status": "queued", "occurred_at": "2026-10-04T09:12:45+00:00" },
{ "status": "processing", "occurred_at": "2026-10-04T09:12:47+00:00" },
{ "status": "sent", "occurred_at": "2026-10-04T09:12:49+00:00" },
{ "status": "delivered", "occurred_at": "2026-10-04T09:13:02+00:00" }
]
},
"errors": {},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d"
}
{reference}is a single message's reference. A campaign reference from the bulk endpoint is not a message reference, so passing one answers404. Read a campaign reference withGET /campaigns/{reference}instead.recipientis the normalised number, in E.164, which is generally not the string you sent.provider_statusis the provider's last word, in the provider's own vocabulary, and may benullbefore the provider has said anything. It is deliberately not folded intostatusor into the timeline.timelineis ordered by when each event happened, not by any fixed list of states: a provider callback can land a delivery before the local send timestamp is written, and the sort is on the timestamp.occurred_atis ISO-8601.- Only states that actually happened appear. A missing entry is an event
that has not occurred, not an event at an unknown time. One consequence:
cancelledcarries no timestamp of its own, so a cancelled message shows the cancellation instatusand never intimeline. timeline[].statususes the same vocabulary as the top-levelstatus, so there is one set of words to learn.
Status vocabulary
status is one of pending, queued, processing, sent, delivered,
failed, scheduled, cancelled, blacklisted. This endpoint reads any of
your account's messages however it was created, so scheduled and cancelled
are reachable here even though this API offers no way to schedule or cancel.
Only pending, queued, processing, sent, delivered and failed ever
appear in timeline — they are the six states that are individually
timestamped.
Treat delivered and failed as terminal and stop polling.
Request example: polling one message
A GET has an empty body, and the empty body still hashes: the canonical
string carries the SHA-256 of the empty string,
e3b0c442...b855, in the same position a POST carries the hash of its JSON.
Hash '' rather than special-casing it. Send no Idempotency-Key.
cURL
# Needs the egab function from Authentication.
REFERENCE="7c1b9a40-3f2e-4d58-9b61-0a2c8d4e5f36"
# No body argument, so no Content-Type and no Idempotency-Key are sent, and
# the hash in the canonical string is the hash of "".
egab GET "/api/v1/bulksms/sms/${REFERENCE}"Python
# Needs egab() from Authentication.
reference = "7c1b9a40-3f2e-4d58-9b61-0a2c8d4e5f36"
status, body = egab("GET", f"/api/v1/bulksms/sms/{reference}")
if status == 200:
message_status = body["data"]["status"]
done = message_status in ("delivered", "failed")
elif status == 404:
# No such reference in your account. Not retryable.
done = True
else:
error = body.get("error")
code = error.get("code") if isinstance(error, dict) else (error or "UNKNOWN")Node
// Needs egab() from Authentication.
const reference = '7c1b9a40-3f2e-4d58-9b61-0a2c8d4e5f36';
const { status, body } = await egab('GET', `/api/v1/bulksms/sms/${reference}`);
let done = false;
if (status === 200) {
const messageStatus = body.data.status;
done = messageStatus === 'delivered' || messageStatus === 'failed';
} else if (status === 404) {
// No such reference in your account. Not retryable.
done = true;
} else {
const code =
typeof body.error === 'object' && body.error !== null
? body.error.code
: body.error ?? 'UNKNOWN';
}PHP
<?php
// Needs $egab from Authentication.
$reference = '7c1b9a40-3f2e-4d58-9b61-0a2c8d4e5f36';
$result = $egab->send('GET', "/api/v1/bulksms/sms/{$reference}");
if ($result['status'] === 200) {
$messageStatus = $result['body']['data']['status'];
$done = in_array($messageStatus, ['delivered', 'failed'], true);
} elseif ($result['status'] === 404) {
// No such reference in your account. Not retryable.
$done = true;
} else {
$error = $result['body']['error'] ?? null;
$code = is_array($error) ? $error['code'] : ($error ?? 'UNKNOWN');
}The path signed is the path requested, {reference} substituted. The
canonical string uses the concrete path and never the route pattern — signing
the literal {reference} is a 401 that looks like a clock problem.
Polling a whole campaign
GET /api/v1/bulksms/campaigns/{reference} — a bulk send's progress, and the
outcome of each message in it. This is where the reference from
POST /sms/bulk is spent, and it is what campaign_url in that response
points at.
No Idempotency-Key is required, and none is checked. See
Idempotency.
Query parameters:
| Parameter | Rules | Default | Notes |
|---|---|---|---|
per_page |
integer, at least 1 | 50 |
Capped at 200. A larger value is clamped, not refused |
page |
integer, at least 1 | 1 |
Standard pagination |
status |
one of the message statuses | none | Filters the message list only — see the note below |
200 OK:
{
"success": true,
"message": "OK",
"data": {
"reference": "1d4e7f20-8b35-4c9a-a0d2-6e5f3b1c9a84",
"status": "processing",
"message_count": 3,
"delivered_count": 1,
"failed_count": 1,
"pending_count": 1,
"created_at": "2026-10-04T09:12:44+00:00",
"updated_at": "2026-10-04T09:13:02+00:00",
"messages": {
"data": [
{
"reference": "7c1b9a40-3f2e-4d58-9b61-0a2c8d4e5f36",
"recipient": "+2348031234567",
"status": "delivered",
"provider_status": "DELIVRD",
"message_category": "promotional",
"sender_id": "BRANDX",
"timeline": [
{ "status": "pending", "occurred_at": "2026-10-04T09:12:44+00:00" },
{ "status": "sent", "occurred_at": "2026-10-04T09:12:49+00:00" },
{ "status": "delivered", "occurred_at": "2026-10-04T09:13:02+00:00" }
]
}
],
"meta": {
"current_page": 1,
"per_page": 50,
"total": 3,
"last_page": 1
}
}
},
"errors": {},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d"
}
{reference}is a campaign's reference, and only a campaign's. This endpoint resolves campaigns only, and/sms/{reference}resolves messages only. Passing either one's reference to the other answers404, in both directions, and the two endpoints will not start accepting each other's ids.- Each message's fields mean what they mean on
GET /sms/{reference}— samestatusvocabulary, sameprovider_statuscaveat, sametimelineordering and the same rule that only states which actually happened appear. The per-messagereferenceis a message reference, so it can be read on/sms/{reference}directly. messages.metacarries four keys and no URLs. It is a page descriptor nested insidedata, not the response's ownmeta—meta.request_idat the top level is unaffected. There are deliberately nolinks: those would be absolute URLs assembled from the incoming request.- The message list is newest first, with the message reference breaking
ties. Every message in a campaign is written in the same instant, so
created_atalone is not a total order and a page boundary would fall inside a tie. - An unknown
statusis a422naming the field, not a silently unfiltered list. A typo in a filter that quietly returns everything is a bug the caller cannot see.
The four counters
| Field | Source | Means |
|---|---|---|
message_count |
counted live | How many messages this campaign has — the total the list pages through when nothing is filtered |
delivered_count |
the campaign's own counter | Messages the network confirmed delivered |
failed_count |
the campaign's own counter | Messages with a recorded failure |
pending_count |
counted live | Messages still in flight: pending, queued, processing or scheduled |
Two things follow, and both matter if you are reconciling a batch:
pending_countis counted, not subtracted.message_count - delivered - failedis the wrong arithmetic:deliveredandfailedare independent outcome metrics over the same messages and one message can answer both — accepted by the gateway, reported undeliverable by the network, then reported delivered by a late callback. Do not expect the four to sum tomessage_count; each answers its own question.- A
statusfilter narrowsmessagesand nothing else. The four counters describe the campaign, not the page you are reading, so they do not move as you page or filter.messages.meta.totalis the filtered count;message_countis not.
Poll until pending_count reaches 0, then read failed_count and page
?status=failed for the numbers that need attention. Or register a
webhook and do not poll at all.
What is not returned
Costs, provider identifiers, raw gateway responses and free-text error messages are not on this surface, for the campaign or for its messages. Nor is the recipient list: read the messages for that. The response is written out field by field rather than dumped, so what you see here is what this version promises — nothing appears later by accident, and nothing you can see today goes away without a version change.
Request example: reading a campaign
Signed exactly like the single-message read — the path, the hash of an empty
body, the timestamp. The query string is not part of the canonical string,
so paging and filtering need no re-signing and a signature can be reused
across ?page=1 and ?page=2 within its timestamp window.
cURL
# Needs the egab function from Authentication.
REFERENCE="3f8e1d20-77a4-4c19-8e52-1b6d9f0a2c34"
egab GET "/api/v1/bulksms/campaigns/${REFERENCE}?per_page=100&status=failed"Python
# Needs egab() from Authentication.
reference = "3f8e1d20-77a4-4c19-8e52-1b6d9f0a2c34"
status, body = egab(
"GET", f"/api/v1/bulksms/campaigns/{reference}?per_page=100&status=failed"
)
if status == 200:
data = body["data"]
# Poll on pending_count, not on the length of the page you asked for.
done = data["pending_count"] == 0
failed = [m["reference"] for m in data["messages"]["data"]]
else:
error = body.get("error")
code = error.get("code") if isinstance(error, dict) else (error or "UNKNOWN")Node
// Needs egab() from Authentication.
const reference = '3f8e1d20-77a4-4c19-8e52-1b6d9f0a2c34';
const { status, body } = await egab(
'GET',
`/api/v1/bulksms/campaigns/${reference}?per_page=100&status=failed`,
);
if (status === 200) {
// Poll on pending_count, not on the length of the page you asked for.
const done = body.data.pending_count === 0;
const failed = body.data.messages.data.map((m) => m.reference);
} else {
const code =
typeof body.error === 'object' && body.error !== null
? body.error.code
: body.error ?? 'UNKNOWN';
}PHP
<?php
// Needs $egab from Authentication.
$reference = '3f8e1d20-77a4-4c19-8e52-1b6d9f0a2c34';
$result = $egab->send(
'GET',
"/api/v1/bulksms/campaigns/{$reference}?per_page=100&status=failed",
);
if ($result['status'] === 200) {
$data = $result['body']['data'];
// Poll on pending_count, not on the length of the page you asked for.
$done = $data['pending_count'] === 0;
$failed = array_column($data['messages']['data'], 'reference');
} else {
$error = $result['body']['error'] ?? null;
$code = is_array($error) ? $error['code'] : ($error ?? 'UNKNOWN');
}?status=failed narrows messages and leaves the four counters alone, so
the loop above reads pending_count from a filtered response without having
to fetch the campaign twice.
Balance
One endpoint, and the number you check before a large batch.
Check your balance
The units you can spend, per category.
GET /api/v1/bulksms/balance
No Idempotency-Key is required, and none is checked.
Takes no body, no path parameter and no query string.
Each entry of data.balances:
| Field | Type | Notes |
|---|---|---|
category |
string | promotional or transactional. Key on this — the two entries are always both present, in that order |
country_code |
string | The billing country your units are priced in. Informational |
balance |
integer | Units held, before reservations |
reserved |
integer | Units held against sends already in flight |
available |
integer | balance less reserved, floored at zero. This is the figure to decide on |
updated_at |
string or null |
ISO-8601, per entry. null for a category you have never held units in |
200 OK:
{
"success": true,
"message": "OK",
"data": {
"balances": [
{
"category": "promotional",
"country_code": "NG",
"balance": 4820,
"reserved": 120,
"available": 4700,
"updated_at": "2026-10-04T09:12:44+00:00"
},
{
"category": "transactional",
"country_code": "NG",
"balance": 0,
"reserved": 0,
"available": 0,
"updated_at": null
}
]
},
"errors": {},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d"
}
balancesalways contains exactly two entries —promotionalandtransactional— in that order. You may rely on both categories being present. An account that has never bought units gets both at zero rather than an empty list, withupdated_at: nullfor a category you have never held units in, so you can read a category'savailablewithout special-casing a new account. Both entries are always present, and that is a promise of this endpoint rather than an accident of your account's history.availableis what you can actually spend —balanceless what isreservedagainst sends already in flight, floored at zero. Make the pre-flight decision onavailable, not onbalance.- All three figures are integer units, not money and not message counts. One message may cost more than one unit: a message is billed per segment per recipient, and how many units a segment costs comes from your billing profile. See What a message costs.
- A promotional balance says nothing about whether a transactional send will
clear. The two ledgers are independent and move independently, which is why
updated_atis per entry and there is no single top-level timestamp. country_codeis the billing country your units are priced in, and it is the same for both entries today. Key oncategorywhen you read this list; treatcountry_codeas informational.
Request example.
A GET with no path parameters, so the only thing that varies between calls is
the timestamp. The body is empty and the empty body still hashes — see
Check delivery status for why. Send no
Idempotency-Key.
The useful shape is not "fetch the balance" but "decide whether to send", so
all four tabs read one category's available out of the list rather than
printing the envelope.
cURL
# Needs the egab function from Authentication.
egab GET /api/v1/bulksms/balancePython
# Needs egab() from Authentication.
status, body = egab("GET", "/api/v1/bulksms/balance")
available = {}
if status == 200:
# Both categories are always present, so this never comes back short.
for entry in body["data"]["balances"]:
available[entry["category"]] = entry["available"]
# Decide on `available`, not on `balance`, and in units rather than messages:
# a long message is billed per segment, so this is a floor and not a count.
can_send = available.get("transactional", 0) > 0Node
// Needs egab() from Authentication.
const { status, body } = await egab('GET', '/api/v1/bulksms/balance');
const available = {};
if (status === 200) {
// Both categories are always present, so this never comes back short.
for (const entry of body.data.balances) {
available[entry.category] = entry.available;
}
}
// Decide on `available`, not on `balance`, and in units rather than messages:
// a long message is billed per segment, so this is a floor and not a count.
const canSend = (available.transactional ?? 0) > 0;PHP
<?php
// Needs $egab from Authentication.
$result = $egab->send('GET', '/api/v1/bulksms/balance');
$available = [];
if ($result['status'] === 200) {
// Both categories are always present, so this never comes back short.
foreach ($result['body']['data']['balances'] as $entry) {
$available[$entry['category']] = $entry['available'];
}
}
// Decide on `available`, not on `balance`, and in units rather than messages:
// a long message is billed per segment, so this is a floor and not a count.
$canSend = ($available['transactional'] ?? 0) > 0;This endpoint has no refusals of its own. It takes no input, so there is
nothing to validate and no 422, and it reads an account you are already
authenticated as, so there is no 404. What you can get back is the set every
endpoint shares: 401 for a signature or clock problem, 403 for a key
without the capability, 429 over the rate limit, 500 for a fault at our
end. All of them are in Error codes.
A 200 here is not a promise that your next send will clear. Units can be
reserved by another call of yours between this read and that send, so treat the
figure as a pre-flight check that avoids obviously doomed batches, and still
handle 402 on the send itself.
Webhooks
Everything above is you calling us. This is us calling you: register a URL, and
the platform POSTs to it when a message reaches a terminal state, so you do not
have to poll GET /sms/{reference} for every send.
A webhook is an addition to the polling endpoint, not a replacement for it. It
is delivered at-least-once with a bounded number of attempts, and an endpoint
that stays down long enough is switched off (see
Retry and disable) — so a correct integration still
treats GET /sms/{reference} as the authority when it needs one.
| Registered in | The dashboard, under SMS → Webhooks — not through this API |
| Events | bulksms.message.delivered, bulksms.message.failed, bulksms.balance.low — subscribe to each one you want |
| Signature header | X-Egab-Signature: t=<unix>,v1=<hex> |
| Idempotency header | X-Egab-Idempotency-Key: <uuid>, stable across retries |
| Retry curve | 60s, 300s, 1800s, 7200s — 5 attempts total |
| Auto-disable | 20 consecutive failures |
| Delivery log retention | 90 days |
Setting up an endpoint
Everything a receiver needs that is the same for all three events: where an endpoint comes from, how a delivery arrives, how to verify it, and how to deduplicate it. Operating an endpoint covers what happens once it is live, and Event reference covers only what differs per event.
Registering an endpoint
Webhook endpoints are managed from the dashboard, by a signed-in user whose role allows it — your account administrator, in most accounts. There is no API-key-authenticated route for this. Registering an endpoint is something a person does once, in a browser, rather than something your client code calls.
Open the Webhooks page under your account settings to add an endpoint, see the ones you already have, rotate a secret, clear a tripped breaker or remove one. Everything in this section happens on that page; none of it is something your integration calls.
The form asks for two fields and offers a third:
| Field | Rules |
|---|---|
url |
Required. A URL, at most 2048 characters |
events[] |
Required, at least one. bulksms.message.delivered, bulksms.message.failed and bulksms.balance.low are the accepted values |
description |
Optional, at most 255 characters — your own label for the endpoint |
Those three are the events the platform sends today, and they are independent subscriptions: none implies another, so an endpoint that wants both outcomes of a send has to name both. Further events are listed under Coming later; the validator rejects anything else with "That is not an event this platform sends", so there is nothing to be gained by registering for a name you have read about elsewhere.
Event names are prefixed, and the prefix is part of the name. bulksms.
says which part of the platform published the event. It is part of the value
you register and part of the X-Egab-Event header you receive. Dropping the
prefix does not give you a shorter spelling of the same event; it gives you a
name registration refuses. Other parts of the platform will publish webhooks
under their own prefixes, which is why an unprefixed name is not offered.
Two of the three describe a message; bulksms.balance.low describes your
account. Its data block has no reference, so a receiver that assumes every
delivery is about one message needs a branch on event before it reads data —
see bulksms.balance.low.
bulksms.message.failed is not sent for every failed message. It covers the
failures of a single message — a delivery report that came back undelivered,
rejected or expired; a transport rejection at submission; a sender ID that no
longer carries the message's category; a gateway error that outlived its
retries; and a single message blocked for billing, whether it was sent
immediately or on a schedule. It is deliberately NOT
sent when a whole campaign or batch is abandoned at once, or when a recipient
is refused for being blacklisted. Neither of those has an event today — see
Coming later — so treat
GET /api/v1/bulksms/sms/{reference}
as the authority on whether a given message failed.
Your URL is checked when a delivery is attempted, not when you register
it. Registration validates shape only — that the string is a URL and the
events exist — and deliberately so: the host check resolves DNS and a verdict
taken at registration is about a moment that has already passed by the time
anything is delivered.
On each attempt the URL must be https and must resolve to a public address;
private and loopback ranges are refused.
A registration
that saves cleanly can therefore still have every delivery refused — the
endpoint's page shows those refusals, so check it after your first send rather
than assuming silence means success.
The signing secret is shown once
Saving the form redirects to a page that renders the plaintext signing secret, and that page renders it exactly once: the value travels in a single-request flash, and reading it is what consumes it. Refreshing, navigating back, or opening the URL again shows the "it's gone" branch. Copy it into your receiver's configuration before you leave the page.
The platform stores only an encrypted copy and cannot show it to you again. If you lose it, Rotate secret mints a replacement — and because the old secret keeps verifying for 24 hours, losing it is recoverable without an outage. See Rotating the secret.
What a delivery looks like
A POST with four headers and a JSON body:
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
X-Egab-Event: bulksms.message.delivered
X-Egab-Signature: t=1791277642,v1=5f2b...e71c
X-Egab-Idempotency-Key: 3f1c8a90-5d2e-4a77-9b1f-6c0e8d4a2b33
Note that a delivery body is not wrapped in the success/data/meta
envelope the API responses use. It is an event, not an answer to a request.
Answer 2xx and answer it quickly. The platform allows 10 seconds per
request and treats
anything else — a non-2xx status, a timeout, a refused connection — as a
failure to be retried. If your handler does real work, acknowledge first and
queue the work.
Verifying the signature
Every delivery is signed with HMAC-SHA256, and every event is signed the same
way — the canonical input is {timestamp}.{body} whatever the event is, so one
verification function covers all of them. Verify it before you trust the body:
the URL is the only thing an attacker needs to POST you a plausible-looking
bulksms.message.delivered or a bulksms.message.failed for a message that is fine.
X-Egab-Signature: t=<unix>,v1=<hex> ordinary
X-Egab-Signature: t=<unix>,v1=<hex>,v0=<hex> during a rotation grace window
tis the unix second the signature was made.v1is always signed with the current secret.v0is always signed with the previous secret, and is present only during a rotation grace window.- Parse by name, not by position. Both parameters sign the same canonical
string, so a receiver that has already migrated checks
v1and never looks atv0.
The canonical input is "{t}.{raw_body}" — the timestamp, a literal dot,
then the request body bytes as received, before any JSON parsing. Decoding
to a dict and re-encoding it will not reproduce them, and the signature will not
match. Read the raw body first, verify, then parse.
The algorithm:
parts = parse_header(X-Egab-Signature)
if now() - parts.t > 300:
reject # replay window — your policy, see below
expected = hmac_sha256(parts.t + "." + raw_body, your_secret)
if constant_time_equals(expected, parts.v1):
accept
else if parts.v0 exists and constant_time_equals(
hmac_sha256(parts.t + "." + raw_body, previous_secret), parts.v0):
accept # still in the grace window
else:
reject
Two notes on that:
- Compare in constant time.
hash_equals,hmac.compare_digest,crypto.timingSafeEqual— not==. - The 300-second window is a recommendation, not a platform guarantee. The
platform does not cap how old a
tit will deliver, and a retry at the fourth backoff step is signed freshly rather than re-sent, so the window never legitimately rejects one. Tighten or widen it to taste; refusing a stale timestamp is what stops a captured delivery being replayed at you later.
Python
import hashlib
import hmac
import time
def egab_verify_webhook(header, raw_body, secret, previous_secret=None, tolerance=300):
"""raw_body is bytes — request.get_data(), not request.json."""
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
if "t" not in parts or "v1" not in parts:
return False
if abs(time.time() - int(parts["t"])) > tolerance:
return False
canonical = parts["t"].encode() + b"." + raw_body
mac = lambda key: hmac.new(key.encode(), canonical, hashlib.sha256).hexdigest()
if hmac.compare_digest(mac(secret), parts["v1"]):
return True
return bool(
previous_secret
and "v0" in parts
and hmac.compare_digest(mac(previous_secret), parts["v0"])
)Node
import crypto from 'node:crypto';
// CommonJS: const crypto = require('node:crypto');
// rawBody is a Buffer. In Express: express.raw({ type: 'application/json' }).
function egabVerifyWebhook(header, rawBody, secret, previousSecret = null, tolerance = 300) {
const parts = Object.fromEntries(
header.split(',').map((pair) => pair.trim().split('=', 2)),
);
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const canonical = Buffer.concat([Buffer.from(`${parts.t}.`), rawBody]);
const mac = (key) => crypto.createHmac('sha256', key).update(canonical).digest('hex');
const equal = (a, b) =>
a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
if (equal(mac(secret), parts.v1)) return true;
return Boolean(previousSecret && parts.v0 && equal(mac(previousSecret), parts.v0));
}PHP
<?php
/**
* True if $header signs $rawBody. $rawBody must be the literal request bytes:
* file_get_contents('php://input'), or Laravel's $request->getContent().
*/
function egab_verify_webhook(
string $header,
string $rawBody,
string $secret,
?string $previousSecret = null,
int $tolerance = 300,
): bool {
$parts = [];
foreach (explode(',', $header) as $pair) {
[$name, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
$parts[$name] = $value;
}
if (! isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$canonical = $parts['t'].'.'.$rawBody;
if (hash_equals(hash_hmac('sha256', $canonical, $secret), $parts['v1'])) {
return true;
}
return $previousSecret !== null
&& isset($parts['v0'])
&& hash_equals(hash_hmac('sha256', $canonical, $previousSecret), $parts['v0']);
}There is no cURL tab here, because verifying a signature is something your server does on a request it received, not something you can send.
The PHP example is extracted from this page and executed against a real delivery and a tampered one on every build, so it cannot drift from what the platform actually sends. The Python and Node examples are checked for the properties that matter — both parameters, constant-time comparison — but are not executed, so read them before you trust them.
Idempotency on your receiver
X-Egab-Idempotency-Key is stable across retries. One logical delivery has
one key, whether it took one attempt or five.
Store recently-seen keys, and on a repeat return 2xx without reprocessing.
A webhook is delivered at-least-once, not exactly-once: a receiver that answered
200 after its own connection dropped will be called again, and the key is how
you tell that from a second event.
This is the same mechanism as the Idempotency-Key header documented under
Idempotency above, at the other end — there you mint the key and
we deduplicate, here we mint it and you do.
A 24-hour memory is a sensible default; the last retry of a delivery arrives just under two and a half hours after the first.
Operating an endpoint
What happens to a live endpoint: how a failed delivery is retried, when the platform stops trying, how to replace a secret without an outage, and how long the delivery log is kept.
Retry and disable
A failure is a non-2xx status, a timeout, or a refused connection.
Retries: 60s, 300s, 1800s, 7200s — five attempts in all. Four delays for five attempts; the fifth has nothing after it to wait for, so a delivery is abandoned just under two and a half hours after the first attempt. The curve is front-loaded because a receiver that 502s is usually mid-deploy.
Two failures are not retried, because the next attempt would fail identically: a URL the outbound host check refuses, and a stored secret the platform cannot decrypt. Both are recorded in the delivery log.
20 consecutive failures disables the endpoint. The count is consecutive across deliveries, and any success resets it to zero — so an endpoint that fails one message in ten never trips, and one that has gone away trips after twenty. Once disabled, nothing further is delivered until a person re-enables it.
Failures inside an active rotation grace window do not count toward that threshold. The window exists to tolerate a receiver that is mid-migration, and the usual shape of that migration is a receiver rejecting deliveries while it is being changed — counting those would spend the breaker on exactly the traffic the window was opened for.
To re-enable, use the button on the endpoint's page. It clears
disabled_at and resets the failure count, and does
nothing else; it does not test whether your receiver is fixed, because the next
delivery is what finds that out.
Re-enabling does not replay the deliveries missed while it was off.
Rotating the secret
Rotate secret is a button on the endpoint's page. The new secret is shown once, on the same show-once page registration uses.
The old secret keeps verifying for 24 hours.
For the
window's duration every delivery carries both parameters — v1 under the new
secret, v0 under the old — so a receiver holding either one succeeds, and the
rotation does not have to be simultaneous with your config change.
The migration:
- Rotate. Copy the new secret.
- Deploy your receiver so it verifies
v1against the new secret andv0against the old one — which the examples above already do if you pass both. - Confirm deliveries are being accepted on
v1. - Drop the old secret from your receiver, and stop accepting
v0.
Two things worth knowing before you click it:
- Rotating resets the consecutive-failure counter to zero. That is deliberate — the count describes the receiver's behaviour under the old secret — but it means a rotation is also a breaker reset. Rotate when the receiver is ready for the new secret, not as a way to quiet a failing endpoint.
- A rotation keeps emitting a signature made with the secret you just replaced, for 24 hours. If you are rotating because the old secret leaked, rotate twice — the second rotation, inside the window, drops the leaked secret immediately.
Rotating does not re-enable a disabled endpoint — see Retry and disable for that. Rotating a secret is not a claim that the receiver is fixed.
Delivery log retention
Every delivery is recorded: the event, the payload, the signature that was sent, the attempt count, the response status and a truncated response body. The endpoint's page is where you read it, and it is the record support quotes when the question is "you never called us".
Each delivery is kept for 90 days, then pruned daily. Export anything you need to keep longer.
Deleting an endpoint deletes its delivery log with it, immediately and regardless of the window.
Coming later
Named here as capabilities rather than as event names, so you do not build around their absence — and so you do not build against a name that would be refused today.
bulksms.message.delivered, bulksms.message.failed and
bulksms.balance.low are the only events the platform emits, and the only ones
registration accepts. There is no fourth name to subscribe to, including the
two gaps below. When they ship they will be announced with their names; until
then, a registration naming anything else is refused.
Two failures are deliberately outside bulksms.message.failed and have no
event of their own yet:
- A campaign or batch abandoned as a whole, rather than message by message.
- A recipient refused for being blacklisted, which is a refusal to send rather than a failure to deliver.
Both reach you today only by reading
GET /api/v1/bulksms/sms/{reference},
so a receiver that must see them needs the status read as well as the webhook —
which is the one case on this page where polling is still required.
Event reference
The payload of each of the three events. Everything common to all three —
headers, signing, retries — is above; these sections cover only the data
block and when the event fires.
bulksms.message.delivered
Sent when a message reaches delivered — the carrier confirmed it. One event
per message, and a receiver that only wants the happy path can subscribe to
this alone.
{
"event": "bulksms.message.delivered",
"occurred_at": "2026-10-06T09:07:22+00:00",
"data": {
"reference": "9c2f41e8-7b3d-4a16-8e05-1d7a9f3b2c04",
"recipient": "08031234567",
"status": "delivered",
"message_category": "transactional",
"sender_id": "BRANDX",
"provider_status": "DELIVRD",
"timeline": [
{ "status": "pending", "occurred_at": "2026-10-06T09:07:14+00:00" },
{ "status": "queued", "occurred_at": "2026-10-06T09:07:15+00:00" },
{ "status": "processing", "occurred_at": "2026-10-06T09:07:16+00:00" },
{ "status": "sent", "occurred_at": "2026-10-06T09:07:18+00:00" },
{ "status": "delivered", "occurred_at": "2026-10-06T09:07:22+00:00" }
]
}
}
data is the same shape GET /api/v1/bulksms/sms/{reference} answers with,
field for field, so a parser written for the polling endpoint reads a webhook
body without a second one.
status and timeline[].status use the vocabulary in
Status vocabulary; provider_status is the provider's own
last word and is not that vocabulary.
bulksms.message.failed
Sent when a single message reaches failed. Subscribing to
bulksms.message.delivered does not imply this one; a receiver that wants
both outcomes names both.
bulksms.message.failed sends the same data block plus failure_code and
failure_reason:
{
"event": "bulksms.message.failed",
"occurred_at": "2026-10-06T09:07:22+00:00",
"data": {
"reference": "9c2f41e8-7b3d-4a16-8e05-1d7a9f3b2c04",
"recipient": "08031234567",
"status": "failed",
"message_category": "transactional",
"sender_id": "BRANDX",
"provider_status": "UNDELIVERED",
"failure_code": "undelivered",
"failure_reason": "Delivery failed — the recipient's network could not deliver the message.",
"timeline": [
{ "status": "pending", "occurred_at": "2026-10-06T09:07:14+00:00" },
{ "status": "queued", "occurred_at": "2026-10-06T09:07:15+00:00" },
{ "status": "processing", "occurred_at": "2026-10-06T09:07:16+00:00" },
{ "status": "sent", "occurred_at": "2026-10-06T09:07:18+00:00" },
{ "status": "failed", "occurred_at": "2026-10-06T09:07:22+00:00" }
]
}
}
| Field | What it is |
|---|---|
failure_code |
A stable slug. Branch on this. One of undelivered, rejected, failed, expired, sender_type_mismatch, billing_not_authorized, or unknown |
failure_reason |
An English sentence for the same code, safe to show a person. Wording may be improved without notice — it is not a contract |
provider_status still carries the provider's own last word, unchanged and
untranslated. failure_code is the platform's reading of it, and the two are not
the same vocabulary: a code of unknown with a provider_status present means
the provider said something outside its documented set, which is worth a support
ticket rather than a branch in your handler.
This is the one place a webhook body is not field-for-field what polling
returns. GET /sms/{reference} answers the same shape for a failed message as
for any other — the reason is in the timeline and in the message's own
failure_reason slug — so these two fields exist only here. They are here
because a receiver wants to branch on the failure kind without making a second
request, which is the entire point of being sent an event.
A bulksms.message.delivered body is unaffected: it carries exactly the fields it
carried before bulksms.message.failed existed.
bulksms.balance.low
Sent when your available units cross below the low-balance threshold. The one
event of the three that is not about a message.
bulksms.balance.low is the one event that is not about a message. It is sent
when your available units cross from at-or-above the platform's low-balance
threshold to below it, so an automated top-up can run before your sends start
being refused.
It reports a spend-down, not every way your balance can fall. The event
means "a send is about to be refused": it fires when you spend or transfer
units past the line. If support adjusts your balance downward past the same
line, you get no webhook — that movement reaches you as an ADJ entry in
SMS → Transactions with the reason the operator recorded, and there is no
notification of any kind for it. Treat the event as a prompt about your
sending capacity and the transaction list as the record of your ledger:
{
"event": "bulksms.balance.low",
"occurred_at": "2026-10-08T11:42:03+00:00",
"data": {
"category": "promotional",
"country_code": "NG",
"available_units": 42,
"threshold_units": 100,
"balance_url": "https://egabsms.com/sms/pricing/balances"
}
}
| Field | What it is |
|---|---|
category |
Which balance crossed: promotional or transactional. They are separate balances and are notified separately |
country_code |
The balance's country, ISO 3166-1 alpha-2 |
available_units |
Units you can still reserve — balance minus what is already reserved for in-flight sends. This, not your purchased total, is what a send is checked against |
threshold_units |
The threshold that was crossed. Sent with every delivery because it is operator-configurable — see below |
balance_url |
The dashboard page for this balance, on the same host as the request that triggered the event — call the platform on https://egabsms.com and that is the host you get back. null in the unlikely case the platform cannot build it; do not make it load-bearing |
threshold_units is not a constant, so do not hard-code it. The threshold
is set by your account administrator — 100 units unless they change it — and
may change without notice. Reading it from the body is the only way to tell a warning
apart from a change of policy — the same 42 units is a warning under a threshold
of 100 and silence under a threshold of 40.
You will not get one of these per send. Two things limit it, and both are operator-configurable:
| Crossing only | The event fires on the transition from at-or-above the threshold to below it, not on every send while you are under it. A balance that sits at 42 units is announced once, not once per message |
| Cooldown | At most one crossing per balance per cooldown window — 24 hours unless your account administrator changes it — which absorbs the case where you top up above the line and send back below it several times in a day |
The cooldown is per balance, not per endpoint. Every endpoint subscribed to the event receives every crossing that is sent — the window does not stop a second endpoint hearing about the same crossing. What it stops is the next crossing on that balance, for all of your endpoints at once. Registering a second endpoint therefore doubles the deliveries you receive and does not shorten anyone's quiet period.
occurred_at on this event is fixed at the crossing and does not change
across retries. The two message events differ here: their occurred_at is
recomputed on each attempt, so a redelivered bulksms.message.delivered carries
the time of the retry rather than of the original attempt. If you deduplicate,
key on X-Egab-Idempotency-Key, which is stable across retries for all three
events; (event, occurred_at) is not a safe key for the message events.
Zero available units does not send this event. Out of units is a different
state from running low, and your sends are already being refused with an
insufficient-units error by the time you reach it — a warning then would arrive
after the thing it was meant to pre-empt. Treat
GET /api/v1/bulksms/balance as the authority on your current position; this
event is a prompt to go and look, not a ledger.
Reference
The four things you will look up again after the integration works.
Error codes
Every failure carries a machine-readable error.code. Branch on the code and
the HTTP status; do not parse message.
| HTTP status | error.code |
When |
|---|---|---|
401 |
(see note 1) invalid_api_key |
Bad signature, unknown key, revoked key, or a timestamp outside the skew window |
402 |
INSUFFICIENT_UNITS |
Not enough SMS units for this send. meta.required_units and meta.available_units say how short |
402 |
INSUFFICIENT_WALLET_BALANCE |
The wallet or package behind the send could not cover it |
403 |
(see note 1) insufficient_scope |
A live key without the SMS API capability |
404 |
NOT_FOUND |
No such reference in your account — see Not yours is not found |
409 |
IDEMPOTENCY_KEY_REUSED |
This Idempotency-Key was already used for a different request body |
409 |
IDEMPOTENCY_IN_PROGRESS |
A request with this key is still running. Retry shortly |
422 |
VALIDATION_FAILED |
The payload broke a rule. errors / error.fields are keyed by field |
422 |
IDEMPOTENCY_KEY_REQUIRED |
A POST arrived without an Idempotency-Key header |
422 |
INVALID_IDEMPOTENCY_KEY |
The key is longer than 120 characters |
422 |
sender_id_not_approved |
The named sender is not an approved sender on your account. meta.sender_id |
422 |
sender_id_type_mismatch |
The sender's type and the message's category disagree. meta.sender_id, meta.sender_type, meta.requested_category |
422 |
spam_keyword_blocked |
The message matched blocked content. meta.keywords |
422 |
OPERATION_REFUSED |
A domain refusal with no more specific code — pricing, wallet, or an argument the service rejected |
429 |
RATE_LIMITED |
Over 60 requests in a minute on this key. meta.retry_after and the Retry-After header |
429 |
(see note 1) too_many_attempts |
Twenty failed authentications from one IP in a minute. A different 429 from the one above |
500 |
SERVER_ERROR |
A fault on our side. Quote meta.request_id |
Note 1 — the auth refusals do not use the envelope. invalid_api_key,
insufficient_scope and too_many_attempts are refused before the request
reaches the SMS API at all, and they answer in the shape
{"error": "<code>", "message": "..."} — a flat string in error, not an
object. insufficient_scope has no message at all. See
Authentication. Everything else in this table is the
envelope, with error.code as given.
401 and 403 can also arrive in the envelope shape, with error.code set to
UNAUTHENTICATED or FORBIDDEN. On this surface that happens only when a
request authenticated successfully and was then refused on authorization, not
when the credential itself is the problem. Handle both shapes for 401 and
403 if you want to be exhaustive: check for a success: false body first,
and fall back to reading the flat error string.
Which failures are retryable
Retry the same request, with the same Idempotency-Key |
429, 500, 409 IDEMPOTENCY_IN_PROGRESS |
| Fix something first | 401, 403, 422, 404 |
| Do not retry blind | 402 — top up, then retry with the same key if you want the original send, or a new key if you have rebuilt the payload |
Sender IDs
A sender ID is the name a recipient sees. Two things govern whether a send is accepted.
1. The sender must be an approved sender on your account. Naming one that
is pending, rejected, disabled, or belongs to someone else, is 422
sender_id_not_approved with meta.sender_id.
List yours with
GET /api/v1/bulksms/sender-ids.
2. The sender's type must agree with the message's message_category. A
transactional sender cannot carry a promotional message or the reverse;
that is 422 sender_id_type_mismatch, and the refusal tells you all three
values so you can fix it without a second call — meta.sender_id,
meta.sender_type, meta.requested_category. The check runs again when the
message is actually sent, so a sender that is disabled between your request and
the send is caught there too.
Omitting both fields
Both sender_id and message_category are optional, and omitting them is the
normal case for a simple integration. When you omit them, they are not sent as
null — they are left out of the payload entirely, so the platform resolves a
sender and infers the category from that sender's type.
The response reports what was actually used, so read sender_id and
message_category back from it rather than assuming.
Declare both when the category matters to you. A verification code billed
and delivered as promotional traffic is a real operational difference, and the
only way to be certain is to name an approved transactional sender and declare
message_category: "transactional".
Applying for a sender ID, what the review checks, and how an application maps to billing categories are handled in the dashboard by your account administrator. There is no endpoint on this API for submitting an application.
Read your registry
GET /api/v1/bulksms/sender-ids — your account's sender ID registry: what you
may send as, and each sender's type.
No Idempotency-Key is required, and none is checked.
Takes no body, no path parameter and no query string. It is not paginated.
Each entry of data:
| Field | Type | Notes |
|---|---|---|
sender_id |
string | The name a recipient sees |
type |
string | transactional or promotional. Must agree with the message_category of any send naming this sender |
status |
string | pending, approved, rejected or disabled. Only approved may be used in a send |
is_default |
boolean | Whether this is the sender used when you name none |
approved_at |
string or null |
ISO-8601, null until approved |
created_at |
string | ISO-8601, when the application was made |
200 OK. Note that data is a bare list, not an object with a key:
{
"success": true,
"message": "OK",
"data": [
{
"sender_id": "BRANDX",
"type": "transactional",
"status": "approved",
"is_default": true,
"approved_at": "2026-09-18T11:04:20+00:00",
"created_at": "2026-09-15T08:33:10+00:00"
},
{
"sender_id": "BrandXAds",
"type": "promotional",
"status": "pending",
"is_default": false,
"approved_at": null,
"created_at": "2026-10-01T16:21:05+00:00"
}
],
"errors": {},
"meta": { "request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d" },
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d"
}
- Every registered sender ID, in every status — not just the usable ones. A
pendingapplication and arejectedone are the two most useful things to see from code: one means wait, the other means apply again with different evidence, and filtering the list down toapprovedwould make both indistinguishable from "never applied".statusis one ofpending,approved,rejected,disabled. Onlyapprovedmay be used in a send. typeistransactionalorpromotional, and it must agree with themessage_categoryof any send that names this sender. This endpoint is what lets you satisfy that without guessing.- Platform-level fallback senders are not listed. They belong to no account, and listing them would read as an entitlement — you would send as one and be refused.
- Ordered by
sender_id. The list is empty ("data": []) for an account that has never applied for one.
Request example: reading the registry
A request example applies here, because this is a request: a signed GET like
any other read, with no path parameters and no query string. It is the one read
on this API you are likely to call before a send rather than after one, so
all four tabs end with the question you came to ask — may I send as this name,
in this category?
cURL
# Needs the egab function from Authentication.
egab GET /api/v1/bulksms/sender-idsPython
# Needs egab() from Authentication.
status, body = egab("GET", "/api/v1/bulksms/sender-ids")
# `data` is a bare list here, not an object with a key.
senders = body["data"] if status == 200 else []
usable = {
sender["sender_id"]: sender["type"]
for sender in senders
if sender["status"] == "approved"
}
# Both halves of the rule in one check: approved, and the right type.
may_send = usable.get("BRANDX") == "transactional"Node
// Needs egab() from Authentication.
const { status, body } = await egab('GET', '/api/v1/bulksms/sender-ids');
// `data` is a bare list here, not an object with a key.
const senders = status === 200 ? body.data : [];
const usable = new Map(
senders
.filter((sender) => sender.status === 'approved')
.map((sender) => [sender.sender_id, sender.type]),
);
// Both halves of the rule in one check: approved, and the right type.
const maySend = usable.get('BRANDX') === 'transactional';PHP
<?php
// Needs $egab from Authentication.
$result = $egab->send('GET', '/api/v1/bulksms/sender-ids');
// `data` is a bare list here, not an object with a key.
$senders = $result['status'] === 200 ? $result['body']['data'] : [];
$usable = [];
foreach ($senders as $sender) {
if ($sender['status'] === 'approved') {
$usable[$sender['sender_id']] = $sender['type'];
}
}
// Both halves of the rule in one check: approved, and the right type.
$maySend = ($usable['BRANDX'] ?? null) === 'transactional';Reading this list is not a substitute for handling the refusal. A sender
can be disabled between this call and your send, and the check runs again when
the message is actually sent, so keep the 422 branches for
sender_id_not_approved and sender_id_type_mismatch whatever this list said.
Like the balance read, this endpoint has no refusals of its own — no input to
validate, no reference to miss. Only the shared set applies: 401, 403,
429, 500. See Error codes.
Applying for a sender ID is not part of this API. Your account administrator applies for one in the dashboard; this endpoint tells you when it is approved.
Rate limits
60 requests per minute, per API key, across the whole surface — reads and writes share one budget. The window is a fixed 60 seconds.
The bucket is per API key, not per account and not per IP. One account may hold a staging key and a production key, and a retry loop in the first does not spend the second's budget.
Every response carries your remaining budget, successes included, so you can back off before you are refused:
| Header | On | Meaning |
|---|---|---|
X-RateLimit-Limit |
every response | The per-minute ceiling |
X-RateLimit-Remaining |
every response | Requests left in this window |
Retry-After |
429 only |
Seconds until the window clears |
X-RateLimit-Reset |
429 only |
When the window clears |
A refusal is 429 with error.code RATE_LIMITED, and Retry-After is also
lifted into meta.retry_after so you need not read headers:
{
"success": false,
"message": "Too many requests. Please try again later.",
"data": null,
"errors": {},
"meta": {
"request_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d",
"retry_after": 37
},
"trace_id": "9f8c1e52-5a1d-4a6f-9d7b-2c3f4a5b6c7d",
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please try again later.",
"fields": {}
}
}
There is a second, unrelated 429. Twenty failed authentications from
one IP in a minute trips the platform's auth throttle, which answers
{"error": "too_many_attempts", ...} in the flat shape and is cleared
immediately by one successful request.
If you are seeing too_many_attempts, your signing is wrong — back off and
fix the signature; retrying the same broken request will not clear it.
An unauthenticated request never reaches a bucket: auth runs first, so a 401
carries no X-RateLimit-* headers at all.
Idempotency
A send spends money and cannot be recalled. An integrator whose connection drops after the request left has no way to tell whether it landed — so without a key, the only choices are double-charging and not sending at all. Hence:
Idempotency-Key is required on both POST endpoints. A POST without one is
refused 422 IDEMPOTENCY_KEY_REQUIRED, and nothing is sent.
GET endpoints do not require an Idempotency-Key, and do not check one. A
read never advertises a write-only requirement. Sending the header on a GET is
harmless and ignored.
The rules
- Generate a fresh key per logical send. A UUID is the obvious choice. Max
120 characters; longer is
422 INVALID_IDEMPOTENCY_KEY. - Same key, same request → the stored answer is replayed, with an
Idempotent-Replayed: trueresponse header. The original status code and body come back; nothing is sent twice. - Same key, different request →
409 IDEMPOTENCY_KEY_REUSED. A key names one request, and "the same request" means the method, the path and the payload with its keys sorted, so two encodings of one payload agree. - Same key while the first is still running →
409 IDEMPOTENCY_IN_PROGRESS. Retry shortly. - Keys expire after 24 hours. After that the key is free again and the same key would do the work a second time. Do not use idempotency as a long-term duplicate guard.
- The key is read from the header only. There is no body fallback on this surface: the signature covers the body, so a key in the body would be covered by the signature while one in the header would not, and the same retry would fingerprint two ways depending on where you put it.
Which outcomes a key remembers
5xx, 401, 403 and 404 are not stored: a fault and the caller's
state at the time are not answers the next attempt should be held to, so
retrying after one of these does real work.
422 and 402 are stored. A retry with the same key after a refused send
replays the refusal rather than re-attempting it. If you fix the cause — top up
units, correct the payload — use a new key.
Detecting a replay
On a replay, X-Request-Id reflects the current request, not the stored
one. The stored body names the request that did the work; you are quoting
this one, so meta.request_id and trace_id are swapped for the current
request's id while the payload itself is returned untouched.
Use Idempotent-Replayed: true as the replay signal — not the request id.
Comparing request ids will tell you nothing: they differ on a replay exactly as
they do on a fresh request.