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:
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.
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.
| Scope | Allows |
|---|---|
| sms:send | Send messages (POST /v1/messages, /v1/sms/send) |
| reports:read | Read messages and batches (GET /v1/messages, /v1/batches) |
| otp:send | Send one-time codes (POST /v1/otp/send) |
| otp:verify | Verify one-time codes (POST /v1/otp/verify) |
| contacts:read | Read contacts and groups |
| contacts:write | Create, change and delete contacts and groups |
| balance:read | Read the credit balance (and sender IDs) |
| sender_ids:read | List approved sender IDs |
| webhooks:write | Manage webhook endpoints (GET/POST/PATCH/DELETE /v1/webhooks) |
A new key without ticked scopes gets sms:send, reports:read and balance:read.
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.
| Encoding | 1 segment | Each further segment |
|---|---|---|
| GSM-7 | 160 characters | 153 characters |
| Unicode | 70 characters | 67 characters |
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.
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:
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"
}
}
| Status | type | code | Meaning |
|---|---|---|---|
| 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. |
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.
Send SMS and follow each message.
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.
| Field | In | Description |
|---|---|---|
| Idempotency-Key | header | Makes retries safe: the same key and body replay the first response. |
| to | body | One number, a comma-separated list, or an array (at most 1000). Local (0772…) or international (+256772…) format. |
| group_ids | body | Send to every member of these groups (grp_12 or 12). Combine with `to` freely; duplicates are sent once. |
| message | body | The text. Placeholders like {{first_name}} or {{first_name|friend}} are filled from saved contacts. Required unless `template_id` is given. |
| template_id | body | Use a saved template's text (tpl_7 or 7) when `message` is absent. |
| from | body | An approved sender ID of the workspace (alias: `sender_id`). Absent: the workspace default, else the shared platform sender. |
| sender_id | body | Alias of `from`. |
| schedule_at | body | ISO 8601 time to send at, up to 90 days ahead. Without an offset it is read in the workspace timezone (Africa/Kampala). |
| client_ref | body | Your own reference, stored on the batch and returned everywhere it appears (filter with GET /batches?client_ref=). |
| metadata | body | Up to 20 keys of your own data, echoed on the batch. |
| opt_out | body | true: always add the opt-out line; false: never; absent: the workspace policy. |
{
"to": [
"+256772123456",
"0700123456"
],
"message": "Hello {{first_name|parent}}, school reopens on Monday 13 Oct.",
"client_ref": "term3-reopening",
"metadata": {
"term": "3"
}
}{
"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"
}
]
}Newest first, cursor-paginated. Filter by batch, status (comma-separated) or number.
| Field | In | Description |
|---|---|---|
| batch_id | query | Only this batch (batch_311). |
| status | query | One or more of: queued, sending, sent, delivered, failed, expired, unknown, skipped, cancelled, opted_out. |
| to | query | Only this phone number. |
| limit | query | Items per page (1-100). |
| cursor | query | `next_cursor` of the previous page. |
{
"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"
}Identical to POST /messages.
| Field | In | Description |
|---|---|---|
| Idempotency-Key | header | Makes retries safe: the same key and body replay the first response. |
| to | body | One number, a comma-separated list, or an array (at most 1000). Local (0772…) or international (+256772…) format. |
| group_ids | body | Send to every member of these groups (grp_12 or 12). Combine with `to` freely; duplicates are sent once. |
| message | body | The text. Placeholders like {{first_name}} or {{first_name|friend}} are filled from saved contacts. Required unless `template_id` is given. |
| template_id | body | Use a saved template's text (tpl_7 or 7) when `message` is absent. |
| from | body | An approved sender ID of the workspace (alias: `sender_id`). Absent: the workspace default, else the shared platform sender. |
| sender_id | body | Alias of `from`. |
| schedule_at | body | ISO 8601 time to send at, up to 90 days ahead. Without an offset it is read in the workspace timezone (Africa/Kampala). |
| client_ref | body | Your own reference, stored on the batch and returned everywhere it appears (filter with GET /batches?client_ref=). |
| metadata | body | Up to 20 keys of your own data, echoed on the batch. |
| opt_out | body | true: always add the opt-out line; false: never; absent: the workspace policy. |
{
"to": [
"+256772123456",
"0700123456"
],
"message": "Hello {{first_name|parent}}, school reopens on Monday 13 Oct.",
"client_ref": "term3-reopening",
"metadata": {
"term": "3"
}
}{
"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"
}
]
}The status of one recipient. `message_id` is the id returned by the send call and carried by every message.* webhook.
| Field | In | Description |
|---|---|---|
| message_id * | path |
{
"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"
}| Field | In | Description |
|---|---|---|
| client_ref | query | Only batches with this client_ref. |
| limit | query | Items per page (1-100). |
| cursor | query | `next_cursor` of the previous page. |
{
"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"
}Status and per-status counts of one send request.
| Field | In | Description |
|---|---|---|
| batch_id * | path |
{
"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"
}One-time verification codes.
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.
| Field | In | Description |
|---|---|---|
| Idempotency-Key | header | Makes retries safe: the same key and body replay the first response. |
| to * | body | The phone number. |
| purpose | body | Your label (login, signup, payment …). Codes of different purposes are independent. Default `verify`. |
| app_name | body | Name in the text. Default: the workspace name. |
| expiry_seconds | body | integer |
| from | body | An approved sender ID (alias `sender_id`). |
{
"to": "+256772123456",
"purpose": "login",
"app_name": "Acme"
}{
"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
}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.
| Field | In | Description |
|---|---|---|
| request_id | body | string |
| code * | body | string |
| to | body | string |
| purpose | body | string |
{
"request_id": "otp_8f2kq0x1m3n5b7v9c4z6a2s1",
"code": "482910"
}{
"request_id": "otp_8f2kq0x1m3n5b7v9c4z6a2s1",
"to": "+256772123456",
"verified": true,
"status": "approved"
}Contacts and groups of the workspace.
| Field | In | Description |
|---|---|---|
| q | query | Name, or a number (whole or part) in any local form: 0772…, 772…, 256…, +256…. |
| phone | query | Exact number. |
| group_id | query | Members of this group (grp_12). |
| consent_status | query | subscribed, opted_out or unknown. |
| updated_since | query | ISO 8601: changed at or after. |
| limit | query | Items per page (1-100). |
| cursor | query | `next_cursor` of the previous page. |
{
"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"
}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.
| Field | In | Description |
|---|---|---|
| Idempotency-Key | header | Makes retries safe: the same key and body replay the first response. |
| phone * | body | string |
| first_name | body | string | null |
| last_name | body | string | null |
| date_of_birth | body | string | null |
| anniversary_date | body | string | null |
| custom_fields | body | object |
| group_ids | body | Add to these static groups (never removes). |
{
"phone": "0772123456",
"first_name": "Grace",
"custom_fields": {
"class": "P5"
},
"group_ids": [
"grp_12"
]
}{
"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
}| Field | In | Description |
|---|---|---|
| contact_id * | path |
{
"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"
}Only the fields present change. Phone and consent can't be changed here.
| Field | In | Description |
|---|---|---|
| contact_id * | path | |
| first_name | body | string | null |
| last_name | body | string | null |
| date_of_birth | body | string | null |
| anniversary_date | body | string | null |
| custom_fields | body | object |
| group_ids | body | Add to these static groups (never removes). |
{
"last_name": "Achieng",
"custom_fields": {
"class": "P6"
}
}{
"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"
}A suppression of the number (opt-out) stays in force.
| Field | In | Description |
|---|---|---|
| contact_id * | path |
{
"id": "ct_5512",
"deleted": true
}{
"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"
}
]
}A group with the same name (any case) is returned instead of a duplicate (200, created false).
| Field | In | Description |
|---|---|---|
| Idempotency-Key | header | Makes retries safe: the same key and body replay the first response. |
| name * | body | string |
{
"name": "Parents P5"
}{
"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
}| Field | In | Description |
|---|---|---|
| group_id * | path |
{
"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"
}Group names are unique in a workspace (any case): a name another group already has is refused with 422.
| Field | In | Description |
|---|---|---|
| group_id * | path | |
| name * | body | string |
{
"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"
}Its contacts are kept.
| Field | In | Description |
|---|---|---|
| group_id * | path |
{
"id": "grp_12",
"deleted": true
}| Field | In | Description |
|---|---|---|
| group_id * | path | |
| limit | query | Items per page (1-100). |
| cursor | query | `next_cursor` of the previous page. |
{
"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"
}Existing contacts only, by id or phone (up to 1,000 per call). Segments (type `segment`) compute their members and can't be changed.
| Field | In | Description |
|---|---|---|
| group_id * | path | |
| contact_ids | body | array |
| phones | body | array |
{
"contact_ids": [
"ct_5512"
],
"phones": [
"0700123456"
]
}{
"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
}The contacts themselves are kept.
| Field | In | Description |
|---|---|---|
| group_id * | path | |
| contact_ids | body | array |
| phones | body | array |
{
"contact_ids": [
"ct_5512"
],
"phones": [
"0700123456"
]
}{
"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
}Balance and sender IDs.
1 credit = 1 SMS segment. `available_for_api` excludes free trial credits, which can't pay for live API traffic.
{
"currency": "UGX",
"credits": 12500,
"reserved": 300,
"available": 12200,
"available_for_api": 12180,
"promo_credits": 20
}Scope `sender_ids:read` (keys with `balance:read` or `sms:send` may also call it).
{
"data": [
{
"sender_id": "KPSCHOOL",
"status": "approved",
"default": true
}
]
}Endpoints that receive signed events.
{
"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"
]
}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.
| Field | In | Description |
|---|---|---|
| url * | body | string |
| description | body | string | null |
| events | body | Default: message.delivered, message.failed, payment.confirmed. |
{
"url": "https://api.example.com/hooks/mobireach",
"events": [
"message.delivered",
"message.failed"
]
}{
"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
}| Field | In | Description |
|---|---|---|
| webhook_id * | path |
{
"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"
}Saving an endpoint also turns it back on after an automatic disable.
| Field | In | Description |
|---|---|---|
| webhook_id * | path | |
| url | body | string |
| description | body | string | null |
| events | body | Default: message.delivered, message.failed, payment.confirmed. |
{
"url": "https://api.example.com/hooks/mobireach",
"events": [
"message.delivered",
"message.failed"
]
}{
"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"
}| Field | In | Description |
|---|---|---|
| webhook_id * | path |
{
"id": "wh_3",
"deleted": true
}For `grace_hours` (0-168, default 24) deliveries carry two `v1` signatures, one per secret, so you can switch without dropping events.
| Field | In | Description |
|---|---|---|
| webhook_id * | path | |
| grace_hours | body | integer |
{
"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"
}Sends one `webhook.test` event right away (no retries) and returns the delivery with your endpoint's response code.
| Field | In | Description |
|---|---|---|
| webhook_id * | path |
{
"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"
}| Field | In | Description |
|---|---|---|
| webhook_id * | path | |
| limit | query | Items per page (1-100). |
{
"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"
}
]
}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.
| Event | When | data |
|---|---|---|
| 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
} |
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.