# Esemes Gateway: full documentation --- --- API base: https://sms.bizmakers.app · OpenAPI: https://sms.bizmakers.app/v1/openapi.json --- --- # Quickstart Esemes Gateway sends SMS from **your own Android phone and SIM** through a simple REST API. Messages can only go to **Mauritian mobile numbers** (+230 5xxx xxxx). Access is by invitation. ## 1. Pair a phone In the console open **Phones → Add device**. Install the Esemes Gateway app on an Android phone, tap **Scan QR code**, then **Start gateway** and pick a SIM. ## 2. Create a project and a key **Projects → New project**, then **New key**. Pick **Test** while you build: nothing is sent and every result is simulated (see [Test mode](/docs/test-mode)). Switch to a **Live** key when you are ready. Keys are shown once. Keep the key on your server. **Never put it in browser or mobile-app code**: anyone could read it and send SMS from your SIM. ## 3. Send a message ```bash curl https://sms.bizmakers.app/v1/messages \ -H "Authorization: Bearer $ESEMES_KEY" \ -H "Content-Type: application/json" \ -d '{"to": "5123 4567", "body": "Your ticket T-1042 is ready: https://example.com/t/1042"}' ``` The answer is `202 Accepted` with the message (`id`, `state: "queued"`). The phone sends it within seconds. ## 4. Follow it `GET https://sms.bizmakers.app/v1/messages/{id}` shows its state and history: `queued → dispatched → sent → delivered` (or `failed`, `expired`, `canceled`). Better: add a [webhook](/docs/webhooks) and be told. ## Next - [Send a one-time code](/docs/otp) (login / phone verification) in two calls. - [Webhooks](/docs/webhooks) for delivery updates and replies. - [Errors](/docs/errors) and [limits](/docs/limits). - Building with an AI agent? Read [AI agents](/docs/ai-agents). --- # Sending messages `POST https://sms.bizmakers.app/v1/messages` with `Authorization: Bearer `. ```json { "to": "5123 4567", "body": "Hello", "kind": "transactional", "idempotency_key": "order-1042", "metadata": {"order": 1042} } ``` | Field | Notes | |---|---| | `to` | Mauritian mobile. `5123 4567`, `51234567`, `+230 5123 4567` all work. Anything else: `422 country_not_allowed`. | | `body` | Up to 1600 characters. Long texts are split into parts by the phone. Emoji or accents use more parts. | | `template` + `params` (+ `locale`) | Instead of `body`: a saved template, see below. | | `kind` | `transactional` or `notification` (default, expires after 24 h), `bulk` (72 h), `otp` (highest priority, 5 min; prefer [/v1/otp](/docs/otp)). `notification` and `bulk` end with an [opt-out link](/docs/replies). | | `device_id` | Send from this phone only (its id is on the console's Phones page). Default: any online phone, least busy first. | | `ttl_seconds` | 30 s to 7 days. A message not sent in time becomes `expired`, never sent late. | | `scheduled_at` | RFC 3339 time to send later. | | `priority` | 0 (first) to 9. | | `idempotency_key` | Or the `Idempotency-Key` header, max 128 chars. Same key + same content returns the first message (`200`, header `Idempotent-Replay: true`); same key + different content is `409 idempotency_conflict`. Use it for every retry-able send. | | `sim_slot` | 0 or 1 to force a SIM on a dual-SIM phone. With several phones, combine it with `device_id`. Usually leave it out. | | `metadata` | Any JSON object; returned with the message and in webhooks. | ## Message states `scheduled → queued → dispatched → sent → delivered`, or `failed` (with `error_code`), `expired`, `canceled`. `delivered` needs the carrier's delivery report; some numbers never send one, so `sent` can be final. ## Reading and cancelling - `GET /v1/messages/{id}`: the message with its `events` history. - `GET /v1/messages?state=&kind=&to=&limit=&before=`: newest first, 50 per page; pass `next_before` from the answer as `before` for the next page. - `POST /v1/messages/{id}/cancel`: only while still `queued`/`scheduled` (`409 not_cancelable` after). - `GET /v1/usage`: messages in the last 24 h / 30 days and your project's quotas. ## Templates ```bash curl -X PUT https://sms.bizmakers.app/v1/templates/ticket -H "Authorization: Bearer $ESEMES_KEY" -H "Content-Type: application/json" \ -d '{"locale": "en", "body": "Your ticket {{.ticket}}: {{.url}}"}' ``` Then send `{"to": "...", "template": "ticket", "params": {"ticket": "T-1042", "url": "https://..."}}`. A missing variable is an error (never sends ""). `GET /v1/templates`, `DELETE /v1/templates/{key}?locale=en`. --- # One-time codes Esemes Gateway makes, sends, stores (hashed) and checks the code. You never handle it. ## 1. Send a code ```bash curl https://sms.bizmakers.app/v1/otp -H "Authorization: Bearer $ESEMES_KEY" -H "Content-Type: application/json" \ -d '{"to": "5123 4567", "purpose": "login"}' ``` ```json {"otp_id": "8c1e…", "message_id": "…", "to": "+23051234567", "expires_at": "…"} ``` Optional: `length` (4-10, default 6), `ttl_seconds` (60-1800, default 300), `template` + `params` (your template gets `{{.code}}` and `{{.minutes}}`), `idempotency_key`. Default text: "123456 is your verification code. It expires in 5 minutes. Do not share it." ## 2. Check what the person typed ```bash curl https://sms.bizmakers.app/v1/otp/verify -H "Authorization: Bearer $ESEMES_KEY" -H "Content-Type: application/json" \ -d '{"otp_id": "8c1e…", "code": "123456"}' ``` ```json {"valid": true} ``` or `{"valid": false, "reason": "incorrect", "attempts_left": 4}`. Reasons: `incorrect`, `expired_or_used`, `too_many_attempts`, `not_found`. You can also verify by `{"to": "...", "purpose": "login", "code": "..."}` (the latest live code for that number). ## Rules - 5 attempts per code, single use, expires after `ttl_seconds`. - 30 seconds between sends to the same number and purpose (`429 otp_cooldown`), 5 per hour (`429 recipient_rate_limited`). - Codes still go to people who opted out, so they can always log in. - With a **test key** the answer also contains `test_code`, so automated tests can verify without a phone. ## Do it on your server Call both endpoints from your backend. A typical flow: the user enters a phone number → your server calls `/v1/otp` and keeps `otp_id` in the session → the user types the code → your server calls `/v1/otp/verify` and only then marks the number verified or logs them in. --- # Webhooks Add one in the console (**Projects → your project → Webhooks**) or with the API: ```bash curl https://sms.bizmakers.app/v1/webhooks -H "Authorization: Bearer $ESEMES_KEY" -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hooks/sms", "events": ["message.sent", "message.delivered", "message.failed", "message.received"]}' ``` The answer contains `secret` (shown once). URLs must be public `https`. Events: `message.queued`, `message.dispatched`, `message.sent`, `message.delivered`, `message.failed`, `message.expired`, `message.canceled`, `message.retried`, `message.received` (a [reply](/docs/replies)). ## What you receive `POST` with JSON: ```json { "id": 4211, "type": "message.delivered", "created_at": "2026-10-03T08:15:02Z", "data": {"device_id": "…"}, "message": {"id": "…", "to": "+23051234567", "state": "delivered", "kind": "transactional", "error_code": null, "metadata": {"order": 1042}, "test": false, "direction": "outbound"} } ``` Headers: `X-SMS-Event` (the type), `X-SMS-Delivery` (unique per delivery, use it to ignore duplicates), `X-SMS-Signature: t=,v1=`. OTP codes are never included. Answer `2xx` within 10 seconds. Anything else is retried after 10 s, 30 s, 2 min, 10 min, 30 min, 2 h, 6 h, 12 h, 12 h, 24 h, then given up. The console shows every delivery and can replay it, and **Send test event** sends a `ping`. ## Check the signature (always) `v1 = HMAC-SHA256(secret, t + "." + raw request body)`, hex. Reject when it does not match or `t` is more than 5 minutes old. Use the **raw** body, before any JSON parsing. **Node.js** ```js import crypto from "node:crypto" export function verify(rawBody, header, secret) { const [t, v1] = header.split(",").map((p) => p.split("=")[1]) if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const want = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex") return v1.length === want.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(want)) } ``` **Python** ```python import hmac, hashlib, time def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) if abs(time.time() - int(parts["t"])) > 300: return False want = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(want, parts["v1"]) ``` **PHP** ```php function esemes_verify(string $rawBody, string $header, string $secret): bool { parse_str(str_replace(',', '&', $header), $p); if (abs(time() - (int)$p['t']) > 300) return false; $want = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret); return hash_equals($want, $p['v1']); } ``` --- # Replies and opt-outs ## Opt-out link (every phone) Messages of kind `notification` and `bulk` end with a line like: ```text Stop: https://sms.bizmakers.app/s/Ab3dE7gh ``` The link opens a page where the person taps **Stop messages**. From then on, your organisation's messages to that number fail with `422 recipient_suppressed`. Opening the link alone changes nothing, so link previews and scanners never unsubscribe anyone. The same page lets them resubscribe. - Each number gets one stable link per organisation. The link is part of the message body (count it in your length). - `transactional` messages (receipts, tickets the person asked for) and one-time codes get no link. - One-time codes still go through after an opt-out, so people can always log in. - You can add or remove numbers yourself under **Suppressions**. ## Replies and STOP (phones on the Replies build) The standard app sends only: Google Play Protect blocks installing apps that can read incoming SMS from a browser. Phones set up with the **Replies build** (installed by cable, see the Phones page: "replies on") also forward replies: - A reply shows up in the project that last texted that number (within 30 days): `direction: "inbound"`, `state: "received"`, in `GET /v1/messages`, the console, and the `message.received` webhook. - A reply of **STOP**, ARRET, UNSUBSCRIBE (whole message) has the same effect as the opt-out link. **START** undoes it. **Privacy:** the phone forwards only texts from numbers this gateway has texted in the last 30 days. Every other SMS on the SIM (family, banks, other services) stays on the phone and never reaches the server. Put your brand name in your messages: they come from a personal SIM number, so the recipient needs to know who is texting. --- # Test mode Create a **Test** key (it starts with `smt_`). Everything works the same, but **nothing is sent**: a simulator moves each message through the normal states within a second or two and fires the same webhooks, with `"test": true`. | To | Result | |---|---| | `5000 0001` | `failed`, `error_code: INVALID_NUMBER` | | `5000 0002` | `expired` | | `5000 0003` | `sent`, never `delivered` | | any other Mauritian mobile | `delivered` | - `POST /v1/otp` with a test key returns the code as `test_code`, so automated tests can verify it. - Test messages do not count toward quotas or usage. Cap: 2000 per project per day. - The Mauritius-only rule still applies, so your validation behaves like production. Use test keys in development, CI and with AI coding agents. Switch to a live key (`smk_`) for production. --- # Errors Errors are JSON: `{"error": {"code": "country_not_allowed", "message": "…"}}`. Branch on `code`, show `message` to developers. | HTTP | code | Meaning / what to do | |---|---|---| | 400 | `bad_request` | Invalid JSON or an unknown field. | | 401 | `unauthorized` | Missing, wrong, revoked key, or the project / organisation is suspended. | | 403 | `forbidden` | The key lacks the scope for this endpoint. | | 404 | `not_found` | No such message / template / webhook in this project. | | 409 | `idempotency_conflict` | Same idempotency key, different content. | | 409 | `not_cancelable` | The message already left the queue. | | 422 | `invalid_request` | A field is wrong; `message` says which. | | 422 | `country_not_allowed` | Only Mauritian mobiles (+230 5xxx xxxx). | | 422 | `recipient_suppressed` | The number opted out (opt-out link or STOP) or is on your do-not-contact list. | | 429 | `quota_exceeded` | Project quota reached (see `/v1/usage`); `Retry-After` header. | | 429 | `recipient_rate_limited` | Too many messages / codes to one number; wait. | | 429 | `otp_cooldown` | Wait 30 s before sending another code to that number. | | 429 | `rate_limited` | More than 50 requests per second with one key. | | 500 | `internal` | Our side. Safe to retry with the same idempotency key. | ## Message failure codes (`error_code` on a failed message) `NO_SERVICE`, `RADIO_OFF` (no signal / airplane mode), `GENERIC_FAILURE` (often: SIM out of credit or SMS bundle), `SIM_UNAVAILABLE`, `INVALID_NUMBER`, `DELIVERY_FAILED` (carrier could not deliver), `LIMIT_EXCEEDED` (Android's own rate limit), `DISPATCH_TIMEOUT` (the phone did not answer), `EXPIRED_ON_DEVICE`, `APP_ERROR`. Phone-level failures are retried; 5 in a row pause the phone for 30 minutes and alert the organisation's admins. --- # Limits | What | Limit | |---|---| | Recipients | Mauritian mobiles only | | API requests | 50 per second per key (burst 100) | | Messages to one number | 20 per hour per project | | One-time codes | 30 s between sends, 5 per hour per number and purpose, 5 attempts each | | Message body | 1600 characters | | Project quotas | Optional per minute / day / 30 days, set in the console | | Phones per organisation | 3 by default (ask the platform admin for more) | | Test mode | 2000 messages per project per day | ## Phone ceilings Each phone has hard ceilings (default 10 per minute, 90 per hour, 800 per day, editable under **Phones**) so Android's "this app is sending a lot of messages" prompt never blocks it. Extra messages wait in the queue. Consumer SIMs also have carrier fair-use rules: this is for transactional SMS (codes, tickets, notifications), not marketing blasts. --- # AI agents Point your agent at the machine-readable docs: **https://sms-admin.bizmakers.app/llms.txt** (index) or **https://sms-admin.bizmakers.app/llms-full.txt** (everything in one file), and the OpenAPI spec at **https://sms.bizmakers.app/v1/openapi.json**. Give it a **test key** (`smt_…`) while it builds: nothing is sent, results are simulated and OTPs return `test_code`. ## Prompt to paste ```text Add SMS one-time-code login to this app using Esemes Gateway. Docs: https://sms-admin.bizmakers.app/llms-full.txt (read it first). API base: https://sms.bizmakers.app Rules: - Call the API only from server-side code. The key is in the ESEMES_KEY env var; never expose it to the browser or app bundle. - Use POST /v1/otp to send and POST /v1/otp/verify to check; keep otp_id in the server session. - After a valid code, sign the person in with a signed/encrypted session (or the app's existing auth), never a plain cookie holding the phone number. - Phone numbers are Mauritian mobiles (+230 5xxx xxxx); show a clear error for country_not_allowed. - Handle 429 otp_cooldown (ask the user to wait 30 s) and reasons incorrect / expired_or_used / too_many_attempts. - Use an idempotency_key for message sends that may be retried. - If you add webhooks, verify X-SMS-Signature as documented. - Use the test key while developing; tell me when to switch to a live key. ``` ## MCP server Agents that speak MCP (Claude Code, Cursor, Claude Desktop) can use the gateway as tools: `get_docs`, `send_sms`, `send_otp`, `verify_otp`, `get_message`, `list_messages`, `get_usage`. ```json { "mcpServers": { "esemes": { "command": "npx", "args": ["-y", "esemes-mcp"], "env": { "ESEMES_API_KEY": "smt_..." } } } } ``` Use a **test key**. The server refuses a live key (`smk_…`) unless you also set `ESEMES_ALLOW_LIVE=1`. `ESEMES_BASE_URL` defaults to https://sms.bizmakers.app. ## Agent skill A step-by-step skill that makes an agent add one-time-code login correctly (key stays on the server, test key first, every error handled): see [Agent skill](/docs/agent-skill). --- # SDKs Both are tiny, dependency-free wrappers around the REST API, for **server-side** code only. They are not on npm / PyPI yet: ask for the files, or copy them from the repository (`sdks/js`, `sdks/python`). ## JavaScript / TypeScript (Node 18+, Bun, Deno, edge) ```ts import { Esemes, EsemesError, verifyWebhook } from "@esemes/gateway" const esemes = new Esemes({ apiKey: process.env.ESEMES_KEY! }) // smt_... while developing await esemes.messages.send({ to: "5123 4567", body: "Your ticket is ready", idempotency_key: "order-1042" }) const { otp_id, test_code } = await esemes.otp.send({ to: "5123 4567" }) const { valid, reason } = await esemes.otp.verify({ otp_id, code: userInput }) try { await esemes.messages.send({ to: "+33612345678", body: "x" }) } catch (e) { if (e instanceof EsemesError && e.code === "country_not_allowed") { /* show a nice error */ } } // webhook route: the RAW body const ok = await verifyWebhook(rawBody, signatureHeader, process.env.ESEMES_WEBHOOK_SECRET!) ``` ## Python (3.9+, standard library only) ```python from esemes import Esemes, EsemesError, verify_webhook esemes = Esemes(os.environ["ESEMES_KEY"]) esemes.messages.send("5123 4567", "Your ticket is ready", idempotency_key="order-1042") sent = esemes.otp.send("5123 4567") result = esemes.otp.verify(user_input, otp_id=sent["otp_id"]) ok = verify_webhook(request.body, request.headers["X-SMS-Signature"], os.environ["ESEMES_WEBHOOK_SECRET"]) ``` Errors carry `status`, `code` (see [Errors](/docs/errors)) and the `Retry-After` seconds for 429s. --- # Add SMS one-time-code login with Esemes Gateway Follow these steps in order. Esemes Gateway sends SMS through a real Android phone in Mauritius, so a mistake sends a real text to a real person. 1. **Read the docs first**: the MCP tool `get_docs` if the `esemes` MCP server is connected, otherwise `https://sms-admin.bizmakers.app/llms-full.txt` (one file). API base: `https://sms.bizmakers.app`. 2. **Use a test key while building.** Keys starting `smt_` send nothing: results are simulated, `POST /v1/otp` returns `test_code`, number `5000 0001` fails, `5000 0002` expires, `5000 0003` is never delivered. Do not ask for or use a live key (`smk_`) until the user says to switch. 3. **Keep the key server-side.** Put it in an environment variable (`ESEMES_KEY`). Never in browser JavaScript, a mobile app bundle, a public repo or a log line. If the app has no backend, add a small server endpoint; do not call the API from the client. 4. **Send:** `POST /v1/otp` with `{"to": "5123 4567", "purpose": "login"}`. Store the returned `otp_id` in the server session (not the code, which you never receive outside test mode). 5. **Verify:** `POST /v1/otp/verify` with `{"otp_id", "code"}`. Success is `{"valid": true}`. Treat any other result as a failed login and show a message for `reason`: `incorrect` (with `attempts_left`), `expired_or_used` (offer to resend), `too_many_attempts` (start over). 6. **Sign the person in properly after `{"valid": true}`.** Use the app's existing auth/session if it has one. Otherwise issue a signed or encrypted session (e.g. `iron-session` or a `jose` JWT with a server secret). Never store the phone number in a plain cookie and trust it later: anyone can set that cookie by hand. Session and `otp_id` cookies are `httpOnly`, `sameSite: "lax"` and `secure` in production. 7. **Handle errors:** `429 otp_cooldown` = wait 30 s before resending (show a countdown); `429 recipient_rate_limited` = too many codes for that number this hour; `country_not_allowed` = only Mauritian mobiles (+230 5xxx xxxx) work, say so; `recipient_suppressed` = the person opted out of this sender's messages (only affects non-OTP sends). 8. **Idempotency:** for plain `POST /v1/messages` sends that may be retried, pass an `idempotency_key`. 9. **Webhooks (optional):** verify `X-SMS-Signature` (`t=,v1=`, HMAC-SHA256 of `"." + rawBody`) and reject timestamps older than 5 minutes. 10. **Test the whole flow** with the test key, including a wrong code, the cooldown and a forged session cookie being refused, then tell the user what to change to go live (swap the key, nothing else). The MCP server `esemes-mcp` has tools `get_docs`, `send_sms`, `send_otp`, `verify_otp`, `get_message`, `list_messages`, `get_usage`; it refuses a live key unless `ESEMES_ALLOW_LIVE=1`.