Skip to main content
Delivering across MTN, Airtel, Glo & 9mobile — with real delivery reports on every message. +2348118875965 support@egabsms.com
Developers

API Documentation

Authentication, request signing, webhook events and delivery retries.

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 array POSTFIELDS — and re-encoding is what silently breaks a signature that looks correct in your logs.
  • Hash the empty body too. A GET signs 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. post is not POST, 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 on message. The codes are a contract; the messages are prose and may be reworded. The codes are listed under Error codes.
  • errors and error.fields hold the same value on a failure, and errors is {} on a success. The duplication is deliberate: errors is the key a client written against any of this platform's APIs already reads, and error.fields is the same value under the error object.
  • meta.request_id and trace_id are the same identifier, and it is also the X-Request-Id response 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

  1. GET /balance and check the relevant category's available before a large batch. A 402 mid-run is avoidable.
  2. GET /sender-ids once at startup, cache it, and pick an approved sender whose type matches what you are about to send.
  3. POST the send with a fresh Idempotency-Key per 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.
  4. On 429, sleep meta.retry_after seconds and retry the same request with the same key. On 5xx, retry with backoff, same key.
  5. Store the reference from the 201.
  6. Poll GET /sms/{reference} for single sends, and stop at delivered or failed. Watch X-RateLimit-Remaining if you are polling many references.
  7. For a bulk send, poll the campaign_url from the 201 instead — one request covers the whole batch, where one per message would not. Stop when pending_count reaches 0, then page ?status=failed for 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"
}
  • reference is the message's UUID. Store it; it is the only handle you get on the message.
  • status at creation is pending. Never queued — 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 created scheduled.
  • sender_id is 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_category is likewise the category the send was actually billed under, resolved from that sender's type when you did not declare one.
  • You send to; a GET of the message answers with recipient. 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"
}
  • reference here 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 a campaign_url pointing to GET /api/v1/bulksms/campaigns/{reference}, which returns per-message status.
  • campaign_url is 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 this reference: a single send's reference is a message, and the campaign read answers 404 for one.
  • recipient_count is 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_count on a 201.

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 answers 404. Read a campaign reference with GET /campaigns/{reference} instead.
  • recipient is the normalised number, in E.164, which is generally not the string you sent.
  • provider_status is the provider's last word, in the provider's own vocabulary, and may be null before the provider has said anything. It is deliberately not folded into status or into the timeline.
  • timeline is 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_at is 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: cancelled carries no timestamp of its own, so a cancelled message shows the cancellation in status and never in timeline.
  • timeline[].status uses the same vocabulary as the top-level status, 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 answers 404, 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} — same status vocabulary, same provider_status caveat, same timeline ordering and the same rule that only states which actually happened appear. The per-message reference is a message reference, so it can be read on /sms/{reference} directly.
  • messages.meta carries four keys and no URLs. It is a page descriptor nested inside data, not the response's own meta — meta.request_id at the top level is unaffected. There are deliberately no links: 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_at alone is not a total order and a page boundary would fall inside a tie.
  • An unknown status is a 422 naming 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_count is counted, not subtracted. message_count - delivered - failed is the wrong arithmetic: delivered and failed are 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 to message_count; each answers its own question.
  • A status filter narrows messages and 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.total is the filtered count; message_count is 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"
}
  • balances always contains exactly two entries — promotional and transactional — 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, with updated_at: null for a category you have never held units in, so you can read a category's available without 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.
  • available is what you can actually spend — balance less what is reserved against sends already in flight, floored at zero. Make the pre-flight decision on available, not on balance.
  • 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_at is per entry and there is no single top-level timestamp.
  • country_code is the billing country your units are priced in, and it is the same for both entries today. Key on category when you read this list; treat country_code as 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/balance

Python

# 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) > 0

Node

// 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
  • t is the unix second the signature was made.
  • v1 is always signed with the current secret.
  • v0 is 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 v1 and never looks at v0.

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 t it 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:

  1. Rotate. Copy the new secret.
  2. Deploy your receiver so it verifies v1 against the new secret and v0 against the old one — which the examples above already do if you pass both.
  3. Confirm deliveries are being accepted on v1.
  4. 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 pending application and a rejected one 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 to approved would make both indistinguishable from "never applied". status is one of pending, approved, rejected, disabled. Only approved may be used in a send.
  • type is transactional or promotional, and it must agree with the message_category of 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-ids

Python

# 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: true response 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.

See Pricing Start Free