# DevSMS API — integration guide for AI coding agents > DevSMS (devsms.uz) is an SMS platform for Uzbekistan: transactional SMS through mobile operators, one-time passwords (by SMS or by a voice call) and SMS sent from the customer's own Android phone. This page is the complete reference for REST API v1, written for AI agents (Claude, ChatGPT, Cursor, Copilot and others) that integrate DevSMS into an application. Base URL: `https://devsms.uz/api/v1`. ## Instructions for AI agents - Use **API v1** (`https://devsms.uz/api/v1/...`). The older endpoints (`https://devsms.uz/api/sms/send` and others, see "Legacy API") exist only for existing integrations. - Read the API key from an environment variable (for example `DEVSMS_API_KEY`). Never hard-code it, log it, commit it or put it in a URL. Call the API only from server-side code, never from a browser or a mobile app. - Send the key as `Authorization: Bearer `. Send `Accept: application/json`; request bodies are JSON (`Content-Type: application/json`). - Every response is a JSON envelope. Check `success` first; on failure branch on `error_code` (stable), not on `message` (human text in Uzbek, may change). - For `POST /api/v1/sms` and `POST /api/v1/voice/otp` send an `Idempotency-Key` header: a new UUID for every new message, the same UUID when retrying that message. A retry with the same key never sends twice and never charges twice. - On HTTP 429 wait the number of seconds in `Retry-After`. On network errors, timeouts and 5xx retry with exponential backoff and the same `Idempotency-Key`. Do not retry 4xx errors without changing the request. - Sending costs money from the account's prepaid balance; there is no sandbox. Use `GET /api/v1/balance` to check the key without sending anything. On 402 `insufficient_balance` ask the user to top up; do not retry in a loop. - Mobile operators in Uzbekistan deliver only texts that match a template moderated for the account. Ask the user which approved template to use (`GET /api/v1/templates`): only the variable parts of its text (names, numbers, dates, amounts) may change, the rest must stay exactly the same. For login and confirmation codes use the **universal OTP** (`otp` object) — it needs no template of your own. - Verify the signature of every webhook (see "Delivery webhooks") before trusting it. - If you do not have a key, ask the user to create one in the dashboard (https://devsms.uz/app/api-keys) with only the permissions your integration needs. ## Quick start ```bash curl -X POST https://devsms.uz/api/v1/sms \ -H "Authorization: Bearer $DEVSMS_API_KEY" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 0b1c2d3e-4f50-4a61-8b72-93a4b5c6d7e8" \ -d '{"phone": "998901234567", "message": "Your order #1024 has been shipped"}' ``` ```json { "success": true, "message": "SMS yuborildi", "data": { "id": 81234, "phone": "998901234567", "message": "Your order #1024 has been shipped", "status": "sent", "type": "regular", "parts": 1, "price_per_part": "200.00", "total_cost": "200.00", "currency": "UZS", "provider_request_id": "…", "country_code": null, "sent_via": "api", "error": null, "created_at": "2026-10-09T12:00:00+05:00", "sent_at": "2026-10-09T12:00:00+05:00", "delivered_at": null, "failed_at": null, "outcome": "sent" } } ``` ## Authentication API v1 accepts two kinds of keys: | Key | Format | Permissions | Where to get it | |---|---|---|---| | Personal key | `\|` (contains a vertical bar) | only the permissions chosen when the key was created | Dashboard → API keys → Personal keys | | Main key | 64 hexadecimal characters | all permissions | Dashboard → API keys → Token | - A personal key may have an expiry date. An expired key returns **401 `token_expired`** — ask the user for a new key. - The number of working personal keys depends on the account's plan. Keys above the limit (the newest ones) return **403 `token_locked_by_plan`** until the plan is upgraded or other keys are deleted. The main key is never locked. - A missing or invalid key returns **401 `unauthenticated`**. A key without the permission an endpoint needs returns **403 `forbidden`**. A blocked account returns **403 `account_blocked`**. ## Permissions Each personal key has a set of permissions. An endpoint needs exactly the permission shown in its section. | Permission | Group | Allows | |---|---|---| | `sms:send` | SMS | Send SMS: POST /api/v1/sms — regular SMS and universal OTP | | `sms:read` | SMS | Read SMS history: GET /api/v1/sms, /api/v1/sms/{id} — SMS history and status | | `templates:read` | SMS | Read templates: GET /api/v1/templates — templates and their moderation status | | `device_sms:send` | SMS from phone | Send SMS from phone: POST /api/v1/devices/sms — SMS from your own phone (DevSMS Sender) | | `device_sms:read` | SMS from phone | Read phone SMS status: GET /api/v1/devices/sms/{id} — phone SMS status | | `devices:read` | SMS from phone | Read devices: GET /api/v1/devices — connected phones | | `voice:send` | Voice OTP | Send voice OTP: POST /api/v1/voice/otp — a call that reads the code aloud | | `voice:read` | Voice OTP | Read voice call history: GET /api/v1/voice, /api/v1/voice/{id} — call history and status | | `balance:read` | Account | Read balance: GET /api/v1/balance — balance | ## Response envelope Success: ```json { "success": true, "message": "OK", "data": { }, "meta": { } } ``` `meta` is present on list endpoints (`next_cursor`, `limit`). Error: ```json { "success": false, "message": "Human readable text", "error_code": "validation_failed", "errors": { "phone": ["…"] }, "meta": { } } ``` - `errors` — only for `validation_failed`: field name → list of messages. - `meta` — extra facts for some errors, for example `charged`, `sms_id`, `call_id`, `remaining_attempts`, `blocked_until`. - Money amounts are decimal strings in UZS (`"200.00"`). Timestamps are ISO 8601 with the Tashkent offset (`2026-10-09T12:00:00+05:00`). ## Phone numbers - Uzbek numbers: `998XXXXXXXXX` (12 digits). Other spellings (`+998 90 123-45-67`, `901234567`) are normalized by the server. - International numbers work only if international SMS is enabled for the account (otherwise 422 `international_disabled`); the price depends on the country. Voice calls go to Uzbek numbers only. ## Rate limits Limits are per account: all keys of the account share them. They depend on the account's plan: | Plan | Send requests / minute | Read requests / minute | Personal keys | |---|---|---|---| | Start | 120 | 240 | 1 | | Pro | 300 | 600 | 5 | | Business | 1500 | 3000 | 20 | - "Send" counts `POST /api/v1/sms`, `POST /api/v1/devices/sms` and `POST /api/v1/voice/otp`; "read" counts all `GET` endpoints. - Every IP address is also limited to 1200 requests per minute (failed authentication attempts included). - Over the limit: **429 `rate_limited`** with a `Retry-After` header (seconds). ## Endpoints ### POST /api/v1/sms — send an SMS Permission: `sms:send`. Header `Idempotency-Key` (optional, strongly recommended): a UUID. | Field | Type | Required | Notes | |---|---|---|---| | `phone` | string | yes | Recipient (see "Phone numbers"). | | `message` | string, ≤ 1000 characters | yes, unless `otp` is sent | SMS text. For Uzbek numbers it must match an approved template of the account. | | `template_id` | integer | no | Id of the approved template the text belongs to (from `GET /api/v1/templates`); the price is calculated for that template. | | `from` | string, 1–11 characters `A-Z a-z 0-9 space . _ -` | no | Sender name registered for the account. Default `4546`. | | `callback_url` | string, `http(s)` URL ≤ 500 characters | no | Delivery webhook for this SMS (a public address; private and local addresses are rejected). | | `otp` | object | no | Universal OTP instead of `message`: `{ "template_type": 1-4, "service_name": "MyShop", "code": "4-8 digits" }`. Template types: 1 — confirm an operation, 2 — password reset, 3 — sign up, 4 — sign in. `service_name` ≤ 50 characters (your company or product name, moderated). | Universal OTP: ```bash curl -X POST https://devsms.uz/api/v1/sms \ -H "Authorization: Bearer $DEVSMS_API_KEY" -H "Accept: application/json" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"phone": "998901234567", "otp": {"template_type": 4, "service_name": "MyShop", "code": "482915"}}' ``` Response `data` — the SMS object: `id`, `phone`, `message`, `status`, `type`, `parts`, `price_per_part`, `total_cost`, `currency`, `provider_request_id`, `country_code`, `sent_via`, `error`, `created_at`, `sent_at`, `delivered_at`, `failed_at`, `outcome`. Idempotency (`Idempotency-Key`): - Same key, same `phone`, `message` (or `otp`) and `from` — no second SMS and no second charge: the first request's result and HTTP status come back with the header `Idempotent-Replayed: true`. `callback_url` and `template_id` are not compared. - Same key with a different phone, text or sender — 422 `idempotency_key_reused` (nothing is charged). Use a new UUID. - The first request with this key is still running — 409 `idempotency_request_in_progress` with `Retry-After: 2` and `meta.sms_id`; repeat the same request after that time. - Keys never expire and are shared by all keys of the account: never reuse a UUID for a different message. ### GET /api/v1/sms — list sent SMS Permission: `sms:read`. Query parameters: `cursor` (from `meta.next_cursor`), `limit` (1–200, default 50), `status` (`pending`, `sent`, `delivered`, `failed`, `blocked`), `from` and `to` (dates `YYYY-MM-DD`, Tashkent time, both inclusive). Newest first. Response: `data` — list of SMS objects; `meta.next_cursor` (`null` on the last page) and `meta.limit`. ### GET /api/v1/sms/{id} — one SMS Permission: `sms:read`. Returns the SMS object. An unknown id or another account's SMS — 404 `not_found`. ### GET /api/v1/templates — SMS templates Permission: `templates:read`. All templates of the account, newest first: `id`, `name`, `text`, `status` (only `approved` templates can be used for sending), `operators` (per-operator moderation: `operator`, `type` — `ad` means an advertising template with the advertising price). A template `text` is a real example of the message (for example `Hello Aziz, your order #1024 is ready`): when sending, change only the variable parts (names, numbers, dates, amounts) and keep the rest of the text exactly as it is. ### POST /api/v1/devices/sms — send an SMS from the customer's own phone Permission: `device_sms:send`. The SMS is queued and sent by the customer's Android phone with the DevSMS Sender app (no template needed, the operator's own tariff applies on the phone). A fixed service fee is charged immediately and refunded if the SMS is not sent or expires. | Field | Type | Required | Notes | |---|---|---|---| | `phone` | string | yes | Recipient. | | `message` | string, ≤ 1000 characters | yes | SMS text. | | `device_id` | integer | no | Phone to use (`id` from `GET /api/v1/devices`). Default — the most recently active phone. | | `expires_in` | integer, 60–86400 | no | Seconds before an unsent SMS expires and the fee is refunded. Default 3600. | | `client_ref` | string, ≤ 100 characters | no | Your own reference, returned in the status and the webhook. | | `callback_url` | string, `http(s)` URL ≤ 500 characters | no | Webhook for the final status. | Response `data`: `id`, `status` (`queued`), `device_id`, `device_online`, `expires_at`, `fee`, `balance`. Errors: 404 `device_not_found` (wrong `device_id`), 409 `no_device` (no active phone), 403 `device_locked` (the phone is above the plan limit), 402 `insufficient_balance`. ### GET /api/v1/devices/sms/{id} — status of a phone SMS Permission: `device_sms:read`. Returns `id`, `status` (`queued`, `sending`, `sent`, `delivered`, `failed`, `expired`, `cancelled`), `phone`, `device_id`, `client_ref`, `fee`, `refunded`, `error_message`, `expires_at`, `sent_at`, `delivered_at`, `failed_at`. ### GET /api/v1/devices — connected phones Permission: `devices:read`. A list of phones: `id`, `name`, `model`, `android_version`, `app_version`, `status`, `online`, `last_heartbeat_at`, `sim_operator`, `sim_slot`, `battery_level`, `signal_strength`, `daily_limit`, `sent_today`, `remaining_today`, `sms_interval`, `supports_api`, `connected_at`. ### POST /api/v1/voice/otp — a call that reads a code aloud Permission: `voice:send`. Header `Idempotency-Key` (optional, strongly recommended): a UUID, same rules as for SMS (compared: phone, text and code). | Field | Type | Required | Notes | |---|---|---|---| | `phone` | string | yes | Uzbek phone number. | | `template_type` | integer 1–4 | yes, unless `text` is sent | 1 — confirm an operation, 2 — password reset, 3 — sign up, 4 — sign in. | | `service_name` | string, ≤ 50 characters | with `template_type` | Company or product name spoken in the call (moderated). | | `text` | string, ≤ 300 characters | no | Your own spoken text instead of a template (only if enabled for the account, otherwise 403 `voice_free_text_disabled`; moderated). Cannot be combined with `template_type`. | | `code` | string, 4–8 digits | no | Code read digit by digit. If omitted, a 6-digit code is generated and returned in `data.code` — compare it with what the user enters. | | `repeat` | boolean | no | Read the code twice. | The price is charged when the call is requested. It is returned only if the provider does not accept the call (422 `provider_rejected`, 503 `voice_busy`); after that it is final, whether or not the call is answered. Response `data` — the call object: `id`, `kind`, `phone`, `status`, `result`, `outcome`, `template_type`, `service_name`, `code`, `text`, `spoken_text`, `repeat`, `price`, `currency`, `provider_call_id`, `duration_seconds`, `billable_seconds`, `dtmf_digits`, `error`, `sent_via`, `created_at`, `provider_called_at`, `answered_at`, `ended_at`, `synced_at`. ### GET /api/v1/voice — list calls; GET /api/v1/voice/{id} — one call Permission: `voice:read`. List query: `cursor`, `limit` (1–200), `status` (`pending`, `blocked`, `not_sent`, `unknown`, `queued`, `calling`, `ringing`, `answered`, `transferred`, `completed`, `no_answer`, `busy`, `failed`, `rejected`, `cancelled`), `from`, `to` (`YYYY-MM-DD`). Same pagination as SMS. The call status (`queued` → `calling` → `ringing` → `answered` → `completed`, or `no_answer`, `busy`, `failed`) may lag up to a minute behind the real call. ### GET /api/v1/balance — balance and prices Permission: `balance:read`. Returns `balance`, `currency`, `sms_price` (price of one SMS part) and `reklama_price` (price of one part of an advertising SMS). ## SMS status and billing - Status flow: `pending` → `sent` → `delivered` or `failed`. `blocked` — rejected by moderation before sending, not charged. - Money is taken before the SMS is handed to the operator. If the operator gateway rejects it at once, the money is returned and the API answers 422 `provider_rejected` (`outcome` = `failed_definitive`). - If the gateway's answer is lost (timeout), the API answers 502 `provider_outcome_unknown` with `meta.sms_id` and `meta.charged`; the money is not returned automatically because the SMS may have been sent. Check `GET /api/v1/sms/{id}` or repeat the request with the same `Idempotency-Key` — never resend without the key, that sends and charges again. - After an SMS is accepted (`sent`) the fee is final, also when the operator later reports `failed` (phone off, number unreachable). - Long texts are split into parts and every part is charged (`parts`, `total_cost`): Latin (GSM-7) text — 160 characters in one part, 153 per part when split; Cyrillic and other Unicode text — 70 and 67. ## Delivery webhooks When `callback_url` is set, DevSMS sends a `POST` with a JSON body. SMS (`POST /api/v1/sms`) — on every status change reported by the operator: ```json { "sms_id": 81234, "request_id": "…", "phone": "998901234567", "status": "delivered", "sent_at": "2026-10-09 12:00:00", "delivered_at": "2026-10-09 12:00:04", "failed_at": null, "timestamp": "2026-10-09 12:00:05" } ``` Phone SMS (`POST /api/v1/devices/sms`) — once, with the final status (or when the SMS has stayed `sent` for 30 minutes): ```json { "type": "device", "device_sms_id": 5512, "client_ref": "ORDER-123", "phone": "998901234567", "status": "delivered", "sent_at": "2026-10-09 12:00:00", "delivered_at": "2026-10-09 12:00:09", "failed_at": null, "error_message": null, "timestamp": "2026-10-09 12:00:10" } ``` Webhook times are `YYYY-MM-DD HH:MM:SS` in Tashkent time (UTC+5). Headers: `X-DevSMS-Timestamp` (unix seconds) and `X-DevSMS-Signature: sha256=`, where `` is HMAC-SHA256 of `"."` with the account's webhook secret as the key (the whole `whsec_…` string from Dashboard → API keys → Webhook). Compute it over the raw body bytes, compare in constant time and reject timestamps older than 5 minutes. ```js import crypto from "node:crypto"; function verify(rawBody, timestamp, signature, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } ``` ```python import hashlib, hmac def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(signature, expected) ``` ```php function verify(string $rawBody, string $timestamp, string $signature, string $secret): bool { return hash_equals('sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret), $signature); } ``` Answer with any status below 400 within 5 seconds. Otherwise the webhook is retried after 1, 5, 15, 30 and 60 minutes, then every hour, for up to 24 hours. The same webhook can arrive more than once — make the handler idempotent (by `sms_id` or `device_sms_id` plus `status`). ## Error codes | HTTP | `error_code` | Meaning and what to do | |---|---|---| | 401 | `unauthenticated` | Missing or invalid key. | | 401 | `token_expired` | The personal key has expired — a new key is needed. | | 403 | `forbidden` | The key lacks the permission for this endpoint. | | 403 | `account_blocked` | The account is blocked — the user must contact support. | | 403 | `token_locked_by_plan` | Personal key above the plan limit — upgrade the plan or delete other keys. | | 403 | `device_locked` | The phone is above the plan limit. | | 403 | `moderation_rejected` | The text (for OTP — the `service_name`) was rejected by moderation. Not charged; see `meta.remaining_attempts`. | | 403 | `otp_suspended` | OTP sending is suspended after repeated moderation rejections (`meta.blocked_until`). | | 403 | `voice_free_text_disabled` | Own text for voice calls is not enabled for the account — use `template_type`. | | 402 | `insufficient_balance` | Not enough balance — the user must top up; do not retry in a loop. | | 404 | `not_found` | Unknown id or another account's resource. | | 404 | `device_not_found` | Unknown `device_id`. | | 405 | `method_not_allowed` | Wrong HTTP method for this path. | | 409 | `no_device` | No active phone for `POST /api/v1/devices/sms`. | | 409 | `idempotency_request_in_progress` | The first request with this `Idempotency-Key` is still running — repeat after `Retry-After`. | | 422 | `validation_failed` | Invalid input — see `errors`. | | 422 | `idempotency_key_reused` | The `Idempotency-Key` was used for a different message — use a new UUID. | | 422 | `international_disabled` | International SMS is not enabled for the account. | | 422 | `unsupported_country` | The number's country is not supported. | | 422 | `provider_rejected` | The operator gateway rejected the message; the money was returned. | | 4xx | `bad_request` | Other malformed request. | | 429 | `rate_limited` | Too many requests — wait `Retry-After` seconds. | | 502 | `provider_outcome_unknown` | The gateway's answer was lost; the money was not returned (`meta.charged`). Check the status or retry with the same `Idempotency-Key`. | | 503 | `voice_disabled` | Voice calls are temporarily turned off. | | 503 | `voice_busy` | The voice provider is busy — retry after `Retry-After`; not charged. | | 503 | `demo_mode` | Sending is disabled on the demo server. | | 500 | `server_error` | Unexpected error — retry later with the same `Idempotency-Key`. | Treat an unknown `error_code` by its HTTP status: 4xx — fix the request; 5xx — retry later with the same `Idempotency-Key`. ## Machine-readable specification OpenAPI document of API v1 (descriptions in Uzbek): https://devsms.uz/docs/api/v1.json — use it to generate a typed client. ## Legacy API The older endpoints (`https://devsms.uz/api/sms/send`, `/api/balance`, `/api/sms/history`, `/api/sms/status`, `/api/devices`) accept only the main key and are kept for existing integrations; their human-readable documentation is https://devsms.uz/docs/api?lang=en. New integrations should use API v1 as described on this page.