OTP Route API reference
A small, predictable JSON API for routing OTP activations, capturing codes with signed webhooks and managing dedicated numbers. Base URL: https://otproute.com/api/v1
Authentication
Create API keys in the dashboard under Developer → API keys. Keys start with or_live_ and are shown once. Send the key as a Bearer token (or in an X-API-Key header). Keep keys server-side.
curl https://otproute.com/api/v1/balance \
-H "Authorization: Bearer or_live_xxxxxxxxxxxxxxxxxxxx"
Errors
Errors return a non-2xx status and a JSON body with a stable code and a human-readable message.
{
"error": { "code": "insufficient_balance", "message": "Insufficient balance: this activation costs $0.20" },
"detail": "Insufficient balance: this activation costs $0.20"
}
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request | Missing or invalid parameters. |
| 401 | unauthorized | Missing, revoked or invalid API key. |
| 402 | insufficient_balance | Wallet balance is lower than the price. Top up and retry. |
| 404 | not_found | Unknown resource, or the service is not offered in that country right now. |
| 409 | conflict | No numbers available, price moved above your max_price, or the action is not allowed in the current status (e.g. cancel during cooldown). |
| 423 | account_locked | The account is locked. Contact support. |
| 429 | rate_limited | Too many requests. Honour the Retry-After header. |
| 5xx | error | Upstream or internal error. Safe to retry reads; for creates, list recent activations before retrying. |
Rate limits
Every account is pay as you go and gets the same limits, applied per account across all keys: 300 requests per minute, up to 10 API keys and up to 5 webhook endpoints. Activation creation is additionally protected by velocity controls. Prefer webhooks over tight polling loops — a poll every 5 seconds per open activation is plenty.
Sandbox
Sandbox accounts return simulated phone numbers (in the fictional 555 range) and deliver a simulated code after roughly 15 seconds. Wallet debits and refunds behave exactly like production, so you can test billing logic end to end. Responses include "sandbox": true. Ask support to enable sandbox on a development account.
Balance
GET/v1/balance
{ "object": "balance", "balance": …, "balance_cents": …, "currency": "USD", "formatted": "…" }Services
GET/v1/services
All services (apps/sites) you can receive codes for. Use the id when ordering.
{ "object": "list", "data": [ { "id": "telegram", "name": "Telegram" }, { "id": "google", "name": "Google" } ] }Countries
GET/v1/countries
{ "object": "list", "data": [ { "id": "us", "name": "United States", "dial": "+1" } ] }Prices
GET/v1/prices?country={country}[&service={service}]
Live price per code and current availability for a service and country. Codes start from $0.20; the dashboard shows the same live prices. Without service returns every service offered in the country.
{ "object": "price", "service": "telegram", "country": "us", "price": 0.2, "price_cents": 20, "currency": "USD", "available": 118 }Activations
An activation rents a temporary verification number for one service and waits for the code. You only pay if the code arrives: the price is held from your wallet when you create it, and if no SMS arrives before expires_at (about 20 minutes) or you cancel, the full price is refunded to your wallet automatically.
| status | Meaning | Billing |
|---|---|---|
pending | Number issued, waiting for the SMS. | Charged (held) |
received | Code arrived — see code and sms_text. | Final |
completed | You marked the activation done. | Final |
expired | Window closed without an SMS. | Refunded |
cancelled | Cancelled by you before a code arrived. | Refunded |
failed | The route could not issue a number. | Refunded |
{
"id": "act_3f9c1e0b7a2d4c5e8f10",
"object": "activation",
"status": "received",
"service": "telegram", "service_name": "Telegram",
"country": "us", "country_name": "United States",
"phone_number": "+15550100123", "number": "+15550100123",
"code": "482913",
"sms_text": "Telegram code: 482913",
"price": 0.2, "price_cents": 20,
"sandbox": false, "source": "api",
"created_at": 1793338200, "expires_at": 1793339400,
"received_at": 1793338241, "cancelable_after": 1793338320
}
POST/v1/activations
Body: service (required), country (required), max_price (optional, USD — reject if the live price is higher). Returns 201 with the activation.
curl -X POST https://otproute.com/api/v1/activations \
-H "Authorization: Bearer $OTPROUTE_KEY" \
-H "Content-Type: application/json" \
-d '{"service":"telegram","country":"us","max_price":0.50}'import os, time, requests
API = "https://otproute.com/api/v1"
H = {"Authorization": f"Bearer {os.environ['OTPROUTE_KEY']}"}
act = requests.post(f"{API}/activations", headers=H,
json={"service": "telegram", "country": "us"}).json()
while act["status"] == "pending":
time.sleep(5)
act = requests.get(f"{API}/activations/{act['id']}", headers=H).json()
print(act["status"], act.get("code"))const API = "https://otproute.com/api/v1";
const H = { Authorization: `Bearer ${process.env.OTPROUTE_KEY}`, "Content-Type": "application/json" };
let act = await (await fetch(`${API}/activations`, { method: "POST", headers: H,
body: JSON.stringify({ service: "telegram", country: "us" }) })).json();
while (act.status === "pending") {
await new Promise(r => setTimeout(r, 5000));
act = await (await fetch(`${API}/activations/${act.id}`, { headers: H })).json();
}
console.log(act.status, act.code);GET/v1/activations/{id}
Returns the activation object.
GET/v1/activations?status=&limit=
Most recent first. limit 1–200 (default 50). Filter by any status.
POST/v1/activations/{id}/cancel
Cancels a pending activation and refunds it. Routes enforce a short cooldown after issue — before cancelable_after you'll get 409. If a code arrived in the meantime, the activation becomes received instead and is not refunded.
POST/v1/activations/{id}/complete
Marks a received activation as completed and releases the number back to the route.
Dedicated numbers
Dedicated US/CA numbers start from $1.95/month and are rented and renewed from the dashboard, paid from your wallet. The API exposes them for messaging automation; renew_price is the USD amount of the next renewal.
GET/v1/numbers
{ "object": "list", "data": [ { "id": "num_8a1f…", "object": "number", "number": "+14155550142", "phone_number": "+14155550142",
"country": "US", "status": "active", "term": "month12", "renew_price": …, "renews_at": 1801114200, "auto_renew": true } ] }GET/v1/numbers/{id}/messages
Latest 100 inbound and outbound messages for the number.
POST/v1/numbers/{id}/messages
Body: to (E.164), text. Billed per 153-character segment from your wallet at the rate shown in the dashboard; failed sends are refunded.
{ "id": "msg_51c…", "object": "message", "status": "sent", "to": "+14155550199", "text": "Your ticket #4821 is resolved.", "price": … }Webhooks
Register HTTPS endpoints under Developer → Webhooks, choose events (or all), and use Send test to verify your handler. Every event has the same envelope:
{
"id": "evt_9b7d0c…",
"type": "activation.sms_received",
"created": 1793338241,
"data": { …activation object… }
}
| Event | data |
|---|---|
activation.sms_received | Activation object with code and sms_text |
activation.expired | Activation object (refunded) |
activation.cancelled | Activation object (refunded) |
number.sms_received | {id, number_id, number, to, from, text, received_at} |
number.renewed / number.released | Number object |
Verifying signatures
Each request carries X-OTPRoute-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" keyed with the endpoint's signing secret (whsec_…). Compare in constant time and reject timestamps older than 5 minutes. Also sent: X-OTPRoute-Event and X-OTPRoute-Delivery (unique — use it to de-duplicate).
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = int(parts["t"]), parts["v1"]
if abs(time.time() - ts) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)import crypto from "node:crypto";
export function verify(rawBody, header, secret, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const ts = Number(parts.t);
if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${ts}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Delivery & retries
Respond with any 2xx within 10 seconds. Failed deliveries are retried with backoff (30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 6 h). Redirects are not followed. Endpoints must resolve to public IP addresses. The delivery log in the dashboard shows status codes and the last error for every attempt.
Next: the full integration walkthrough
Error handling, idempotency and production checklists.