REST · JSON · Signed webhooks
Phone Number API
The PhoneBorn Phone Number API is a REST API for buying real mobile numbers in 200 countries on 1, 3, 6 or 12-month plans and receiving their SMS in your own app — by polling or HMAC-signed webhooks. One API key, one crypto-funded wallet, JSON everywhere.
Overview
The PhoneBorn API lets you list countries and prices, buy phone-number plans (real mobile numbers you keep), read incoming SMS and buy single-use OTP numbers — from your own code. It is a small, predictable REST API with JSON bodies, API-key authentication and signed webhooks. Purchases are charged to the same prepaid wallet you top up with crypto, so there are no separate billing contracts.
This page covers phone-number plans. Outgoing SMS and calls are made from the dashboard; the API focuses on buying numbers and receiving messages. For single-use verification codes, see the OTP Verification 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" }Endpoint reference
Base URL: https://phoneborn.com/v1
| Method | Endpoint | Description |
|---|---|---|
| GET | /v1/balance | Current wallet balance |
| GET | /v1/countries | Countries with plan prices (1, 3, 6, 12 months; API key required), 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 1, 3, 6 or 12-month 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 |
| POST | /v1/numbers/{id}/replace | 7-day replacement guarantee: swap for a new number from the same country (free, up to 3 times) |
| POST | /v1/numbers/{id}/refund | Refund to your balance: full refund within 7 days if the number was never used |
List countries & prices
Returns every enabled country with its plan prices (plans: 1, 3, 6 and 12 months — the same in every country), the price teaser (from_per_month on from_plan), the minimum OTP price plus the number of numbers in stock. Cache it for a few minutes — prices rarely change. Plan prices are returned to authenticated API keys and are shown as "…" below — your dashboard shows the current values.
curl https://phoneborn.com/v1/countries -H "X-API-Key: $PHONEBORN_API_KEY"[
{
"iso": "GB", "name": "United Kingdom", "slug": "united-kingdom", "flag": "🇬🇧",
"dial_code": "+44", "region": "Europe", "available": 34,
"otp_from": "0.30",
"from_per_month": "1.80", "from_plan": "12 months", "from_plan_code": "yearly",
"plans": [
{ "code": "monthly", "months": 1, "days": 30, "label": "1 month", "price": "…", "per_month": "…", "saving_pct": "…" },
{ "code": "quarterly", "months": 3, "days": 90, "label": "3 months", "price": "…", "per_month": "…", "saving_pct": "…" },
{ "code": "semiannual", "months": 6, "days": 180, "label": "6 months", "price": "…", "per_month": "…", "saving_pct": "…" },
{ "code": "yearly", "months": 12, "days": 365, "label": "12 months", "price": "…", "per_month": "…", "saving_pct": "…" }
]
}
]For one country use GET /v1/prices?country=GB, which returns { "otp", "from_per_month", "from_plan", "from_plan_code" }.
Buy a phone number
POST /v1/numbers with a country ISO code and a plan code: monthly (1 month, 30 days), quarterly (3 months), semiannual (6 months) or yearly (12 months, 365 days). The plan's total price is charged to your wallet, the number is active immediately and it stays yours on every renewal. If your balance is too low you get 402; if the country is out of stock you get 409.
curl -X POST https://phoneborn.com/v1/numbers \
-H "X-API-Key: $PHONEBORN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"country": "GB", "plan": "monthly"}'{
"id": 912,
"number": { "e164": "+44XX123456XX", "display": "+44 XX 1234 56XX" },
"country": { "iso": "GB", "name": "United Kingdom", "flag": "🇬🇧" },
"plan": "monthly",
"price": "…",
"started_at": "2026-09-24T10:12:03Z",
"expires_at": "2026-10-24T10:12:03Z",
"auto_renew": true,
"status": "active",
"days_left": 30,
"label": null,
"sms_count": 0,
"renew_price": { "monthly": "…", "quarterly": "…", "semiannual": "…", "yearly": "…" }
}Check the auto_renew field in the response and manage renewals from the dashboard. List all your numbers with GET /v1/numbers.
Read incoming SMS
GET /v1/numbers/{id}/sms returns messages newest-last. Pass after_id with the highest id you have already seen to fetch only new messages. When we can detect a verification code, it is returned separately in code.
curl "https://phoneborn.com/v1/numbers/912/sms?after_id=0" -H "X-API-Key: $PHONEBORN_API_KEY"[
{
"id": 55012,
"sender": "Telegram",
"body": "Telegram code: 70582. Do not give this code to anyone.",
"code": "70582",
"received_at": "2026-09-24T10:14:41Z"
}
]Polling every 5–10 seconds is fine; for real-time delivery without polling, use webhooks.
Replace or refund a number
Every plan is covered by the 7-day replacement guarantee. If a number doesn't work for the app you need, call POST /v1/numbers/{id}/replace within 7 days of purchase. The rental keeps its id, plan, expiry and allowance usage; only the phone number changes to another one from the same country. Up to 3 free replacements per plan. You get 409 if the number is no longer eligible or no other number from that country is in stock.
curl -X POST https://phoneborn.com/v1/numbers/912/replace \
-H "X-API-Key: $PHONEBORN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "app_rejected"}'reason is optional: not_working, app_rejected, no_sms or other. The response is the updated rental with its new number. The old number is retired from your account immediately.
Never used the number? POST /v1/numbers/{id}/refund returns the full price to your wallet within 7 days if the number has not sent or received any SMS or call, and releases it. Both calls are covered in the replacement & refund policy.
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 "", 200Complete examples
const API = "https://phoneborn.com/v1";
const headers = { "X-API-Key": process.env.PHONEBORN_API_KEY, "Content-Type": "application/json" };
// 1. Buy a UK number on a monthly plan
const rental = await fetch(`${API}/numbers`, {
method: "POST", headers, body: JSON.stringify({ country: "GB", plan: "monthly" }),
}).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); });
console.log("Your number:", rental.number.e164);
// 2. Poll for new messages (or use webhooks — recommended)
let lastId = 0;
setInterval(async () => {
const sms = await fetch(`${API}/numbers/${rental.id}/sms?after_id=${lastId}`, { headers }).then((r) => r.json());
for (const m of sms) {
lastId = Math.max(lastId, m.id);
console.log(`[${m.sender}] ${m.body}`, m.code ? `→ code ${m.code}` : "");
}
}, 5000);import os, time, requests
API = "https://phoneborn.com/v1"
H = {"X-API-Key": os.environ["PHONEBORN_API_KEY"]}
rental = requests.post(f"{API}/numbers", json={"country": "GB", "plan": "yearly"}, headers=H)
rental.raise_for_status()
rental = rental.json()
print("Your number:", rental["number"]["e164"], "expires", rental["expires_at"])
last_id = 0
while True:
for m in requests.get(f"{API}/numbers/{rental['id']}/sms", params={"after_id": last_id}, headers=H).json():
last_id = max(last_id, m["id"])
print(m["sender"], m["body"], m.get("code"))
time.sleep(5)Errors
Errors use standard HTTP status codes with a JSON body: {"detail": "Insufficient balance"}.
| 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. |
Limits & best practices
- Keep API keys server-side only. Rotate them from the dashboard if one is exposed — revocation is immediate.
- Handle
429with exponential back-off. Poll no more often than every few seconds per number. - Purchases are not idempotent: if a
POSTtimes out, list your numbers before retrying to avoid buying twice. - Monitor your balance with
GET /v1/balanceso auto-renewals never fail. - Use of the API is subject to our Terms of Service, including the acceptable-use rules.
Build on PhoneBorn.
Create an account, generate a key and make your first call in minutes.