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:
POST /v1/otp— the price is charged and the order starts in statuswaitingwith a countdown (seconds_left, 20 minutes by default).- The code arrives — status becomes
receivedandcode/sms_bodyare filled. The number is released. - No code? Cancel with
POST /v1/otp/{id}/cancelfor an immediate refund, or let it expire — statusexpired,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"}.
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:
curl "https://phoneborn.com/v1/prices?country=GB&service=whatsapp" -H "X-API-Key: $PHONEBORN_API_KEY"{ "monthly": "5.49", "yearly": "27.45", "otp": "0.42", "yearly_saving_pct": 58 }Create an order
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"}'{
"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.
curl https://phoneborn.com/v1/otp/1842 -H "X-API-Key: $PHONEBORN_API_KEY"{
"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.
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.
{
"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.
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);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 "", 200End-to-end examples
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"))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
| Method | Endpoint | Description |
|---|---|---|
| GET | /v1/balance | Current wallet balance |
| GET | /v1/countries | Countries with monthly, yearly and OTP prices and live stock |
| GET | /v1/services | Supported services with lowest OTP price |
| GET | /v1/prices?country=&service= | Exact prices for a country (and optional service) |
| POST | /v1/otp | Buy 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}/cancel | Cancel before a code arrives (full refund) |
| POST | /v1/numbers | Buy a phone number on a monthly or yearly plan |
| GET | /v1/numbers | List your phone numbers and their plans |
| GET | /v1/numbers/{id}/sms | Read SMS received on one of your phone numbers |
Errors
All errors return JSON: {"detail": "message"}.
| Status | Meaning | When it happens |
|---|---|---|
| 400 | Bad request | Malformed JSON or an unknown country / service / plan. |
| 401 | Unauthorized | Missing, invalid or revoked X-API-Key. |
| 402 | Payment required | Insufficient wallet balance — top up and retry. |
| 404 | Not found | The order, number or resource does not exist or is not yours. |
| 409 | Conflict | Action not allowed in the current state (e.g. cancelling after a code arrived) or no stock for that country. |
| 422 | Validation error | A field is missing or has the wrong type. |
| 429 | Too many requests | Rate limit exceeded — back off and retry with exponential delay. |
| 5xx | Server error | Temporary 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.