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"}.

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 plan prices (1, 3, 6, 12 months; API key required), 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 1, 3, 6 or 12-month plan
GET/v1/numbersList your phone numbers and their plans
GET/v1/numbers/{id}/smsRead SMS received on one of your phone numbers
POST/v1/numbers/{id}/replace7-day replacement guarantee: swap for a new number from the same country (free, up to 3 times)
POST/v1/numbers/{id}/refundRefund 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.

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,
    "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.

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": "…",
  "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.

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.

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.

Request
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.

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.