Skip to content

No code, no charge. Every route is measured, and failing routes are removed.

How we measure

FastOTP APIdocumentationv1

Base URL https://fastotp.io/api/v1

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:create and orders: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.

ShellRequest
export FASTOTP_KEY="sk_live_..."

curl https://fastotp.io/api/v1/account \
  -H "Authorization: Bearer $FASTOTP_KEY"
JSONExample response ยท 200 OK
{ "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.

ShellRequest
curl "https://fastotp.io/api/v1/routes?service=whatsapp&country=indonesia" \
  -H "Authorization: Bearer $FASTOTP_KEY"
JSONExample response ยท 200 OK
{
  "routes": [
    {
      "service": "whatsapp",
      "country": "indonesia",
      "price": "0.13",
      "status": "on_sale",
      "delivery": { "rate": 94.2, "orders": 1340, "window_days": 30 }
    }
  ],
  "next": null
}
Fields of a route
FieldTypeDescription
servicestringService slug. Use it as-is when you create an order.
countrystringCountry slug. Use it as-is when you create an order.
pricestring | nullLive price of one order in US dollars. null only when the route is not on sale and there is no price to quote.
statusstringon_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.
deliveryobject | nullrate 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.
Paging
FieldTypeDescription
limitquery, integerRoutes per page, 1 to 1000. Default 1000.
cursorquery, stringThe next value of the previous page. Leave it out for the first page.
nextstring | nullIn 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_price is the most you agree to pay. If the live price is higher, the call fails with price_changed and nothing is charged.
  • The Idempotency-Key header 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.
ShellRequest
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"}'
JSONExample response ยท 201 Created
{
  "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.

ShellRequest
curl https://fastotp.io/api/v1/orders/ord_9f2k1x \
  -H "Authorization: Bearer $FASTOTP_KEY"
JSONExample response ยท 200 OK
{
  "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.

ShellRequest
curl -X DELETE https://fastotp.io/api/v1/orders/ord_9f2k1x \
  -H "Authorization: Bearer $FASTOTP_KEY"
JSONExample response ยท 200 OK
{
  "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.

Node.jsverify.mjs
// 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).

Pythonverify.py
"""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.")

Where to go next