# BulkSMS API — complete reference > BulkSMS (by OASIS TECH, Dar es Salaam) sends SMS to Tanzanian and international mobile numbers under your own > approved sender ID. Pay-as-you-go: 1 credit per SMS part per recipient, no subscriptions. This file is the complete, > authoritative description of the public HTTP API (version 1). It is written for developers and for AI coding > assistants: every rule below is what the production API does today. Last updated 2026-10-08. Human-readable version: https://bulksms.oasistech.co.tz/docs · OpenAPI 3: https://bulksms.oasistech.co.tz/openapi.json · Postman: https://bulksms.oasistech.co.tz/postman.json · Short index: https://bulksms.oasistech.co.tz/llms.txt --- ## 1. Essentials | | | |---|---| | Base URL | `https://bulksms.oasistech.co.tz` | | Version prefix | `/v1` (recommended). `/api/...` is the same endpoint and stays supported: `/v1/sms` = `/api/sms`. | | Send an SMS | `POST /v1/sms` (there is no `/v1/sms/send` or `/api/sms/send`) | | Format | JSON over HTTPS, UTF-8. Send `Content-Type: application/json` on every POST. | | Auth | `Authorization: Bearer ` | | Rate limit | 300 send requests per minute per account (one request can hold many recipients) | | Client timeout | Allow at least **180 seconds** for a send (the gateway can take up to 170 s for a big list) | | Large sends | Over **10,000 recipients** in one request: answered at once (HTTP 202, `background: true`) and sent right after (§3.4) | | Cost | 1 credit per SMS part per recipient (see §6) | | Responses to `/v1` URLs | carry `API-Version: 1.0.0` and `Deprecation: false` headers | Before your first send, the account needs all of: 1. **KYC approved** — required for every send, including free trial credits. 2. **An approved sender ID** of your own. The examples in this document use our default sender `BULK SMS TZ` — put your own approved sender ID in its place. Accounts with an approved sender ID must send under it. Send it exactly as registered (same letters and case): our check ignores case, but the networks show what you send. 3. **Credits** — check with `GET /v1/balance`; top up in the web portal (Billing). 4. **An API key** with the `messages:send` scope (web portal → API keys). Minimal working request: ```bash curl -X POST https://bulksms.oasistech.co.tz/v1/sms \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: otp-48213-1" \ -d '{"to": "+255712345678", "sender": "BULK SMS TZ", "message": "Your BulkSMS code is 482913."}' ``` --- ## 2. Authentication and API keys - Create keys in the web portal: **API keys → New key**. The key is shown **once** — store it as a secret. - Send it on every request: `Authorization: Bearer `. - Keys expire after 365 days by default (you choose when creating). An expired key answers 401. - Optional **IP allowlist** per key: list the exact public IP addresses that may use it (comma-separated). Requests from other addresses answer 403 `Access denied from this IP address`. - A key acts as the user who created it, inside that user's account. Suspended or closed accounts answer 403. ### Scopes | Scope | Allows | |---|---| | `messages:send` | `POST /v1/sms` — send, schedule and quote messages. **Enough for an OTP integration.** | | `messages:read` | `GET /v1/logs` — message history (needed to reconcile sends, see §5). | | `balance:read` | `GET /v1/balance` | New keys get all three by default; a key limited to `messages:send` can send but not read history or balance. `GET /v1/sender-ids` and the webhook endpoints need no particular scope; managing webhooks needs the key's user to be an account owner, admin or developer (deleting: owner or admin). Authentication errors: | HTTP | `error` | |---|---| | 401 | `Missing or invalid Authorization header. Use: Bearer ` | | 401 | `Invalid or revoked token` | | 401 | `Token has expired. Please login again to get a new token.` | | 403 | `Token is missing required scope: messages:read` (or the scope the endpoint needs) | | 403 | `Access denied from this IP address` | | 403 | `Tenant is suspended.` / `Tenant is closed.` | --- ## 3. Send SMS — `POST /v1/sms` Scope: `messages:send`. One request sends one message to one or many recipients. ### 3.1 Request body | Field | Type | Required | Rules | |---|---|---|---| | `to` | string, array of strings, or array of `{number, params}` | yes | One or many phone numbers (see 3.2). | | `sender` | string | yes | Your approved sender ID, exactly as registered. 3–20 characters: letters, digits, space, `& . _ - + /`. | | `message` | string | yes | The text. Leading/trailing whitespace is removed. May contain `{placeholders}` (see 3.3). | | `params` | object | no | Values for `{placeholders}`, applied to every recipient. | | `scheduled_at` | string | no | ISO-8601 time (e.g. `2026-10-20T08:00:00+03:00`) between 30 seconds and 90 days from now. See 3.6. | | `dry_run` | boolean | no | `true` = quote only: run every check and return the cost, send nothing (see 3.7). | | `idempotency_key` | string | no | Same as the `Idempotency-Key` header (the header wins if both are set). See §4. | Headers: `Authorization`, `Content-Type: application/json`, optional `Idempotency-Key`. ### 3.2 Phone numbers - Tanzanian numbers may be written in any common form: `0712345678`, `712345678`, `255712345678`, `+255712345678`, `+255 712 345 678`. All become `+255712345678`. - Other countries: full international format with `+` and country code. - Several numbers: a JSON array (`["0712345678", "0754901234"]`) or one string separated by commas, semicolons or new lines (`"0712345678,0754901234"`). Spaces do NOT separate numbers. - If any number is invalid, nothing is sent: 400 with `invalid_numbers` (every bad entry) and a `hint`. - Numbers on the regulator's Do-Not-Call list are removed silently before sending; if every recipient is on it the answer is 422 `All recipients are on the DNC list.` with `blocked`. ### 3.3 Personalisation Put `{key}` in `message`; values come from `params` (all recipients) and from each recipient's own `params` (which win). Unknown placeholders are left as written — except `{name}` / `{jina}` (aliases of each other), which are removed with their leading space when no value is given, so `"Habari {name}, ..."` becomes `"Habari, ..."`. ```json { "to": [ {"number": "0712345678", "params": {"name": "Amina", "amount": "25,000"}}, {"number": "0754901234", "params": {"name": "Juma", "amount": "18,500"}} ], "sender": "BULK SMS TZ", "message": "Habari {name}, your balance is TZS {amount}." } ``` Each recipient is charged for the parts of their own resolved text. ### 3.4 Responses The HTTP status tells you the outcome; the body carries per-recipient detail (except a large send, see below). | HTTP | `status` | Meaning | |---|---|---| | 200 | `sent` | Every recipient was accepted by the mobile network gateway. | | 202 | `pending` (one recipient) / `processing` (several) | The gateway did not confirm in time. The message has **most likely gone out** and is charged. **Do not resend.** | | 202 | `scheduled` | Queued for `scheduled_at` (see 3.6). | | 202 | `processing` + `background: true` | More than 10,000 recipients: accepted and going out right after the reply. Results in `GET /v1/logs` and webhooks; refused numbers refunded. **Do not resend.** | | 502 | `failed` (one recipient) / `partial` (several) | At least one recipient was refused by the gateway. Failed recipients are **not charged**. | `sent` means accepted by the gateway for delivery to the handset. Handset delivery receipts are not part of the API. **One recipient** — the body is that recipient's result plus `credit_info` (HTTP 200): ```json { "number": "+255712345678", "status": "sent", "sms_log_id": 123456, "credits_used": 1, "sms_response": {"msg_id": "1880000001", "number": "255712345678", "str_response": "Send Successful"}, "provider_ref": "1880000001", "credit_info": {"total_recipients": 1, "credits_charged": 1, "balance_after": 6, "sandbox": false} } ``` | Field | Meaning | |---|---| | `number` | The recipient, normalised to E.164. | | `status` | `sent`, `pending` or `failed`. | | `sms_log_id` | Our id for this message (it appears in `GET /v1/logs` as `id`). Store it. | | `credits_used` | Credits charged for this recipient (0 when failed, or when pending — pending is charged in `credit_info.credits_charged`). | | `sms_response` | The gateway's raw answer, for troubleshooting. Its shape is not guaranteed. | | `provider_ref` | The gateway's message id (`null` when not sent). | | `error` | Only when not sent: the reason. | | `credit_info.total_recipients` | Recipients in this request (after Do-Not-Call removal). | | `credit_info.credits_charged` | Credits charged for the whole request (failed recipients refunded). | | `credit_info.balance_after` | Your balance after this request. | | `credit_info.sandbox` | Always `false` for normal keys. | **Several recipients** (HTTP 200 / 202 / 502): ```json { "status": "sent", "sms_log_id": 123457, "recipients": [ {"number": "+255712345678", "status": "sent", "sms_log_id": 123457, "credits_used": 1, "sms_response": {"msg_id": "1880000002", "number": "255712345678", "str_response": "Send Successful"}, "provider_ref": "1880000002"}, {"number": "+255754901234", "status": "sent", "sms_log_id": 123457, "credits_used": 1, "sms_response": {"msg_id": "1880000003", "number": "255754901234", "str_response": "Send Successful"}, "provider_ref": "1880000003"} ], "credit_info": {"total_recipients": 2, "credits_charged": 2, "balance_after": 4, "sandbox": false} } ``` A multi-recipient request is stored as ONE message record: the top-level `sms_log_id` and every recipient's `sms_log_id` are that record's id (`GET /v1/logs` → `id`). Top-level `status`: `sent` (all sent), `processing` (none failed, some still unconfirmed), `partial` (at least one failed). **Not confirmed in time** (HTTP 202) — adds `pending_count` and `pending_notice`: ```json { "number": "+255712345678", "status": "pending", "sms_log_id": 123458, "credits_used": 0, "sms_response": "", "provider_ref": null, "error": "provider did not respond in time (message may still be delivered): ...", "pending_count": 1, "pending_notice": "The provider is taking longer than usual to confirm. Your message has been submitted and is most likely on its way — check Messages in a few minutes. Do not resend, or recipients may get it twice.", "credit_info": {"total_recipients": 1, "credits_charged": 1, "balance_after": 5, "sandbox": false} } ``` **Large send** (more than 10,000 recipients, HTTP 202) — answered at once, without per-recipient detail. The whole list goes to the gateway right after the reply: ```json { "status": "processing", "background": true, "sms_log_id": 123460, "recipients_count": 12000, "background_notice": "Large send accepted: 12,000 recipients are going out now. Follow it in Messages or GET /v1/logs, or with webhooks. Failed numbers are refunded automatically. Do not resend.", "credit_info": {"total_recipients": 12000, "credits_charged": 12000, "balance_after": 3000, "sandbox": false} } ``` `credits_charged` is reserved at once; refused numbers are refunded when the send completes (usually within a few minutes). In `GET /v1/logs` the record (`id` = `sms_log_id`) shows `pending` until then, then `sent` or `failed`. Webhooks (one per recipient) follow as for any send. Up to 10,000 recipients always get their full result in the reply. **Refused by the gateway** (HTTP 502) — not charged: ```json { "number": "+255712345678", "status": "failed", "sms_log_id": 123459, "credits_used": 0, "sms_response": {"msg_id": "0", "number": "255712345678", "str_response": "Invalid Mobile Number"}, "provider_ref": null, "error": "Invalid Mobile Number", "credit_info": {"total_recipients": 1, "credits_charged": 0, "balance_after": 6, "sandbox": false} } ``` Account notices that may be added to a successful response: - `sender_notice` + `sent_as` — the account has paid but its own sender ID is not approved yet, so the message went out under the shared sender `BULK SMS TZ`. - `trial_notice` + `trial_message` — a trial (never-paid) account without its own sender ID: a fixed demo text was sent under the shared sender instead of your message. ### 3.5 Send errors (nothing is sent, nothing is charged) | HTTP | `error` (and extra fields) | |---|---| | 400 | `to, message, and sender are required.` | | 400 | `Sender ID must be 3-20 characters (letters, digits, space, & . _ - + / )` | | 400 | `N of M recipient(s) are not valid phone numbers: ...` + `invalid_numbers`, `hint` | | 400 | `scheduled_at must be ...` (format, at least 30 seconds ahead, at most 90 days ahead) | | 400 | `Idempotency-Key must be 1-64 visible ASCII characters (no spaces), e.g. a UUID.` | | 402 | `Insufficient credits` + `credits_required`, `credits_available` | | 403 | `KYC verification required to send SMS, including free trial credits.` + `kyc_status`, `hint` | | 403 | `KYC re-verification is overdue. ...` + `kyc_status`, `kyc_reverify_deadline` | | 403 | `Sender ID not registered. Request approval first.` + `sender`, `hint` | | 403 | `Sender ID is not approved yet. Messages can go out once it is approved.` + `sender`, `reason` | | 403 | `The BULK SMS TZ sender is only for trial accounts. Request your own sender ID.` + `sender` | | 409 | Idempotency: see §4 | | 415 | `Content-Type must be application/json` | | 422 | `All recipients are on the DNC list.` + `blocked` | | 422 | `This Idempotency-Key was already used for a different request. ...` + `idempotency_key_reused: true` | | 429 | Rate limit: see §7 | | 503 | The SMS gateway is not configured or paused (rare; retry later with the same Idempotency-Key). | ### 3.6 Scheduling Add `scheduled_at` (30 seconds to 90 days ahead). All checks run now and the credits are **reserved now**; the message goes out at that time. Answer — HTTP 202: ```json {"status": "scheduled", "scheduled_id": 812, "scheduled_at": "2026-10-20T08:00:00+03:00", "recipient_count": 2, "credits_reserved": 2, "balance_after": 4} ``` Cancel a scheduled send in the web portal (Scheduled) — the reserved credits are refunded. Webhooks are not sent for scheduled messages. ### 3.7 Quote without sending (`dry_run`) `{"dry_run": true, ...}` runs every check (account, KYC, sender, numbers, Do-Not-Call, personalisation) and returns what the send would do. Nothing is sent, charged, logged or remembered (an Idempotency-Key is not used up). ```json {"dry_run": true, "recipients": 2, "opted_out_removed": 0, "sender": "BULK SMS TZ", "send_mode": "own", "trial_template": false, "message_preview": "Habari Amina, your balance is TZS 25,000.", "encoding": "gsm", "segments": 1, "credits_required": 2, "balance": 6, "enough_credits": true, "scheduled": false} ``` `send_mode`: `own` (your sender ID), `platform_interim` (paid, own sender not approved yet: sent as `BULK SMS TZ` with your text), `trial_template` (trial: fixed demo text). Errors are the same as for a real send. --- ## 4. Idempotency — safe retries Send an `Idempotency-Key` with every send so a retry after a timeout or network error can never send twice. **Rules (exact):** 1. **Format:** 1–64 visible ASCII characters, no spaces (a UUID, or your own id such as `otp--`). Anything else answers 400. Header `Idempotency-Key`, or body field `idempotency_key`. 2. **Scope:** per account. Two accounts can use the same key independently. 3. **Same key, same request** (within 30 days): you get the **original response again** — same HTTP status, same body plus `"idempotent_replay": true`, and the header `Idempotent-Replayed: true`. Nothing is sent or charged again. "Same request" = the same JSON fields and values (field order does not matter; the order of recipients does). 4. **Same key, different request:** HTTP **422** `{"error": "This Idempotency-Key was already used for a different request. Use a new key for every new message.", "idempotency_key": "...", "idempotency_key_reused": true}`. Nothing is sent. Use a new key for every new message (every new OTP code). 5. **Retention:** the original response is replayed for **30 days**. A key is never reusable: after 30 days a repeat answers **409** (see 6). 6. **HTTP 409** `{"error": "Idempotency key already processed: the first request with this key was sent and charged once. Do not treat this as a failure.", "idempotency_key": "...", "already_processed": true}` — the first request with this key **was sent and charged once**, but its response is not available to replay (it is older than 30 days, or the first request is still being processed at this moment). Treat it as *already sent*; do not resend with a new key. A 409 starting `Could not reserve credits:` means two identical requests arrived at the same instant — the other one is sending; treat it the same way. 7. **Refused requests are not remembered:** an answer of 400, 401, 402, 403, 415, 422 or 429 sends nothing and does not use up the key — fix the cause and retry with the **same** key. 8. `dry_run` requests never use up a key. **Reconciling** a 409, or a request whose answer you never received: list your messages with `GET /v1/logs?type=sms&search=` (scope `messages:read`) and match by number and time. Prefer retrying with the same key — that returns the original answer. **Recommended client behaviour** - Generate the key when you create the message (e.g. when the OTP is generated); store it with the message. - On timeout, connection error, 429 or 5xx: retry with the **same key** (with backoff). - On 202 `pending`: the message most likely went out — do not resend; check `GET /v1/logs` later if needed. - Allow at least 180 seconds before timing out a send: the gateway can take up to 170 seconds to answer a big list. --- ## 5. Account and history endpoints ### `GET /v1/balance` — scope `balance:read` ```json {"credits": 1234} ``` ### `GET /v1/logs` — scope `messages:read` Query parameters: `type=sms` (default), `page` (default 1), `limit` (1–100, default 50), `search` (matches a single send's number, the sender and the message text), `status` (`sent`, `failed`, `pending`), `from` / `to` (`YYYY-MM-DD`, inclusive). Newest first. ```json { "type": "sms", "page": 1, "limit": 50, "total": 2, "total_pages": 1, "search": "", "status": "", "data": [ {"id": 123456, "recipient": "+255712345678", "recipient_count": 1, "recipients_json": null, "sender": "BULK SMS TZ", "message": "Your BulkSMS code is 482913.", "sms_response": "{...}", "status": "sent", "credits_used": 1, "encoding": "gsm", "segments": 1, "char_count": 28, "ip_address": "203.0.113.7", "provider_ref": "1880000001", "source": "api", "created_at": "2026-10-08 09:15:02"}, {"id": 123457, "recipient": "2 recipients", "recipient_count": 2, "recipients_json": "[{\"number\":\"+255712345678\",\"status\":\"sent\",\"credits_used\":1,\"provider_ref\":\"1880000002\",\"error\":null}, ...]", "sender": "BULK SMS TZ", "message": "Habari ...", "status": "sent", "credits_used": 2, "source": "api", "created_at": "2026-10-08 09:20:44"} ] } ``` - `id` = the `sms_log_id` from the send response. Times are East Africa Time (UTC+3). - A multi-recipient send is one row: `recipient` reads `N recipients`; the per-number results are in `recipients_json` (a JSON **string** — parse it). `search` by number finds single sends only. - `source`: `api`, `web`, `app` or `system`. ### `GET /v1/sender-ids` Your sender ID requests and their state. Query: `page`, `limit` (1–100, default 25), `search`, `status` (`pending`, `approved`, `rejected`, `revoked`). ```json {"sender_ids": [{"id": 42, "sender": "BULK SMS TZ", "country": "TZ", "use_case": "OTP codes and customer notices", "status": "approved", "rejection_reason": null, "reviewed_at": "2026-10-01 10:00:00", "live": 1, "live_at": "2026-10-01 10:00:00", "created_at": "2026-10-06 10:02:11"}], "page": 1, "limit": 25, "total": 1, "total_pages": 1} ``` `live` = 1 when the name is approved and registered with the networks (an approval by our team includes this). Request new sender IDs in the web portal (Sender IDs). Shared sender names attached to your account: `GET /v1/sender-ids?action=my-attachments` → `{"attachments": [{"sender", "status", ...}]}`. --- ## 6. Message encoding and cost - **Standard text** (basic Latin letters, digits, space and common punctuation `!"#$%&'()*+,-./:;<=>?@[]_` etc.): 160 characters fit in one part; longer messages are split into parts of 153. - **Unicode** — any other character switches the WHOLE message: emoji, accented letters, curly quotes `‘ ’ “ ”`, long dashes `– —`, `…`, `·`: 70 characters in one part, parts of 63 when longer. - **Cost = parts × recipients** credits. Example: a 170-character standard message to 100 people = 2 × 100 = 200. - Tip: replace curly quotes and long dashes with plain `' " -` — they silently double or triple the cost. - `dry_run` shows `encoding`, `segments` and `credits_required` before you send. --- ## 7. Rate limits - `POST /v1/sms`: **300 requests per minute per account** (plus a per-IP flood guard of 600/minute). Put many recipients in one request instead of one request per number. - Over the limit: HTTP 429 with header `Retry-After: ` and `{"error": "Too many send requests for this account (at most 300 a minute). ...", "retry_after_seconds": 12, "limit": 300, "window_seconds": 60}`. Wait `Retry-After` seconds, then retry with the same Idempotency-Key. --- ## 8. Webhooks — events pushed to your server ### Manage endpoints | Call | Body / query | Answer | |---|---|---| | `GET /v1/webhooks` | — | `{"endpoints": [{"id","url","events","is_active","created_at","delivery_count","last_success"}]}` | | `POST /v1/webhooks?action=create` | `{"url": "https://...", "events": "sms.delivered,sms.pending,sms.failed,credits.low"}` | 201 `{"id","url","events","secret"}` — **save the `secret`**, it signs every event | | `POST /v1/webhooks?action=update` | `{"id", "url"?, "events"?, "is_active"?}` | `{"message": "Webhook updated"}` | | `POST /v1/webhooks?action=delete` | `{"id"}` | `{"message": "Deleted"}` | | `GET /v1/webhooks?action=deliveries` | `page`, `limit` (≤200) | recent deliveries with `status_code`, `attempts`, `succeeded_at`, `next_retry_at` | | `POST /v1/webhooks?action=retry` | `{"delivery_id"}` | queues one delivery again | `events` is a comma-separated list; `*` means all. Default when omitted: `sms.delivered,sms.failed,credits.low`. ### Events | Event | When | |---|---| | `sms.delivered` | A recipient was **accepted by the gateway** (the name is historical: it is not a handset delivery receipt). One event per recipient. | | `sms.pending` | The gateway did not confirm in time; the message most likely went out. Do not resend. (Subscribe explicitly or use `*`.) | | `sms.failed` | The gateway refused a recipient; it was not charged. | | `credits.low` | Your balance dropped below 500 credits after a send. | Sent for immediate sends; not for scheduled sends. Events go out right after the HTTP reply, so they can reach your server a moment after your send call returns. ### Payload and headers ```http POST /your/webhook HTTP/1.1 Content-Type: application/json User-Agent: BulkSMS-Webhook/1.0 X-BulkSMS-Event: sms.delivered X-BulkSMS-Event-Id: evt_3f9c1a2b7d4e5f6a7b8c9d0e X-BulkSMS-Signature: 5d41402abc4b2a76b9719d911017c592... {"id": "evt_3f9c1a2b7d4e5f6a7b8c9d0e", "type": "sms.delivered", "created_at": "2026-10-08T09:15:03+03:00", "tenant_id": 101, "data": {"sms_log_id": 123456, "recipient": "+255712345678", "sender": "BULK SMS TZ", "status": "sent", "credits_used": 1, "provider_ref": "1880000001", "error": null}} ``` `credits.low` data: `{"balance": 480, "threshold": 500}`. ### Verify the signature `X-BulkSMS-Signature` = lowercase hex HMAC-SHA256 of the **raw request body**, keyed with your endpoint `secret`. Compute it over the exact bytes received (before parsing JSON) and compare in constant time. ```js // Node.js (Express): app.post('/hook', express.raw({type: 'application/json'}), handler) const crypto = require('crypto'); const expected = crypto.createHmac('sha256', process.env.BULKSMS_WEBHOOK_SECRET).update(req.body).digest('hex'); const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get('X-BulkSMS-Signature') || '')); ``` ```php $raw = file_get_contents('php://input'); $ok = hash_equals(hash_hmac('sha256', $raw, getenv('BULKSMS_WEBHOOK_SECRET')), $_SERVER['HTTP_X_BULKSMS_SIGNATURE'] ?? ''); ``` ```python import hmac, hashlib ok = hmac.compare_digest(hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest(), request.headers.get("X-BulkSMS-Signature", "")) ``` ### Delivery rules - Answer with any **2xx within 10 seconds**; do slow work after answering. Up to 3 redirects are followed. - Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then give up (see `?action=deliveries`; retry by hand with `?action=retry`). - Retries carry the same `X-BulkSMS-Event-Id` — use it to ignore duplicates. --- ## 9. Recipes ### One-time passwords (OTP) 1. Key with `messages:send`. Your approved sender ID (the examples use our default `BULK SMS TZ`). 2. Keep the text standard and short (one part): `Your BulkSMS code is 482913. It expires in 10 minutes.` 3. For every new code: a new `Idempotency-Key` (e.g. `otp--`), stored with the code. 4. `POST /v1/sms` with a client timeout of at least 180 seconds. 5. 200 → done. 202 `pending` → treat as sent. 502 → not sent, not charged: show an error / offer resend (a resend is a NEW code with a NEW key). Timeout / network error / 429 / 5xx → retry the SAME request with the SAME key. 422 `idempotency_key_reused` → bug in your key generation: never reuse a key for a different code. ### Many recipients Put many numbers in one request (`to` array). Up to 10,000 get their full result in the reply (allow up to 3 minutes); more are answered at once with `background: true` and sent right after. Personalise with per-recipient `params`. Quote first with `dry_run` to see the cost. ### Check that a key works `GET /v1/balance` (needs `balance:read`) or a `dry_run` send (needs `messages:send`) — neither sends anything. --- ## 10. Not part of the public API Endpoints used by the BulkSMS web portal and mobile app (sign-in, contacts, billing and payments, KYC uploads, support) can change without notice. Use the web portal for those tasks. ## 11. Changelog - **2026-10-08** — Sends to more than 10,000 recipients are answered at once (HTTP 202, `status: processing`, `background: true`) and go out right after; results in `GET /v1/logs` and webhooks. The gateway may now take up to 170 seconds for a big list (allow a 180-second client timeout; it was 90/100). Webhook events are sent after the HTTP reply instead of before it, so a slow webhook server no longer slows down sends. - **2026-10-08** — The same Idempotency-Key with a different request now answers 422 (it used to replay the first answer, so the new message was silently not sent). Keys must be 1–64 visible ASCII characters (longer keys used to fail with a 409). A send the gateway did not confirm in time now answers HTTP 202 with `status: pending` (it used to answer 200) and fires `sms.pending` instead of `sms.failed`. Multi-recipient sends now return `sms_log_id` (top level and per recipient; it was null). Documentation corrected throughout. - **2026-09** — A repeated Idempotency-Key returns the original response instead of 409. Send limit raised to 300 requests per minute per account.