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 monthly or yearly 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"}.

Check your balance
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

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

List countries & prices

Returns every enabled country with the current monthly, yearly and minimum OTP price, plus the number of numbers in stock. Cache it for a few minutes — prices rarely change.

Request
curl https://phoneborn.com/v1/countries -H "X-API-Key: $PHONEBORN_API_KEY"
200 OK
[
  {
    "iso": "GB", "name": "United Kingdom", "slug": "united-kingdom", "flag": "🇬🇧",
    "dial_code": "+44", "region": "Europe", "available": 34,
    "monthly": "5.49", "yearly": "27.45", "otp_from": "0.30"
  }
]

For one country use GET /v1/prices?country=GB, which returns { "monthly", "yearly", "otp", "yearly_saving_pct" }.

Buy a phone number

POST /v1/numbers with a country ISO code and a plan (monthly = 30 days, yearly = 365 days). The 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.

Request
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"}'
200 OK — Rental
{
  "id": 912,
  "number": { "e164": "+44XX123456XX", "display": "+44 XX 1234 56XX" },
  "country": { "iso": "GB", "name": "United Kingdom", "flag": "🇬🇧" },
  "plan": "monthly",
  "price": "5.49",
  "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": "5.49", "yearly": "27.45" }
}

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.

Request
curl "https://phoneborn.com/v1/numbers/912/sms?after_id=0" -H "X-API-Key: $PHONEBORN_API_KEY"
200 OK
[
  {
    "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.

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

Complete examples

Node.js 18+ — buy & poll
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);
Python 3 — buy & poll
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"}.

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.

Limits & best practices

  • Keep API keys server-side only. Rotate them from the dashboard if one is exposed — revocation is immediate.
  • Handle 429 with exponential back-off. Poll no more often than every few seconds per number.
  • Purchases are not idempotent: if a POST times out, list your numbers before retrying to avoid buying twice.
  • Monitor your balance with GET /v1/balance so 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.