# OTPRun API: complete guide > OTPRun sends WhatsApp and Telegram messages and one-time codes (OTP) through one HTTP API. WhatsApp messages go out from the customer's own WhatsApp numbers linked to the cloud (like WhatsApp Web), so there is a flat price per number and no per-message fee. Telegram goes through the @OTPRun_bot bot. This file is written so that a program or an agent holding an API key can act immediately. Base URL: `https://otprun.com` Public API prefix: `/v1` Format: JSON over HTTPS, UTF-8. Times are RFC 3339 in UTC. Phones are international numbers with country code. ## 1. Authentication Every `/v1` request needs an API key. Keys look like `or_live_` followed by 32 characters. They are created in the dashboard at https://otprun.com/app → Projects → a project → API keys. Each key belongs to one project and has scopes: - `messages`: send and read plain messages. - `verify`: create and check verifications (one-time codes). - `read`: read-only access to messages, verifications and numbers. Send the key in one of these ways (first one preferred): ``` Authorization: Bearer or_live_YOUR_KEY X-API-Key: or_live_YOUR_KEY ``` For GET requests only, the key may also go into the query string as `api_key=or_live_YOUR_KEY`. Use that only when the tool cannot set headers. Check the key: ``` curl https://otprun.com/v1/ping -H "Authorization: Bearer or_live_YOUR_KEY" ``` ```json {"project_id": 1, "workspace_id": 1, "scopes": ["verify", "messages", "read"]} ``` ## 2. Send a WhatsApp message `POST /v1/messages` (scope `messages`) Request body: | field | type | required | notes | |---|---|---|---| | `to` | string | yes | recipient phone, for example `+77010000000`; spaces, dashes, dots and brackets are ignored, the leading `+` is optional, no leading zeros or trunk prefixes like `8` | | `text` | string | yes | 1 to 4096 characters, plain text with emoji and line breaks | | `idempotency_key` | string | no | up to 128 characters; repeating a request with the same key returns the first message instead of sending again | | `slot_id` | string | no | id of a linked WhatsApp number to send from; by default the best connected number is picked automatically and the same number keeps talking to the same recipient | ``` curl -X POST https://otprun.com/v1/messages \ -H "Authorization: Bearer or_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"to":"+77010000000","text":"Your order #1042 has shipped","idempotency_key":"order-1042-shipped"}' ``` Response `202 Accepted`: ```json { "id": "01a0e22c-b349-751d-8d57-e7c56fe0a4c0", "project_id": 1, "to_phone": "+77010000000", "channel": "whatsapp_gateway", "slot_id": "3", "body": "Your order #1042 has shipped", "purpose": "notification", "status": "queued", "error_code": "", "attempts": 0, "scheduled_at": "2026-09-27T09:22:55Z", "sent_at": null, "delivered_at": null, "read_at": null, "failed_at": null, "created_at": "2026-09-27T09:22:55Z" } ``` ### Send with a plain link `GET /v1/send?to=...&text=...&api_key=...` does exactly what `POST /v1/messages` does, for tools that can only open URLs. Encode `+` as `%2B`, spaces as `%20`, line breaks as `%0A`. ``` https://otprun.com/v1/send?to=%2B77010000000&text=Hello%20from%20OTPRun&api_key=or_live_YOUR_KEY ``` Opening the same link (same recipient and text) again within ten minutes returns the same message and does not send twice. Pass `idempotency_key` to control this yourself. ### Follow delivery `GET /v1/messages/{id}` (scope `messages` or `read`) returns the same object with the current status. | status | meaning | |---|---| | `queued` | waiting for its turn; `scheduled_at` says when it goes out | | `sending` | the WhatsApp number is typing and sending it | | `sent` | accepted by WhatsApp servers | | `delivered` | arrived on the recipient's phone | | `read` | the recipient opened it (only if they have read receipts on) | | `failed` | not sent; see `error_code` | Typical timing: `sent` within 1 to 10 seconds, `delivered` within a few seconds after that when the phone is online. A message can stay `sent` while the recipient's phone is offline. Instead of polling, subscribe to webhooks (section 6). ### Pacing and limits These protect the customer's WhatsApp numbers from bans and are applied automatically: - Messages to the same recipient are spaced by at least 15 seconds. Sending several in a row is fine: they are queued and `scheduled_at` shows when each goes out. - One recipient gets at most 20 messages per hour and 100 per day from a workspace; beyond that the API answers `429`. - Each number sends with small random pauses and a daily warm-up limit, so bulk sends are spread over time. - One API key can make 600 requests per minute. ### Check numbers before sending `POST /v1/numbers/check` (scope `messages` or `read`) with `{"numbers": ["+77010000000", "+77020000000"]}` (1 to 100 phones). Returns `{"results": [{"phone": "+77010000000", "exists": true, "checked_at": "..."}]}`. `exists` is `null` when the number has not been checked yet; the check runs in the background and the answer appears on the next call. Sending to a number known not to be on WhatsApp fails with `422`. ### Linked WhatsApp numbers `GET /v1/slots` lists the workspace's linked numbers with `id`, `label`, `phone`, `state` (`new`, `pairing`, `connected`, `disconnected`, `logged_out`, `banned`), `health` (0 to 100), `sent_today`, `daily_cap` and `warmup_day`. At least one number must be `connected` to send. Numbers are linked by the account owner in the dashboard (https://otprun.com/app/numbers) with a QR code or a pairing code entered in WhatsApp → Settings → Linked devices → Link a device. ## 3. One-time codes (Verify API) The Verify API generates the code, sends it in the recipient's language, falls back to another channel if the first one does not deliver in time, and checks the code the user enters. Codes are never stored in plain text. ### Create a verification `POST /v1/verifications` (scope `verify`) | field | type | notes | |---|---|---| | `to` | string | recipient phone; required for `outbound` | | `mode` | `outbound` or `inbound` | `outbound` (default): we send the code to the user. `inbound`: the user taps a link and sends the code to us from their own WhatsApp or opens the Telegram bot, which proves they own the number | | `channels` | array | order of channels to try: `whatsapp_gateway`, `telegram`, `telegram_gateway`, `whatsapp_official`. Omit it to use the project routing policy (by default WhatsApp first, Telegram after 8 seconds without delivery) | | `locale` | string | BCP 47 language of the message, for example `en`, `ru`, `kk`, `uz`, `ar`; defaults to the project language | | `code_length` | int | 4 to 8, default 6 | | `ttl_seconds` | int | 30 to 900, default 300 | | `max_attempts` | int | 1 to 10 wrong entries before the verification locks, default 5 | | `app_name` | string | name shown in the message text, defaults to the project sender name | ``` curl -X POST https://otprun.com/v1/verifications \ -H "Authorization: Bearer or_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"to":"+77010000000","locale":"en","app_name":"Acme"}' ``` Response `201 Created` with fields `id`, `status` (`pending`, `sent`, `delivered`, `verified`, `failed`, `expired`, `canceled`, `locked`), `channel_used`, `expires_at`, `attempts`, `max_attempts`, `to_phone`, `mode`, `inbound_link`, `inbound` (`{"wa_link": "...", "tg_link": "..."}` or `null`), timestamps. When the code goes through Telegram and the bot does not know the user's phone yet, `inbound.tg_link` holds a `https://t.me/OTPRun_bot?start=...` link. Show it to the user as a button: one tap opens the bot, shares the phone and delivers the code. ### Check the code `POST /v1/verifications/{id}/check` with `{"code": "123456"}`. ```json {"status": "verified", "attempts_left": 4} ``` `status` is one of `verified`, `wrong`, `expired`, `locked`, `canceled`. Only `verified` means success. ### Other verification calls - `GET /v1/verifications/{id}`: current state. - `POST /v1/verifications/{id}/cancel`: close an open verification. - Per phone: at most 5 verifications per hour and 20 per day (`429` beyond that). ## 4. Errors Errors use RFC 9457 problem JSON: ```json {"status": 422, "title": "Unprocessable Entity", "detail": "phone must be an international number in E.164 format, such as +77010000000"} ``` Validation errors may add `errors: [{"location": "body.to", "message": "...", "value": "..."}]`. | status | meaning and what to do | |---|---| | 400 | malformed JSON; fix the body | | 401 | API key missing, malformed or revoked; send `Authorization: Bearer or_live_...` | | 403 | key lacks the needed scope, or the IP is not in the key allowlist | | 404 | message or verification id does not belong to this project | | 409 | no connected WhatsApp number can take the message; link or reconnect a number in the dashboard, or use Telegram for verifications | | 422 | invalid field, invalid phone, or recipient not on WhatsApp; read `detail` | | 429 | limit reached; wait for `Retry-After` seconds | | 5xx | temporary; retry with the same `idempotency_key` | ## 5. Recipes Send and wait for delivery (Python): ```python import time, requests API = "https://otprun.com/v1" HEADERS = {"Authorization": "Bearer or_live_YOUR_KEY"} m = requests.post(f"{API}/messages", headers=HEADERS, json={"to": "+77010000000", "text": "Hello"}).json() for _ in range(30): m = requests.get(f"{API}/messages/{m['id']}", headers=HEADERS).json() if m["status"] in ("delivered", "read", "failed"): break time.sleep(2) print(m["status"], m["error_code"]) ``` Node.js: ```js const res = await fetch('https://otprun.com/v1/messages', { method: 'POST', headers: { Authorization: 'Bearer or_live_YOUR_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ to: '+77010000000', text: 'Hello' }), }) console.log(res.status, await res.json()) ``` PHP: ```php $ch = curl_init('https://otprun.com/v1/messages'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer or_live_YOUR_KEY', 'Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['to' => '+77010000000', 'text' => 'Hello']), ]); echo curl_exec($ch); ``` Send an order notification to many customers: call `POST /v1/messages` once per recipient with an `idempotency_key` such as `order--`. The gateway spreads the sends over time automatically; there is no batch endpoint to call. Log-in code with fallback: `POST /v1/verifications` with only `to`, show a code input, then `POST /v1/verifications/{id}/check`. If `inbound.tg_link` is present, also show a "Get the code in Telegram" button with that link. ## 6. Webhooks Configure webhook URLs in the dashboard (project → Webhooks). Each delivery is a `POST` with JSON: ```json {"id": "123", "event": "message.delivered", "created_at": "2026-09-27T09:22:58Z", "data": {"...": "message or verification object"}} ``` Events: `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.inbound` (someone wrote to a linked number), `verification.sent`, `verification.delivered`, `verification.verified`, `verification.failed`, `slot.connected`, `slot.disconnected`, `slot.paused`, `webhook.test`. Signature header: `X-OTPRun-Signature: t=,v1=` where `hex = HMAC-SHA256(secret, ".")`. Reject deliveries older than 5 minutes. Failed deliveries are retried with backoff. `POST /v1/webhooks/test` sends a `webhook.test` event to all subscriptions of the project. ## 7. Channels - `whatsapp_gateway`: the customer's own WhatsApp numbers linked to OTPRun. Flat price per number, unlimited messages, sender is a regular WhatsApp account. - `telegram`: the @OTPRun_bot bot (or the customer's own bot). Free. The user must open the bot once; the deep link in `inbound.tg_link` does that in one tap. - `telegram_gateway`: Telegram's official verification-code service, delivered by Telegram itself to the phone number. - `whatsapp_official`: the WhatsApp Business Platform with the customer's own templates (coming soon). ## 8. Links - Short overview: https://otprun.com/llms.txt - Interactive reference: https://otprun.com/docs - OpenAPI 3.1: https://otprun.com/openapi.json - Endpoint index: https://otprun.com/v1 - Dashboard: https://otprun.com/app - Pricing: https://otprun.com/en/pricing