API documentation ยท 1 of 4
Quickstart: your first verification code in four requests
Four requests take you from an API key to an SMS verification code: check the key, list routes, create an order, and poll the order until the code arrives.
On this page
Before you start
- An account with balance. Orders are paid from your balance, at the same price the website shows. Create an account and see pricing per route.
- An API key. You create it in the account area. The full key, which starts with
sk_live_, is shown once and stored only as a hash, so copy it straight into your secret store. - A key with all four scopes for this walkthrough:
catalog:read,orders:read,orders:createandorders:cancel.
Every request goes to https://fastotp.io/api/v1 with the header Authorization: Bearer sk_live_โฆ. Requests and responses are JSON in UTF-8. Timestamps are ISO 8601 in UTC. Money is a decimal string in US dollars, such as "0.13".
1. Check your key
GET/accountany valid key
Reading your balance is the quickest way to confirm that the key works and that there is money to spend.
export FASTOTP_KEY="sk_live_..."
curl https://fastotp.io/api/v1/account \
-H "Authorization: Bearer $FASTOTP_KEY"{ "balance": "12.35" }A 401 with the code unauthorized means the header or the key is wrong. Every error has a name like that one; they are all listed in the error reference.
2. List routes
GET/routesscope catalog:read
A route is one service in one country. This call returns each route with its live price, whether it is on sale, and its measured delivery rate. Both filters, service and country, are optional; leave them out to get the whole catalogue, up to 1,000 routes at a time: when next is not null, ask again with cursor set to it. A filter that matches nothing returns an empty list.
curl "https://fastotp.io/api/v1/routes?service=whatsapp&country=indonesia" \
-H "Authorization: Bearer $FASTOTP_KEY"{
"routes": [
{
"service": "whatsapp",
"country": "indonesia",
"price": "0.13",
"status": "on_sale",
"delivery": { "rate": 94.2, "orders": 1340, "window_days": 30 }
}
],
"next": null
}| Field | Type | Description |
|---|---|---|
| service | string | Service slug. Use it as-is when you create an order. |
| country | string | Country slug. Use it as-is when you create an order. |
| price | string | null | Live price of one order in US dollars. null only when the route is not on sale and there is no price to quote. |
| status | string | on_sale: can be ordered. suspended: taken off sale because its delivery rate stayed below 80% (see how delivery performance is measured). unavailable: no stock right now. |
| delivery | object | null | rate is the percentage of issued numbers that received a code, orders is how many numbers that figure is based on, and window_days is the period measured. It is null until the route has enough orders to be rated: at least 20 in the last 30 days. |
| Field | Type | Description |
|---|---|---|
| limit | query, integer | Routes per page, 1 to 1000. Default 1000. |
| cursor | query, string | The next value of the previous page. Leave it out for the first page. |
| next | string | null | In the response: pass it as cursor to get the following page. null on the last page. |
3. Create an order
POST/ordersscope orders:create
This buys a number on one route. Your balance is charged when the order is created, and the response is the order with the phone number in it. Three details make the call safe to automate:
max_priceis the most you agree to pay. If the live price is higher, the call fails withprice_changedand nothing is charged.- The
Idempotency-Keyheader makes a retry harmless. Repeat the request with the same key within 24 hours and you get the original order back, not a second one. - A client timeout of at least 30 seconds gives the call room to fetch a number before you conclude that it failed.
curl -X POST https://fastotp.io/api/v1/orders \
--max-time 30 \
-H "Authorization: Bearer $FASTOTP_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"service": "whatsapp", "country": "indonesia", "max_price": "0.13"}'{
"id": "ord_9f2k1x",
"status": "active",
"service": "whatsapp",
"country": "indonesia",
"phone": "+6283150707440",
"price": "0.13",
"code": null,
"created_at": "2026-10-12T08:55:30Z",
"expires_at": "2026-10-12T09:15:30Z",
"seconds_to_code": null
}Give phone to the service you are verifying with, then wait for its SMS.
4. Poll for the code
GET/orders/{id}scope orders:read
Fetch the order every 3 to 5 seconds while its status is active. When the status becomes completed, code holds the verification code.
curl https://fastotp.io/api/v1/orders/ord_9f2k1x \
-H "Authorization: Bearer $FASTOTP_KEY"{
"id": "ord_9f2k1x",
"status": "completed",
"service": "whatsapp",
"country": "indonesia",
"phone": "+6283150707440",
"price": "0.13",
"code": "482913",
"created_at": "2026-10-12T08:55:30Z",
"expires_at": "2026-10-12T09:15:30Z",
"seconds_to_code": 11
}Cancel if you stop waiting
DELETE/orders/{id}scope orders:cancel
Cancelling refunds the order straight away, so you need not wait for it to expire. It is allowed from 2 minutes after the order was created, and only while no code has arrived. Earlier than that the call fails with cancel_too_early and tells you how many seconds to wait.
curl -X DELETE https://fastotp.io/api/v1/orders/ord_9f2k1x \
-H "Authorization: Bearer $FASTOTP_KEY"{
"id": "ord_9f2k1x",
"status": "cancelled",
"refunded": "0.13"
}Complete script: Node.js
The whole flow in one file, using the fetch built into Node.js. It checks the route, buys a number at no more than the listed price, polls every four seconds, and cancels for a refund if no code has arrived after five minutes.
// verify.mjs: needs Node.js 18 or later, no packages.
// Run it with: FASTOTP_KEY=sk_live_... node verify.mjs
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
const BASE = "https://fastotp.io/api/v1";
const KEY = process.env.FASTOTP_KEY;
const POLL_EVERY_MS = 4_000; // poll every 3 to 5 seconds
const GIVE_UP_AFTER_MS = 5 * 60_000; // keep above 2 minutes: no cancelling before that
if (!KEY) throw new Error("Set FASTOTP_KEY first.");
class ApiError extends Error {
constructor(res, error) {
super(`${res.status} ${error.code}: ${error.message}`);
this.status = res.status;
this.code = error.code;
this.body = error; // extra fields live here: price, retry_after, fields
this.retryAfter = Number(res.headers.get("retry-after")) || null; // seconds
}
}
async function api(method, path, { body, headers, timeoutMs = 10_000 } = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
...(body && { "Content-Type": "application/json" }),
...headers,
},
body: body && JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(res, data.error ?? { code: "unknown", message: res.statusText });
return data;
}
// One Idempotency-Key per purchase, reused on every retry, so a retry after
// a timeout returns the first order instead of buying a second number.
async function createOrder(service, country, maxPrice) {
const idempotencyKey = randomUUID();
for (let attempt = 1; ; attempt++) {
try {
return await api("POST", "/orders", {
body: { service, country, max_price: maxPrice },
headers: { "Idempotency-Key": idempotencyKey },
timeoutMs: 30_000,
});
} catch (err) {
// Timeouts, network errors, 429 and 5xx are worth another try. Other errors are final.
const final = err instanceof ApiError && err.status !== 429 && err.status < 500;
if (final || attempt === 4) throw err;
await sleep((err.retryAfter ?? 2 ** attempt) * 1000);
}
}
}
// Poll while the order is active, until the code arrives or we give up.
async function waitForCode(order) {
const giveUpAt = Date.now() + GIVE_UP_AFTER_MS;
while (order.status === "active" && Date.now() < giveUpAt) {
await sleep(POLL_EVERY_MS);
try {
order = await api("GET", `/orders/${order.id}`);
} catch (err) {
if (!(err instanceof ApiError)) continue; // timeout or network error: poll again
if (err.status === 429) await sleep((err.retryAfter ?? 5) * 1000);
else if (err.status < 500) throw err; // a 4xx will not fix itself
}
}
return order;
}
// Giving up: cancel, so the price comes back now and not when the order expires.
async function cancel(order) {
for (;;) {
try {
return await api("DELETE", `/orders/${order.id}`);
} catch (err) {
if (err.code === "cancel_too_early") {
await sleep(err.body.retry_after * 1000);
} else if (err.code === "not_cancellable") {
// It ended on its own. The code may have arrived this very second.
return api("GET", `/orders/${order.id}`);
} else {
throw err; // left alone, the order still expires and is refunded
}
}
}
}
const { routes } = await api("GET", "/routes?service=whatsapp&country=indonesia");
const route = routes[0];
if (route?.status !== "on_sale") throw new Error("That route is not on sale right now.");
let order = await createOrder(route.service, route.country, route.price);
console.log(`Number: ${order.phone}. Enter it in the app; waiting for the SMS.`);
order = await waitForCode(order);
if (order.status === "active") order = await cancel(order);
if (order.status === "completed") {
console.log(`Code: ${order.code} (arrived in ${order.seconds_to_code} s)`);
} else {
console.log(`No code. Order ${order.status}, refunded in full.`);
}Complete script: Python
The same flow with the requests package (pip install requests).
"""verify.py: needs Python 3.8 or later and the requests package.
Run it with: FASTOTP_KEY=sk_live_... python verify.py
"""
import os
import time
import uuid
import requests
BASE = "https://fastotp.io/api/v1"
POLL_EVERY = 4 # seconds; poll every 3 to 5 seconds
GIVE_UP_AFTER = 5 * 60 # seconds; keep above 120: no cancelling before that
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FASTOTP_KEY']}"
class ApiError(Exception):
def __init__(self, response):
try:
error = response.json()["error"]
except (ValueError, KeyError, TypeError):
error = {"code": "unknown", "message": response.reason}
super().__init__(f"{response.status_code} {error['code']}: {error['message']}")
self.status = response.status_code
self.code = error["code"]
self.body = error # extra fields live here: price, retry_after, fields
self.retry_after = int(response.headers.get("Retry-After", 0)) or None # seconds
def api(method, path, timeout=10, **kwargs):
response = session.request(method, BASE + path, timeout=timeout, **kwargs)
if not response.ok:
raise ApiError(response)
return response.json()
def create_order(service, country, max_price):
# One Idempotency-Key per purchase, reused on every retry, so a retry after
# a timeout returns the first order instead of buying a second number.
headers = {"Idempotency-Key": str(uuid.uuid4())}
body = {"service": service, "country": country, "max_price": max_price}
attempt = 1
while True:
try:
return api("POST", "/orders", json=body, headers=headers, timeout=30)
except ApiError as err:
# 429 and 5xx are worth another try. Other errors are final.
if attempt == 4 or not (err.status == 429 or err.status >= 500):
raise
time.sleep(err.retry_after or 2**attempt)
except requests.RequestException: # timeout or network error
if attempt == 4:
raise
time.sleep(2**attempt)
attempt += 1
def wait_for_code(order):
# Poll while the order is active, until the code arrives or we give up.
give_up_at = time.monotonic() + GIVE_UP_AFTER
while order["status"] == "active" and time.monotonic() < give_up_at:
time.sleep(POLL_EVERY)
try:
order = api("GET", f"/orders/{order['id']}")
except ApiError as err:
if err.status == 429:
time.sleep(err.retry_after or 5)
elif err.status < 500:
raise # a 4xx will not fix itself
except requests.RequestException:
pass # timeout or network error: poll again
return order
def cancel(order):
# Giving up: cancel, so the price comes back now and not when the order expires.
while True:
try:
return api("DELETE", f"/orders/{order['id']}")
except ApiError as err:
if err.code == "cancel_too_early":
time.sleep(err.body["retry_after"])
elif err.code == "not_cancellable":
# It ended on its own. The code may have arrived this very second.
return api("GET", f"/orders/{order['id']}")
else:
raise # left alone, the order still expires and is refunded
routes = api("GET", "/routes", params={"service": "whatsapp", "country": "indonesia"})["routes"]
if not routes or routes[0]["status"] != "on_sale":
raise SystemExit("That route is not on sale right now.")
route = routes[0]
order = create_order(route["service"], route["country"], route["price"])
print(f"Number: {order['phone']}. Enter it in the app; waiting for the SMS.")
order = wait_for_code(order)
if order["status"] == "active":
order = cancel(order)
if order["status"] == "completed":
print(f"Code: {order['code']} (arrived in {order['seconds_to_code']} s)")
else:
print(f"No code. Order {order['status']}, refunded in full.")