Pay per code · Auto-refund · Webhooks

OTP Verification API

The PhoneBorn OTP Verification API sells single-use numbers for one verification code: create an order for a service and country, get the code back as JSON or by signed webhook, and cancel for an instant refund if no code arrives. Built for QA automation, resellers and verification workflows.

How OTP orders work

An OTP order gives you a fresh, single-use number for one service in one country. You enter the number in the target platform, the verification SMS arrives, and we return the extracted code. The lifecycle is simple:

  1. POST /v1/otp — the price is charged and the order starts in status waiting with a countdown (seconds_left, 20 minutes by default).
  2. The code arrives — status becomes received and code / sms_body are filled. The number is released.
  3. No code? Cancel with POST /v1/otp/{id}/cancel for an immediate refund, or let it expire — status expired, refunded: true.

You never pay for a code that did not arrive. For real mobile numbers you keep and re-use, see the Phone Number API.

Authentication

All requests go to https://phoneborn.com/v1 over HTTPS and are authenticated with an API key in the X-API-Key header. Create and revoke keys in Dashboard → API; each key is shown once, so store it in a secret manager. Request and response bodies are JSON, money values are USD strings with two decimals (e.g. "4.99"), and timestamps are ISO 8601 in UTC. Errors return a non-2xx status with {"detail": "message"}.

Check your balance
curl https://phoneborn.com/v1/balance \
  -H "X-API-Key: pb_live_xxxxxxxxxxxxxxxx"

# 200 OK
{ "balance": "25.00" }

Services & prices

List supported services with GET /v1/services (each has a slug such as whatsapp, telegram or google). Get the exact price for a service in a country before buying:

Request
curl "https://phoneborn.com/v1/prices?country=GB&service=whatsapp" -H "X-API-Key: $PHONEBORN_API_KEY"
200 OK
{ "monthly": "5.49", "yearly": "27.45", "otp": "0.42", "yearly_saving_pct": 58 }

Create an order

Request
curl -X POST https://phoneborn.com/v1/otp \
  -H "X-API-Key: $PHONEBORN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"country": "GB", "service": "whatsapp"}'
200 OK — OtpOrder
{
  "id": 1842,
  "number": { "e164": "+44XX123456XX", "display": "+44 XX 1234 56XX" },
  "country": { "iso": "GB", "name": "United Kingdom", "flag": "🇬🇧" },
  "service": { "slug": "whatsapp", "name": "WhatsApp" },
  "price": "0.42",
  "status": "waiting",
  "code": null,
  "sms_body": null,
  "created_at": "2026-09-24T10:20:00Z",
  "expires_at": "2026-09-24T10:40:00Z",
  "seconds_left": 1200,
  "refunded": false
}

402 means your wallet balance is too low; 409 means no number is currently available for that country and service — try another country.

Get the code

Poll GET /v1/otp/{id} every 3–5 seconds until status changes, or skip polling entirely and use a webhook.

Request
curl https://phoneborn.com/v1/otp/1842 -H "X-API-Key: $PHONEBORN_API_KEY"
200 OK — code received
{
  "id": 1842,
  "status": "received",
  "code": "482913",
  "sms_body": "Your WhatsApp code is 482-913. Do not share it.",
  "seconds_left": 1164,
  "refunded": false,
  "...": "other fields as above"
}

Possible statuses: waiting, received, cancelled (refunded), expired (refunded).

Cancel & refunds

Cancel any order that is still waiting. The full price is returned to your wallet immediately and the order is returned with refunded: true. Cancelling after a code arrived returns 409.

Request
curl -X POST https://phoneborn.com/v1/otp/1842/cancel -H "X-API-Key: $PHONEBORN_API_KEY"

Webhooks & signatures

Set a webhook URL in Dashboard → API and we will POST an event the moment an SMS arrives on any of your numbers — phone-number plan or OTP. OTP events include order_id; phone-number events include rental_id.

sms.received payload
{
  "event": "sms.received",
  "number": "+44XX123456XX",
  "sender": "WhatsApp",
  "body": "Your WhatsApp code is 482-913. Do not share it.",
  "code": "482913",
  "order_id": 1842
}

Every request carries an X-PhoneBorn-Signature header: sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your webhook secret. Always verify it with a constant-time comparison before trusting the payload, and respond with a 2xx within a few seconds.

Node.js (Express) — verify signature
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.PHONEBORN_WEBHOOK_SECRET;

// Use the RAW body — re-serialised JSON will not match the signature.
app.post("/webhooks/phoneborn", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-PhoneBorn-Signature") || "";
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");

  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send("invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.event === "sms.received") {
    console.log(`Code ${event.code} from ${event.sender} on ${event.number}`);
  }
  res.sendStatus(200); // respond quickly; do heavy work async
});

app.listen(3000);
Python (Flask) — verify signature
import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["PHONEBORN_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/phoneborn")
def phoneborn_webhook():
    raw = request.get_data()  # raw bytes, exactly as sent
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-PhoneBorn-Signature", "")
    if not hmac.compare_digest(received, expected):
        abort(401)

    event = request.get_json()
    if event["event"] == "sms.received":
        print(f"Code {event['code']} from {event['sender']} on {event['number']}")
    return "", 200

End-to-end examples

Python 3 — get a WhatsApp code
import os, time, requests

API = "https://phoneborn.com/v1"
H = {"X-API-Key": os.environ["PHONEBORN_API_KEY"]}

def get_code(service: str, country: str, timeout: int = 300) -> str | None:
    r = requests.post(f"{API}/otp", json={"country": country, "service": service}, headers=H)
    r.raise_for_status()
    order = r.json()
    print("Use this number:", order["number"]["e164"])

    deadline = time.time() + min(timeout, order["seconds_left"])
    while time.time() < deadline:
        order = requests.get(f"{API}/otp/{order['id']}", headers=H).json()
        if order["status"] == "received":
            return order["code"]
        if order["status"] in ("cancelled", "expired"):
            return None  # already refunded
        time.sleep(4)

    # Give up early: cancelling before a code arrives refunds the wallet
    requests.post(f"{API}/otp/{order['id']}/cancel", headers=H)
    return None

print(get_code("whatsapp", "GB"))
Node.js 18+ — get a code
const API = "https://phoneborn.com/v1";
const headers = { "X-API-Key": process.env.PHONEBORN_API_KEY, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function getCode(service, country, timeoutMs = 300_000) {
  const res = await fetch(`${API}/otp`, { method: "POST", headers, body: JSON.stringify({ country, service }) });
  if (res.status === 402) throw new Error("Top up your wallet");
  if (!res.ok) throw new Error((await res.json()).detail);
  let order = await res.json();
  console.log("Use this number:", order.number.e164);

  const deadline = Date.now() + Math.min(timeoutMs, order.seconds_left * 1000);
  while (Date.now() < deadline) {
    order = await fetch(`${API}/otp/${order.id}`, { headers }).then((r) => r.json());
    if (order.status === "received") return order.code;
    if (order.status === "cancelled" || order.status === "expired") return null;
    await sleep(4000);
  }
  await fetch(`${API}/otp/${order.id}/cancel`, { method: "POST", headers }); // full refund
  return null;
}

Endpoint reference

Base URL: https://phoneborn.com/v1

PhoneBorn API endpoints
MethodEndpointDescription
GET/v1/balanceCurrent wallet balance
GET/v1/countriesCountries with monthly, yearly and OTP prices and live stock
GET/v1/servicesSupported services with lowest OTP price
GET/v1/prices?country=&service=Exact prices for a country (and optional service)
POST/v1/otpBuy a single-use OTP number for a service
GET/v1/otp/{id}Poll an OTP order — status, code and SMS text
POST/v1/otp/{id}/cancelCancel before a code arrives (full refund)
POST/v1/numbersBuy a phone number on a monthly or yearly plan
GET/v1/numbersList your phone numbers and their plans
GET/v1/numbers/{id}/smsRead SMS received on one of your phone numbers

Errors

All errors return JSON: {"detail": "message"}.

API error codes
StatusMeaningWhen it happens
400Bad requestMalformed JSON or an unknown country / service / plan.
401UnauthorizedMissing, invalid or revoked X-API-Key.
402Payment requiredInsufficient wallet balance — top up and retry.
404Not foundThe order, number or resource does not exist or is not yours.
409ConflictAction not allowed in the current state (e.g. cancelling after a code arrived) or no stock for that country.
422Validation errorA field is missing or has the wrong type.
429Too many requestsRate limit exceeded — back off and retry with exponential delay.
5xxServer errorTemporary problem on our side or at the provider. Safe to retry reads; check order status before retrying purchases.

Best practices

  • Enter the number in the target platform as soon as the order is created — the countdown starts immediately.
  • Request the code from the platform only once; repeated requests trigger their rate limits, not ours.
  • If a platform rejects a number, cancel the order (refund) and retry with a different country.
  • Prefer webhooks at scale; if polling, keep it to one request every few seconds per order.
  • OTP numbers receive one code. If you need to re-verify later, buy a phone-number plan instead.

Automate verification today.

Top up once with crypto and call the API — codes from 110+ services, refunds handled for you.