// API REFERENCE · v1

MobiReach SMS API

Send SMS and one-time codes to Ugandan mobile numbers, manage contacts and receive signed delivery webhooks. Machine-readable description: /api/openapi.json (OpenAPI 3.1). Base URL:

https://mobireach.theanathoth.com/api/v1

Quick start

1. Create a key under Developers → API keys in the app (start with a mr_test_ key: nothing is sent or billed). 2. Send a message. The response lists one message_id per recipient; the same ids arrive in your webhooks. These are code samples you can paste and run; there is no SDK to install. Without from the message goes out from your default sender ID, or the shared platform sender until you have one; once a sender ID is approved, add "from": "YOURNAME" with its exact value.

code samples
curl -X POST https://mobireach.theanathoth.com/api/v1/messages \
  -H "Authorization: Bearer $MOBIREACH_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"to": ["+256772123456"], "message": "Hello from MobiReach"}'

Authentication & scopes

Send the key in the Authorization: Bearer … header on every request, never in the URL. Keys are shown once; only a hash is stored. A key can be limited to IP addresses or ranges (CIDR), given an expiry date, its own rate limit, and rotated: the old key keeps working for a grace period you choose so you can deploy the new one. Test keys (mr_test_) never reach a network, are never billed, and their OTP code is always 000000; their webhooks carry "livemode": false.

ScopeAllows
sms:sendSend messages (POST /v1/messages, /v1/sms/send)
reports:readRead messages and batches (GET /v1/messages, /v1/batches)
otp:sendSend one-time codes (POST /v1/otp/send)
otp:verifyVerify one-time codes (POST /v1/otp/verify)
contacts:readRead contacts and groups
contacts:writeCreate, change and delete contacts and groups
balance:readRead the credit balance (and sender IDs)
sender_ids:readList approved sender IDs
webhooks:writeManage webhook endpoints (GET/POST/PATCH/DELETE /v1/webhooks)

A new key without ticked scopes gets sms:send, reports:read and balance:read.

Credits & segments

1 credit = 1 SMS segment. A message written only with GSM-7 characters is 1 segment up to 160 characters, then 153 characters per segment. Any other character (emoji, curly quotes ‘ ’, most accented letters) makes it Unicode: 70 characters, then 67 per segment. Each recipient is billed for its own rendered text (placeholders change the length). Credits are reserved when a batch is accepted and settled once every message was attempted; a message no network accepted is never billed. Free trial credits pay only for test sends from the app, never for live API traffic.

Encoding1 segmentEach further segment
GSM-7160 characters153 characters
Unicode70 characters67 characters

Rate limits

Each key may make 120 requests per minute unless a lower or higher limit (up to 600) is set on the key. The OTP endpoints add 10 per minute per key. Requests with a missing or invalid key are limited to 30 per minute per IP. One request may carry up to 1000 numbers in to (use group_ids for larger audiences).

Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit you get 429 rate_limit_exceeded with Retry-After in seconds.

Idempotency

Network errors happen. Send an Idempotency-Key header (1-255 printable characters, a UUID is ideal) with POST /messages, /sms/send, /otp/send, /contacts and /groups, and retry with the same key:

  • same key, same body: the first response is returned again with Idempotent-Replayed: true. Nothing is sent twice.
  • same key, different body or endpoint: 422 idempotency_mismatch.
  • while the first request is still running: 409 idempotency_in_progress (retry in a second).
  • Keys are remembered for 24 hours per API key. 5xx, 409 and 429 answers are not stored, so a retry runs again.

Errors

Every error has the same shape. Branch on code; type is the family. Validation errors add errors (messages per field); some add fields next to error (rejected, flags). Every response has an X-Request-Id header: quote it to support.

{
    "error": {
        "type": "invalid_request",
        "code": "invalid_sender_id",
        "message": "Sender ID \"ACME\" is not approved for this workspace.",
        "request_id": "req_7h2kq9x0m1n3b5v7c9z1"
    }
}
StatustypecodeMeaning
401 authentication_error missing_api_key No `Authorization: Bearer mr_live_…` / `mr_test_…` header.
401 authentication_error invalid_api_key The key does not exist or was revoked.
401 authentication_error api_key_expired The key passed its expiry date (or the grace window after a rotation ended).
401 authentication_error key_creator_removed The team member who created the key left the workspace (or was removed or suspended), so the key was revoked. Create a new key.
402 insufficient_credits insufficient_credits Not enough SMS credits for this request. Top up the wallet.
402 insufficient_credits promo_credits_restricted Only free trial credits are left; they cannot pay for live API traffic.
403 permission_denied missing_scope The key does not carry the scope this endpoint needs.
403 permission_denied ip_not_allowed The request came from an address outside the key's IP allowlist.
403 permission_denied workspace_suspended The workspace is suspended. Contact support.
404 not_found resource_not_found The message, batch, contact, group or webhook does not exist in this workspace.
404 not_found route_not_found There is no endpoint at this path.
405 method_not_allowed method_not_allowed The endpoint exists but not with this HTTP method (see the Allow header).
409 conflict idempotency_in_progress A request with this Idempotency-Key is still being processed. Retry shortly.
409 conflict conflict Something changed while the request was processed (for example the audience of a send). Nothing was done; retry the request.
409 conflict group_in_use The group is the audience of an automation; change or delete the automation first.
422 invalid_request validation_failed A field is missing or invalid. `errors` lists each field.
422 invalid_request invalid_phone The phone number is not a valid Ugandan mobile number.
422 invalid_request invalid_sender_id The sender ID is unknown or not approved for this workspace.
422 invalid_request too_many_recipients More recipients than one request may carry (see the limit in the message).
422 invalid_request no_valid_recipients None of the numbers can receive messages. `rejected` says why for each one.
422 invalid_request content_blocked The message contains content that is not allowed. `flags` lists the reasons.
422 invalid_request invalid_schedule `schedule_at` is not a valid ISO 8601 time in the future (at most 90 days ahead).
422 invalid_request unknown_group A group in `group_ids` does not exist in this workspace.
422 invalid_request unknown_template `template_id` does not exist in this workspace.
422 invalid_request otp_incorrect The verification code is wrong (attempts count towards a lockout).
422 invalid_request otp_expired The code expired. Send a new one.
422 invalid_request otp_already_verified The code was already verified once; codes are single-use.
422 invalid_request otp_max_attempts Too many attempts for this code. Send a new one.
422 invalid_request invalid_idempotency_key The Idempotency-Key header is empty, too long (255+) or has non-printable characters.
422 invalid_request idempotency_mismatch This Idempotency-Key was already used with a different request body or endpoint.
422 invalid_request invalid_webhook_url The webhook URL is not https, or points at a private, loopback or link-local address.
422 invalid_request webhook_limit_reached The workspace already has the maximum number of webhook endpoints.
429 rate_limited rate_limit_exceeded Too many requests for this key (or IP). Wait `Retry-After` seconds.
429 rate_limited otp_cooldown A code was sent to this number moments ago. Wait `Retry-After` seconds.
429 rate_limited otp_hourly_cap Too many codes for this number in the last hour.
429 rate_limited otp_locked Too many wrong codes for this number; it is locked for a while.
429 rate_limited otp_throttled OTP traffic from this workspace looks unusual and is temporarily limited.
500 api_error internal_error Something went wrong on our side. Retry with the same Idempotency-Key.
503 api_error otp_delivery_failed No network accepted the code. Nothing was billed; try again.

Pagination & ids

Lists return {"data": [...], "has_more": true, "next_cursor": "…"}, newest first. Pass cursor=<next_cursor> for the next page and limit (1-100, default 50). Ids carry a prefix: msg_ (one recipient of a send), batch_ (one send request), ct_ contact, grp_ group, wh_ webhook endpoint, whd_ delivery, evt_ event, otp_ code request. Times are ISO 8601 in UTC; phone numbers are E.164 (+256…).

Message statuses: queued → sending → sent (accepted by the network) → delivered or failed; expired (no delivery report within 48 hours), unknown (submitted, outcome not confirmed), skipped (opted out, suppressed or invalid at send time, never billed), cancelled. failure_reason is one of: invalid_number, recipient_blocked, network_rejected, undelivered, expired, provider_rejected, provider_unavailable, network_failure, sender_id_unavailable, opted_out, cancelled, other.

Messages

Send SMS and follow each message.

post /messages sms:send Idempotency-Key
Send SMS

Creates one batch with one message per recipient. Each `to` entry comes back in `accepted` or in `rejected` with a reason (`invalid_number`, `duplicate`, `opted_out`). **201** when queued (or scheduled) with every message id; **202** when the batch is held for a quick review (large sends, links, new accounts; `status` `in_review`), waits for a team approver (the workspace requires send approval and the member who created this live key cannot approve sends; `status` `pending_approval`) or has more than 1000 recipients (it is built in the background; follow it with GET /batches/{batch_id}). Test keys never reach a network and are not billed.

FieldInDescription
Idempotency-KeyheaderMakes retries safe: the same key and body replay the first response.
tobodyOne number, a comma-separated list, or an array (at most 1000). Local (0772…) or international (+256772…) format.
group_idsbodySend to every member of these groups (grp_12 or 12). Combine with `to` freely; duplicates are sent once.
messagebodyThe text. Placeholders like {{first_name}} or {{first_name|friend}} are filled from saved contacts. Required unless `template_id` is given.
template_idbodyUse a saved template's text (tpl_7 or 7) when `message` is absent.
frombodyAn approved sender ID of the workspace (alias: `sender_id`). Absent: the workspace default, else the shared platform sender.
sender_idbodyAlias of `from`.
schedule_atbodyISO 8601 time to send at, up to 90 days ahead. Without an offset it is read in the workspace timezone (Africa/Kampala).
client_refbodyYour own reference, stored on the batch and returned everywhere it appears (filter with GET /batches?client_ref=).
metadatabodyUp to 20 keys of your own data, echoed on the batch.
opt_outbodytrue: always add the opt-out line; false: never; absent: the workspace policy.
REQUEST BODY
{
    "to": [
        "+256772123456",
        "0700123456"
    ],
    "message": "Hello {{first_name|parent}}, school reopens on Monday 13 Oct.",
    "client_ref": "term3-reopening",
    "metadata": {
        "term": "3"
    }
}
RESPONSE 201
{
    "batch_id": "batch_311",
    "status": "queued",
    "kind": "api",
    "mode": "live",
    "livemode": true,
    "recipients": 2,
    "estimated_segments": 1,
    "estimated_credits": 2,
    "client_ref": "term3-reopening",
    "metadata": {
        "term": "3"
    },
    "scheduled_at": null,
    "created_at": "2026-10-06T09:00:00+00:00",
    "accepted": [
        "+256772123456",
        "+256700123456"
    ],
    "rejected": [],
    "message_id": null,
    "messages": [
        {
            "message_id": "msg_90211",
            "to": "+256772123456",
            "status": "queued"
        },
        {
            "message_id": "msg_90212",
            "to": "+256700123456",
            "status": "queued"
        }
    ]
}
Other responses: 202 401 402 403 409 422 429 500
get /messages reports:read
List messages

Newest first, cursor-paginated. Filter by batch, status (comma-separated) or number.

FieldInDescription
batch_idqueryOnly this batch (batch_311).
statusqueryOne or more of: queued, sending, sent, delivered, failed, expired, unknown, skipped, cancelled, opted_out.
toqueryOnly this phone number.
limitqueryItems per page (1-100).
cursorquery`next_cursor` of the previous page.
RESPONSE 200
{
    "data": [
        {
            "message_id": "msg_90211",
            "batch_id": "batch_311",
            "to": "+256772123456",
            "status": "delivered",
            "failure_reason": null,
            "skip_reason": null,
            "network": "mtn",
            "segments": 1,
            "credits": 1,
            "refunded": false,
            "client_ref": "term3-reopening",
            "livemode": true,
            "created_at": "2026-10-06T09:00:00+00:00",
            "sent_at": "2026-10-06T09:00:02+00:00",
            "delivered_at": "2026-10-06T09:00:03+00:00"
        }
    ],
    "has_more": true,
    "next_cursor": "aWQ6OTAyMTE"
}
Other responses: 401 403 429 500
post /sms/send sms:send Idempotency-Key
Send SMS (alias of POST /messages)

Identical to POST /messages.

FieldInDescription
Idempotency-KeyheaderMakes retries safe: the same key and body replay the first response.
tobodyOne number, a comma-separated list, or an array (at most 1000). Local (0772…) or international (+256772…) format.
group_idsbodySend to every member of these groups (grp_12 or 12). Combine with `to` freely; duplicates are sent once.
messagebodyThe text. Placeholders like {{first_name}} or {{first_name|friend}} are filled from saved contacts. Required unless `template_id` is given.
template_idbodyUse a saved template's text (tpl_7 or 7) when `message` is absent.
frombodyAn approved sender ID of the workspace (alias: `sender_id`). Absent: the workspace default, else the shared platform sender.
sender_idbodyAlias of `from`.
schedule_atbodyISO 8601 time to send at, up to 90 days ahead. Without an offset it is read in the workspace timezone (Africa/Kampala).
client_refbodyYour own reference, stored on the batch and returned everywhere it appears (filter with GET /batches?client_ref=).
metadatabodyUp to 20 keys of your own data, echoed on the batch.
opt_outbodytrue: always add the opt-out line; false: never; absent: the workspace policy.
REQUEST BODY
{
    "to": [
        "+256772123456",
        "0700123456"
    ],
    "message": "Hello {{first_name|parent}}, school reopens on Monday 13 Oct.",
    "client_ref": "term3-reopening",
    "metadata": {
        "term": "3"
    }
}
RESPONSE 201
{
    "batch_id": "batch_311",
    "status": "queued",
    "kind": "api",
    "mode": "live",
    "livemode": true,
    "recipients": 2,
    "estimated_segments": 1,
    "estimated_credits": 2,
    "client_ref": "term3-reopening",
    "metadata": {
        "term": "3"
    },
    "scheduled_at": null,
    "created_at": "2026-10-06T09:00:00+00:00",
    "accepted": [
        "+256772123456",
        "+256700123456"
    ],
    "rejected": [],
    "message_id": null,
    "messages": [
        {
            "message_id": "msg_90211",
            "to": "+256772123456",
            "status": "queued"
        },
        {
            "message_id": "msg_90212",
            "to": "+256700123456",
            "status": "queued"
        }
    ]
}
Other responses: 202 401 402 403 409 422 429 500
get /messages/{message_id} reports:read
Get one message

The status of one recipient. `message_id` is the id returned by the send call and carried by every message.* webhook.

FieldInDescription
message_id *path
RESPONSE 200
{
    "message_id": "msg_90211",
    "batch_id": "batch_311",
    "to": "+256772123456",
    "status": "delivered",
    "failure_reason": null,
    "skip_reason": null,
    "network": "mtn",
    "segments": 1,
    "credits": 1,
    "refunded": false,
    "client_ref": "term3-reopening",
    "livemode": true,
    "created_at": "2026-10-06T09:00:00+00:00",
    "sent_at": "2026-10-06T09:00:02+00:00",
    "delivered_at": "2026-10-06T09:00:03+00:00"
}
Other responses: 401 403 404 429 500
get /batches reports:read
List batches
FieldInDescription
client_refqueryOnly batches with this client_ref.
limitqueryItems per page (1-100).
cursorquery`next_cursor` of the previous page.
RESPONSE 200
{
    "data": [
        {
            "batch_id": "batch_311",
            "status": "sent",
            "kind": "api",
            "mode": "live",
            "livemode": true,
            "recipients": 2,
            "estimated_segments": 1,
            "estimated_credits": 2,
            "client_ref": "term3-reopening",
            "metadata": {
                "term": "3"
            },
            "scheduled_at": null,
            "created_at": "2026-10-06T09:00:00+00:00",
            "counts": {
                "queued": 0,
                "sending": 0,
                "sent": 0,
                "delivered": 2,
                "failed": 0,
                "expired": 0,
                "unknown": 0,
                "skipped": 0,
                "cancelled": 0,
                "opted_out": 0
            },
            "credits_used": 2,
            "sent_at": "2026-10-06T09:00:02+00:00",
            "completed_at": "2026-10-06T09:00:05+00:00"
        }
    ],
    "has_more": true,
    "next_cursor": "aWQ6OTAyMTE"
}
Other responses: 401 403 429 500
get /batches/{batch_id} reports:read
Get one batch

Status and per-status counts of one send request.

FieldInDescription
batch_id *path
RESPONSE 200
{
    "batch_id": "batch_311",
    "status": "sent",
    "kind": "api",
    "mode": "live",
    "livemode": true,
    "recipients": 2,
    "estimated_segments": 1,
    "estimated_credits": 2,
    "client_ref": "term3-reopening",
    "metadata": {
        "term": "3"
    },
    "scheduled_at": null,
    "created_at": "2026-10-06T09:00:00+00:00",
    "counts": {
        "queued": 0,
        "sending": 0,
        "sent": 0,
        "delivered": 2,
        "failed": 0,
        "expired": 0,
        "unknown": 0,
        "skipped": 0,
        "cancelled": 0,
        "opted_out": 0
    },
    "credits_used": 2,
    "sent_at": "2026-10-06T09:00:02+00:00",
    "completed_at": "2026-10-06T09:00:05+00:00"
}
Other responses: 401 403 404 429 500

OTP

One-time verification codes.

post /otp/send otp:send Idempotency-Key
Send a one-time code

Sends a 6-digit code as "{code} is your {app_name} verification code. It expires in N minutes. Do not share it." from the workspace's sender ID. A new code replaces any earlier one for the same number and purpose. Live keys are billed per segment (usually 1 credit). Limits: one code per number every 30 s, 5 per number per hour; unusual traffic (volume spikes, one number range, codes never verified) is throttled automatically. Test keys send nothing and the code is always 000000.

FieldInDescription
Idempotency-KeyheaderMakes retries safe: the same key and body replay the first response.
to *bodyThe phone number.
purposebodyYour label (login, signup, payment …). Codes of different purposes are independent. Default `verify`.
app_namebodyName in the text. Default: the workspace name.
expiry_secondsbodyinteger
frombodyAn approved sender ID (alias `sender_id`).
REQUEST BODY
{
    "to": "+256772123456",
    "purpose": "login",
    "app_name": "Acme"
}
RESPONSE 201
{
    "request_id": "otp_8f2kq0x1m3n5b7v9c4z6a2s1",
    "to": "+256772123456",
    "status": "sent",
    "purpose": "login",
    "expires_in": 300,
    "expires_at": "2026-10-06T09:05:00+00:00",
    "segments": 1,
    "credits_charged": 1,
    "mode": "live",
    "livemode": true
}
Other responses: 401 402 403 409 422 429 500 503
post /otp/verify otp:verify
Verify a code

Checks what the user typed against the code of `request_id`. Codes are single-use; 5 wrong attempts lock the number for 30 minutes. The older form `{to, code, purpose}` (newest code for that number) also works.

FieldInDescription
request_idbodystring
code *bodystring
tobodystring
purposebodystring
REQUEST BODY
{
    "request_id": "otp_8f2kq0x1m3n5b7v9c4z6a2s1",
    "code": "482910"
}
RESPONSE 200
{
    "request_id": "otp_8f2kq0x1m3n5b7v9c4z6a2s1",
    "to": "+256772123456",
    "verified": true,
    "status": "approved"
}
Other responses: 404 422 429 401 403 500

Contacts

Contacts and groups of the workspace.

get /contacts contacts:read
List contacts
FieldInDescription
qqueryName, or a number (whole or part) in any local form: 0772…, 772…, 256…, +256….
phonequeryExact number.
group_idqueryMembers of this group (grp_12).
consent_statusquerysubscribed, opted_out or unknown.
updated_sincequeryISO 8601: changed at or after.
limitqueryItems per page (1-100).
cursorquery`next_cursor` of the previous page.
RESPONSE 200
{
    "data": [
        {
            "id": "ct_5512",
            "phone": "+256772123456",
            "first_name": "Grace",
            "last_name": "Achieng",
            "date_of_birth": null,
            "anniversary_date": null,
            "custom_fields": {
                "class": "P5"
            },
            "consent_status": "subscribed",
            "suppressed": false,
            "can_receive": true,
            "groups": [
                "grp_12"
            ],
            "source": "api",
            "created_at": "2026-10-01T08:00:00+00:00",
            "updated_at": "2026-10-05T12:30:00+00:00"
        }
    ],
    "has_more": true,
    "next_cursor": "aWQ6OTAyMTE"
}
Other responses: 401 403 429 500
post /contacts contacts:write Idempotency-Key
Create (or update) a contact

Keyed by phone. A new number is saved as subscribed (consent source `api`, recorded with your key), unless it is on the workspace suppression list (then opted out). If the number exists, only the fields you send change and consent never does. `custom_fields` are merged; a null value removes a field.

FieldInDescription
Idempotency-KeyheaderMakes retries safe: the same key and body replay the first response.
phone *bodystring
first_namebodystring | null
last_namebodystring | null
date_of_birthbodystring | null
anniversary_datebodystring | null
custom_fieldsbodyobject
group_idsbodyAdd to these static groups (never removes).
REQUEST BODY
{
    "phone": "0772123456",
    "first_name": "Grace",
    "custom_fields": {
        "class": "P5"
    },
    "group_ids": [
        "grp_12"
    ]
}
RESPONSE 200
{
    "id": "ct_5512",
    "phone": "+256772123456",
    "first_name": "Grace",
    "last_name": "Achieng",
    "date_of_birth": null,
    "anniversary_date": null,
    "custom_fields": {
        "class": "P5"
    },
    "consent_status": "subscribed",
    "suppressed": false,
    "can_receive": true,
    "groups": [
        "grp_12"
    ],
    "source": "api",
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-05T12:30:00+00:00",
    "created": false
}
Other responses: 201 401 403 409 422 429 500
get /contacts/{contact_id} contacts:read
Get a contact
FieldInDescription
contact_id *path
RESPONSE 200
{
    "id": "ct_5512",
    "phone": "+256772123456",
    "first_name": "Grace",
    "last_name": "Achieng",
    "date_of_birth": null,
    "anniversary_date": null,
    "custom_fields": {
        "class": "P5"
    },
    "consent_status": "subscribed",
    "suppressed": false,
    "can_receive": true,
    "groups": [
        "grp_12"
    ],
    "source": "api",
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-05T12:30:00+00:00"
}
Other responses: 401 403 404 429 500
patch /contacts/{contact_id} contacts:write
Update a contact

Only the fields present change. Phone and consent can't be changed here.

FieldInDescription
contact_id *path
first_namebodystring | null
last_namebodystring | null
date_of_birthbodystring | null
anniversary_datebodystring | null
custom_fieldsbodyobject
group_idsbodyAdd to these static groups (never removes).
REQUEST BODY
{
    "last_name": "Achieng",
    "custom_fields": {
        "class": "P6"
    }
}
RESPONSE 200
{
    "id": "ct_5512",
    "phone": "+256772123456",
    "first_name": "Grace",
    "last_name": "Achieng",
    "date_of_birth": null,
    "anniversary_date": null,
    "custom_fields": {
        "class": "P5"
    },
    "consent_status": "subscribed",
    "suppressed": false,
    "can_receive": true,
    "groups": [
        "grp_12"
    ],
    "source": "api",
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-05T12:30:00+00:00"
}
Other responses: 401 403 404 429 500
delete /contacts/{contact_id} contacts:write
Delete a contact

A suppression of the number (opt-out) stays in force.

FieldInDescription
contact_id *path
RESPONSE 200
{
    "id": "ct_5512",
    "deleted": true
}
Other responses: 401 403 404 429 500
get /groups contacts:read
List groups
RESPONSE 200
{
    "data": [
        {
            "id": "grp_12",
            "name": "Parents P5",
            "type": "static",
            "contacts": 248,
            "created_at": "2026-10-01T08:00:00+00:00",
            "updated_at": "2026-10-05T12:30:00+00:00"
        }
    ]
}
Other responses: 401 403 429 500
post /groups contacts:write Idempotency-Key
Create a group

A group with the same name (any case) is returned instead of a duplicate (200, created false).

FieldInDescription
Idempotency-KeyheaderMakes retries safe: the same key and body replay the first response.
name *bodystring
REQUEST BODY
{
    "name": "Parents P5"
}
RESPONSE 201
{
    "id": "grp_12",
    "name": "Parents P5",
    "type": "static",
    "contacts": 248,
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-05T12:30:00+00:00",
    "created": true
}
Other responses: 401 403 409 429 500
get /groups/{group_id} contacts:read
Get a group
FieldInDescription
group_id *path
RESPONSE 200
{
    "id": "grp_12",
    "name": "Parents P5",
    "type": "static",
    "contacts": 248,
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-05T12:30:00+00:00"
}
Other responses: 401 403 404 429 500
patch /groups/{group_id} contacts:write
Rename a group

Group names are unique in a workspace (any case): a name another group already has is refused with 422.

FieldInDescription
group_id *path
name *bodystring
RESPONSE 200
{
    "id": "grp_12",
    "name": "Parents P5",
    "type": "static",
    "contacts": 248,
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-05T12:30:00+00:00"
}
Other responses: 422 401 403 404 429 500
delete /groups/{group_id} contacts:write
Delete a group

Its contacts are kept.

FieldInDescription
group_id *path
RESPONSE 200
{
    "id": "grp_12",
    "deleted": true
}
Other responses: 401 403 404 429 500
get /groups/{group_id}/contacts contacts:read
List a group's contacts
FieldInDescription
group_id *path
limitqueryItems per page (1-100).
cursorquery`next_cursor` of the previous page.
RESPONSE 200
{
    "data": [
        {
            "id": "ct_5512",
            "phone": "+256772123456",
            "first_name": "Grace",
            "last_name": "Achieng",
            "date_of_birth": null,
            "anniversary_date": null,
            "custom_fields": {
                "class": "P5"
            },
            "consent_status": "subscribed",
            "suppressed": false,
            "can_receive": true,
            "groups": [
                "grp_12"
            ],
            "source": "api",
            "created_at": "2026-10-01T08:00:00+00:00",
            "updated_at": "2026-10-05T12:30:00+00:00"
        }
    ],
    "has_more": true,
    "next_cursor": "aWQ6OTAyMTE"
}
Other responses: 401 403 404 429 500
post /groups/{group_id}/contacts contacts:write
Add contacts to a group

Existing contacts only, by id or phone (up to 1,000 per call). Segments (type `segment`) compute their members and can't be changed.

FieldInDescription
group_id *path
contact_idsbodyarray
phonesbodyarray
REQUEST BODY
{
    "contact_ids": [
        "ct_5512"
    ],
    "phones": [
        "0700123456"
    ]
}
RESPONSE 200
{
    "group": {
        "id": "grp_12",
        "name": "Parents P5",
        "type": "static",
        "contacts": 248,
        "created_at": "2026-10-01T08:00:00+00:00",
        "updated_at": "2026-10-05T12:30:00+00:00"
    },
    "added": 2,
    "not_found": 0
}
Other responses: 401 403 404 429 500
delete /groups/{group_id}/contacts contacts:write
Remove contacts from a group

The contacts themselves are kept.

FieldInDescription
group_id *path
contact_idsbodyarray
phonesbodyarray
REQUEST BODY
{
    "contact_ids": [
        "ct_5512"
    ],
    "phones": [
        "0700123456"
    ]
}
RESPONSE 200
{
    "group": {
        "id": "grp_12",
        "name": "Parents P5",
        "type": "static",
        "contacts": 248,
        "created_at": "2026-10-01T08:00:00+00:00",
        "updated_at": "2026-10-05T12:30:00+00:00"
    },
    "removed": 2,
    "not_found": 0
}
Other responses: 401 403 404 429 500

Account

Balance and sender IDs.

get /balance balance:read
Credit balance

1 credit = 1 SMS segment. `available_for_api` excludes free trial credits, which can't pay for live API traffic.

RESPONSE 200
{
    "currency": "UGX",
    "credits": 12500,
    "reserved": 300,
    "available": 12200,
    "available_for_api": 12180,
    "promo_credits": 20
}
Other responses: 401 403 429 500
get /sender-ids sender_ids:read
Approved sender IDs

Scope `sender_ids:read` (keys with `balance:read` or `sms:send` may also call it).

RESPONSE 200
{
    "data": [
        {
            "sender_id": "KPSCHOOL",
            "status": "approved",
            "default": true
        }
    ]
}
Other responses: 401 403 429 500

Webhooks

Endpoints that receive signed events.

get /webhooks webhooks:write
List webhook endpoints
RESPONSE 200
{
    "data": [
        {
            "id": "wh_3",
            "url": "https://api.example.com/hooks/mobireach",
            "description": "Production",
            "events": [
                "message.delivered",
                "message.failed"
            ],
            "status": "active",
            "disabled_reason": null,
            "secret_rotated_at": null,
            "created_at": "2026-10-01T08:00:00+00:00",
            "updated_at": "2026-10-01T08:00:00+00:00",
            "health": "healthy"
        }
    ],
    "events": [
        "message.sent",
        "message.delivered",
        "message.failed",
        "message.inbound",
        "contact.opted_out",
        "contact.opted_in",
        "payment.confirmed",
        "payment.failed",
        "payment.refunded",
        "campaign.completed",
        "wallet.low_balance"
    ]
}
Other responses: 401 403 429 500
post /webhooks webhooks:write
Create a webhook endpoint

https only; the host must resolve to public addresses (private, loopback and link-local ranges are refused, at save and before every delivery). The signing `secret` is returned only here and on rotation. At most 5 endpoints per workspace.

FieldInDescription
url *bodystring
descriptionbodystring | null
eventsbodyDefault: message.delivered, message.failed, payment.confirmed.
REQUEST BODY
{
    "url": "https://api.example.com/hooks/mobireach",
    "events": [
        "message.delivered",
        "message.failed"
    ]
}
RESPONSE 201
{
    "id": "wh_3",
    "url": "https://api.example.com/hooks/mobireach",
    "description": "Production",
    "events": [
        "message.delivered",
        "message.failed"
    ],
    "status": "active",
    "disabled_reason": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-01T08:00:00+00:00",
    "secret": "whsec_3f1c…",
    "created": true
}
Other responses: 422 401 403 429 500
get /webhooks/{webhook_id} webhooks:write
Get an endpoint
FieldInDescription
webhook_id *path
RESPONSE 200
{
    "id": "wh_3",
    "url": "https://api.example.com/hooks/mobireach",
    "description": "Production",
    "events": [
        "message.delivered",
        "message.failed"
    ],
    "status": "active",
    "disabled_reason": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-01T08:00:00+00:00",
    "health": "healthy"
}
Other responses: 401 403 404 429 500
patch /webhooks/{webhook_id} webhooks:write
Update an endpoint

Saving an endpoint also turns it back on after an automatic disable.

FieldInDescription
webhook_id *path
urlbodystring
descriptionbodystring | null
eventsbodyDefault: message.delivered, message.failed, payment.confirmed.
REQUEST BODY
{
    "url": "https://api.example.com/hooks/mobireach",
    "events": [
        "message.delivered",
        "message.failed"
    ]
}
RESPONSE 200
{
    "id": "wh_3",
    "url": "https://api.example.com/hooks/mobireach",
    "description": "Production",
    "events": [
        "message.delivered",
        "message.failed"
    ],
    "status": "active",
    "disabled_reason": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-01T08:00:00+00:00"
}
Other responses: 401 403 404 429 500
delete /webhooks/{webhook_id} webhooks:write
Delete an endpoint
FieldInDescription
webhook_id *path
RESPONSE 200
{
    "id": "wh_3",
    "deleted": true
}
Other responses: 401 403 404 429 500
post /webhooks/{webhook_id}/rotate-secret webhooks:write
Rotate the signing secret

For `grace_hours` (0-168, default 24) deliveries carry two `v1` signatures, one per secret, so you can switch without dropping events.

FieldInDescription
webhook_id *path
grace_hoursbodyinteger
RESPONSE 200
{
    "id": "wh_3",
    "url": "https://api.example.com/hooks/mobireach",
    "description": "Production",
    "events": [
        "message.delivered",
        "message.failed"
    ],
    "status": "active",
    "disabled_reason": null,
    "secret_rotated_at": null,
    "created_at": "2026-10-01T08:00:00+00:00",
    "updated_at": "2026-10-01T08:00:00+00:00",
    "secret": "whsec_9a7b…",
    "previous_secret_valid_until": "2026-10-07T09:00:00+00:00"
}
Other responses: 401 403 404 429 500
post /webhooks/{webhook_id}/test webhooks:write
Send a test event

Sends one `webhook.test` event right away (no retries) and returns the delivery with your endpoint's response code.

FieldInDescription
webhook_id *path
RESPONSE 200
{
    "id": "whd_901",
    "event_id": "evt_k2m9x8c7v6b5n4q3w2e1r0ty",
    "event": "webhook.test",
    "status": "delivered",
    "attempts": 1,
    "response_status": 200,
    "error": null,
    "duration_ms": 182,
    "next_attempt_at": null,
    "delivered_at": "2026-10-06T09:00:04+00:00",
    "created_at": "2026-10-06T09:00:03+00:00"
}
Other responses: 401 403 404 429 500
get /webhooks/{webhook_id}/deliveries webhooks:write
Recent deliveries
FieldInDescription
webhook_id *path
limitqueryItems per page (1-100).
RESPONSE 200
{
    "data": [
        {
            "id": "whd_901",
            "event_id": "evt_k2m9x8c7v6b5n4q3w2e1r0ty",
            "event": "message.delivered",
            "status": "delivered",
            "attempts": 1,
            "response_status": 200,
            "error": null,
            "duration_ms": 182,
            "next_attempt_at": null,
            "delivered_at": "2026-10-06T09:00:04+00:00",
            "created_at": "2026-10-06T09:00:03+00:00"
        }
    ]
}
Other responses: 401 403 404 429 500

Webhooks & signatures

Add endpoints under Developers → Webhooks (or with the Webhooks endpoints above): https only, on a public address. Each event is a POST with a JSON body and these headers:

Content-Type: application/json
X-MobiReach-Signature: t=1759741203,v1=5f2b9c…   (hex HMAC-SHA256)
X-MobiReach-Event: message.delivered
X-MobiReach-Event-Id: evt_k2m9x8c7v6b5n4q3w2e1r0ty
X-MobiReach-Delivery: whd_901

{
    "id": "evt_k2m9x8c7v6b5n4q3w2e1r0ty",
    "event": "message.delivered",
    "created_at": "2026-10-06T09:00:03+00:00",
    "data": {
        "message_id": "msg_90211",
        "batch_id": "batch_311",
        "campaign_id": 311,
        "to": "+256772123456",
        "status": "delivered",
        "failure_reason": null,
        "network": "mtn",
        "segments": 1,
        "delivered_at": "2026-10-06T09:00:03+00:00",
        "refunded": false,
        "livemode": true
    }
}

Verify every request. Compute HMAC-SHA256(secret, "<t>.<raw body>") as hex with the endpoint's signing secret (whsec_…) and compare it, in constant time, with each v1 value. Reject the request when t is more than 5 minutes from your clock (replay protection). After a secret rotation both the new and the old secret sign for the grace period, so the header has two v1 values.

Answer 2xx within 10 seconds. Anything else (including redirects, which are never followed) is retried up to 10 times, waiting 1m, 2m, 5m, 10m, 30m, 1h, 2h, 4h, 8h between attempts. The same id (evt_…) is used on every retry: de-duplicate on it. After 5 deliveries in a row fail completely the endpoint is turned off and your team is notified; saving it again turns it back on. Each endpoint's deliveries, response codes and a Redeliver button are on the Developers screen.

code samples
// Express: keep the RAW body, the signature covers the exact bytes
import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.post('/hooks/mobireach', express.raw({ type: 'application/json' }), (req, res) => {
  let t = null; const sigs = [];
  for (const part of (req.get('X-MobiReach-Signature') || '').split(',')) {
    const [k, v] = part.trim().split('=');
    if (k === 't') t = Number(v);
    if (k === 'v1') sigs.push(v);
  }
  const raw = req.body.toString('utf8');
  const expected = crypto.createHmac('sha256', process.env.MOBIREACH_WEBHOOK_SECRET).update(`${t}.${raw}`).digest('hex');
  const fresh = t && Math.abs(Date.now() / 1000 - t) <= 300;
  const ok = fresh && sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(raw); // de-duplicate on event.id, then handle event.event / event.data
  res.sendStatus(200);
});
app.listen(3000);
EventWhendata
message.sent A message was accepted for delivery
Example
{
    "message_id": "msg_90211",
    "batch_id": "batch_311",
    "campaign_id": 311,
    "to": "+256772123456",
    "status": "sent",
    "network": "mtn",
    "segments": 1,
    "sent_at": "2026-10-06T09:00:02+00:00",
    "livemode": true
}
message.delivered The handset confirmed delivery
Example
{
    "message_id": "msg_90211",
    "batch_id": "batch_311",
    "campaign_id": 311,
    "to": "+256772123456",
    "status": "delivered",
    "failure_reason": null,
    "network": "mtn",
    "segments": 1,
    "delivered_at": "2026-10-06T09:00:03+00:00",
    "refunded": false,
    "livemode": true
}
message.failed A message could not be delivered
Example
{
    "message_id": "msg_90211",
    "batch_id": "batch_311",
    "campaign_id": 311,
    "to": "+256772123456",
    "status": "failed",
    "failure_reason": "invalid_number",
    "network": "airtel",
    "segments": 1,
    "delivered_at": null,
    "refunded": false,
    "livemode": true
}
message.inbound A reply came in
Example
{
    "id": "in_771",
    "from": "+256772123456",
    "to": "6000",
    "text": "YES",
    "keyword": "kps",
    "action": "reply",
    "in_reply_to": "msg_90211",
    "batch_id": "batch_311",
    "received_at": "2026-10-06T10:12:00+00:00"
}
contact.opted_out A number opted out (STOP)
Example
{
    "phone": "+256772123456",
    "source": "sms_stop",
    "contact_id": 5512,
    "opted_out_at": "2026-10-06T10:12:00+00:00"
}
contact.opted_in A number opted back in (START)
Example
{
    "phone": "+256772123456",
    "source": "sms_start",
    "contact_id": 5512,
    "opted_in_at": "2026-10-07T08:30:00+00:00"
}
payment.confirmed A top-up was paid and credited
Example
{
    "reference": "MR-8KQ2F",
    "amount_ugx": 350000,
    "credits": 11666,
    "rate_ugx": 30,
    "method": "mtn_momo",
    "provider": "mtn_momo",
    "status": "paid",
    "livemode": true
}
payment.failed A top-up failed
Example
{
    "reference": "MR-9LA1C",
    "amount_ugx": 50000,
    "credits": 1428,
    "rate_ugx": 35,
    "method": "airtel_money",
    "provider": "airtel_money",
    "status": "failed",
    "livemode": true,
    "reason": "declined"
}
payment.refunded A top-up was refunded
Example
{
    "reference": "MR-8KQ2F",
    "amount_ugx": 350000,
    "credits": 11666,
    "rate_ugx": 30,
    "method": "mtn_momo",
    "provider": "mtn_momo",
    "status": "refunded",
    "livemode": true,
    "refunded_amount_ugx": 350000,
    "refunded_credits": 11666
}
campaign.completed Every message of a campaign has a final result
Example
{
    "batch_id": "batch_311",
    "campaign_id": 311,
    "name": "API term3-reopening",
    "kind": "api",
    "status": "sent",
    "recipients": 2,
    "delivered": 2,
    "failed": 0,
    "skipped": 0,
    "cost_credits": 2,
    "completed_at": "2026-10-06T09:00:05+00:00",
    "livemode": true
}
wallet.low_balance Credits fell below the alert level
Example
{
    "available_credits": 300,
    "balance_credits": 420,
    "reserved_credits": 120,
    "promo_credits": 0,
    "threshold_credits": 500,
    "scheduled_next_7_days": {
        "campaigns": 1,
        "credits": 120
    },
    "next_scheduled_campaign": {
        "batch_id": "batch_318",
        "name": "Term 3 fees reminder",
        "scheduled_at": "2026-10-09T06:00:00+00:00",
        "credits": 120
    },
    "alerted_at": "2026-10-06T07:00:00+00:00",
    "livemode": true
}

OTP code samples

Send a code, keep the request_id with the login attempt, then verify what the user typed. Codes are 6 digits, single-use, valid for 5 minutes by default (300-600 seconds via expiry_seconds), and a new code replaces the previous one.

code samples
# 1. Send the code
curl -X POST https://mobireach.theanathoth.com/api/v1/otp/send \
  -H "Authorization: Bearer $MOBIREACH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "+256772123456", "purpose": "login", "app_name": "Acme"}'
# => {"request_id": "otp_8f2k…", "status": "sent", "expires_in": 300, …}

# 2. Verify what the user typed
curl -X POST https://mobireach.theanathoth.com/api/v1/otp/verify \
  -H "Authorization: Bearer $MOBIREACH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"request_id": "otp_8f2k…", "code": "482910"}'
# => 200 {"verified": true, "status": "approved", …}   wrong code => 422 otp_incorrect