Rent numbers and collect codes from your own code. Send us a key, and we'll push each code to your server the moment it lands.
Create an account to get an API key, point a webhook at your server and see every delivery we attempt. The reference below is the whole contract, so you can read it first and decide.
/services?country=US&q=whatsapp
/balance
/rent
/status?id=123
/active
/cancel
/finish
/history?page=1&per_page=25&status=received
Every response is JSON with "ok": true or "ok": false. Amounts are numbers in NGN, times are ISO 8601 in UTC, and expires_in is in seconds. Each rental appears as the same object everywhere (rent, status, active, history, cancel, finish and webhooks); its fields are listed under Webhooks → Body.
{
"ok": true,
"services": [
{
"id": 147,
"name": "Whatsapp",
"service_code": "6",
"country": "US",
"country_name": "United States",
"route": "US",
"price": 1700
},
{
"id": 152,
"name": "Whatsapp Business",
"service_code": "1127",
"country": "US",
"country_name": "United States",
"route": "US",
"price": 1900
}
],
"count": 2,
"currency": "NGN",
"synced_at": "2026-10-20T13:30:04+00:00"
}
price is what one number costs, in currency, and is what /rent charges. name is the app or website the number is for. Every service listed is on sale. The catalogue is refreshed every 30 minutes (synced_at), and services that are out of stock are left out until they are back. There is no stock count, because a number is assigned only when you rent. If one runs out in between, /rent answers provider_error and charges nothing. id stays the same across refreshes. route tells routes apart when a country has more than one; today it equals country.
{
"ok": true,
"balance": 48300,
"currency": "NGN"
}
curl -X POST https://textsms.io/api/v1/rent \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4471" \
-d '{"service_id": 147}'
{
"ok": true,
"rental": {
"id": 8891,
"order_id": "9f2c41a0be77d1c4e5aa",
"service": "Whatsapp",
"service_code": "6",
"number": "18082796286",
"code": "",
"status": "waiting",
"active": true,
"price": 1700,
"currency": "NGN",
"rented_at": "2026-10-20T14:02:11+00:00",
"expires_in": 900
},
"balance": 46600,
"currency": "NGN"
}
The price is charged when the number is issued; balance is what is left after it. Store rental.id: every later call and webhook about this number uses it. Sending the same Idempotency-Key again returns this same rental with "replay": true (and an Idempotent-Replay: true header) instead of buying another. See Timeouts.
{
"ok": true,
"rental": {
"id": 8891,
"order_id": "9f2c41a0be77d1c4e5aa",
"service": "Whatsapp",
"service_code": "6",
"number": "18082796286",
"code": "Your WhatsApp code: 994-213. Don't share this code with others",
"status": "received",
"active": true,
"price": 1700,
"currency": "NGN",
"rented_at": "2026-10-20T14:02:11+00:00"
},
"expires_in": 731,
"refunded": null
}
While nothing has arrived, code is empty and status is waiting. If the number runs out of time during this call, it comes back closed: status is cancelled, expires_in is 0, and refunded holds the amount returned to your wallet. A rental that is already closed returns expires_in 0 and refunded null.
{
"ok": true,
"rentals": [
{
"id": 8891,
"order_id": "9f2c41a0be77d1c4e5aa",
"service": "Whatsapp",
"service_code": "6",
"number": "18082796286",
"code": "",
"status": "waiting",
"active": true,
"price": 1700,
"currency": "NGN",
"rented_at": "2026-10-20T14:02:11+00:00",
"expires_in": 512
}
],
"count": 1
}
Every rental not yet closed, newest first, unpaginated. A number that has received its code stays here, with its code, until you call /finish. This endpoint reads what we already know; it does not check for new codes, so use /status or webhooks for that.
{
"ok": true,
"items": [
{
"id": 8891,
"order_id": "9f2c41a0be77d1c4e5aa",
"service": "Whatsapp",
"service_code": "6",
"number": "18082796286",
"code": "Your WhatsApp code: 994-213. Don't share this code with others",
"status": "received",
"active": false,
"price": 1700,
"currency": "NGN",
"rented_at": "2026-10-20T14:02:11+00:00"
},
{
"id": 8893,
"order_id": "c41d2e9f0a1b3c5d7e8f",
"service": "Telegram",
"service_code": "4",
"number": "16062646527",
"code": "",
"status": "cancelled",
"active": false,
"price": 1000,
"currency": "NGN",
"rented_at": "2026-10-20T14:11:33+00:00"
}
],
"page": 1,
"per_page": 25,
"total": 2,
"pages": 1
}
{
"ok": true,
"rental": {
"id": 8893,
"order_id": "c41d2e9f0a1b3c5d7e8f",
"service": "Telegram",
"service_code": "4",
"number": "16062646527",
"code": "",
"status": "cancelled",
"active": false,
"price": 1000,
"currency": "NGN",
"rented_at": "2026-10-20T14:11:33+00:00"
},
"refunded": 1000,
"balance": 47600,
"currency": "NGN"
}
Only a number that has not received a code can be cancelled; the full price comes back. When it can't be, nothing changes and you get one of these:
{
"ok": false,
"error": {
"code": "too_early",
"message": "This number can be cancelled about 2 minutes after renting it."
}
}
too_early409code_received409already_closed409provider_error502{
"ok": true,
"rental": {
"id": 8891,
"order_id": "9f2c41a0be77d1c4e5aa",
"service": "Whatsapp",
"service_code": "6",
"number": "18082796286",
"code": "Your WhatsApp code: 994-213. Don't share this code with others",
"status": "received",
"active": false,
"price": 1700,
"currency": "NGN",
"rented_at": "2026-10-20T14:02:11+00:00"
}
}
Closes a number that has its code, so it leaves /active. No money moves. Finishing one that is already finished returns the same response again. A number with no code can't be finished:
{
"ok": false,
"error": {
"code": "no_code",
"message": "This number has not received a code yet \u2014 cancel it instead."
}
}
Failures always have this shape and a matching HTTP status. Branch on error.code, not on the message, which may be reworded.
{
"ok": false,
"error": {
"code": "insufficient_balance",
"message": "Your balance is too low for this number."
}
}
unauthenticated401insufficient_balance402not_found404service_unavailable404 / 422method_not_allowed405in_progress409too_early, code_received, already_closed, no_code409bad_request422rate_limited429provider_error502Limits: 60 requests a minute per key, and 20 rents a minute. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds).
Send an Idempotency-Key (your own unique string, up to 120 characters, such as your order number) with every /rent. If a call times out or you get no answer, send it again with the same key:
200 with "replay": true409 in_progress200 without replayprovider_error, insufficient_balance, service_unavailable429 rate_limitedKeys are remembered for 24 hours, per account. Without a key, check /active for a rental you don't recognise before renting again. Cancel and finish are safe to repeat: a repeat returns already_closed, or for finish the same success.
1. Call the API from your server only. Browsers are refused by design (no CORS), and the key can spend your balance.
2. Your wallet is prepaid in NGN. Top it up on the Wallet page; a rent with too little balance is refused, not queued.
3. Send an Idempotency-Key with every rent.
4. Get codes by webhook, or poll /status for each open number about every 10 seconds, keeping all calls under 60 a minute.
5. Numbers expire 15 minutes after renting and are refunded automatically if no code came.
Numbers can be cancelled from two minutes after renting.
6. code can be the whole SMS; extract the digits you need.
7. Call /finish once you have the code.
8. There is no test mode; every rent is real. To try the flow for free, rent the cheapest service and cancel it.
We send one JSON POST to your webhook URL for each event. A rental produces at most one of the three number.* events, because it either gets a code, expires, or is cancelled.
number.code_receivednumber.expirednumber.cancelledwebhook.testContent-TypeUser-AgentX-Textsms-EventX-Textsms-DeliveryintegerX-Textsms-TimestampintegerX-Textsms-SignatureEvery event has the same envelope, and every number.* event has the same data object: the rental, exactly as the API returns it. webhook.test carries a sample rental in that shape too. The body arrives as compact JSON; it is formatted here only for reading.
eventstringcreated_atstringdataobjectdata.idintegerdata.order_idstringdata.servicestringdata.service_codestringdata.numberstringdata.codestringdata.statusstringdata.activebooleandata.pricenumberdata.currencystringdata.rented_atstringdata.refundednumberdata.test, data.messageThese are signed with the secret whsec_docs_example_only, so you can check your verification code against them. Your own secret is on this page once you sign in.
POST /webhooks/textsms HTTP/1.1
Host: yourapp.com
Content-Type: application/json
User-Agent: textsms.io-webhooks/1
X-Textsms-Event: number.code_received
X-Textsms-Delivery: 4471
X-Textsms-Timestamp: 1792505021
X-Textsms-Signature: sha256=a8a0be60cd3788e32e24a38a358a18e068fe6a18f9b663c20656f9c8e89a13fd
{
"event": "number.code_received",
"created_at": "2026-10-20T14:03:40+00:00",
"data": {
"id": 8891,
"order_id": "9f2c41a0be77d1c4e5aa",
"service": "Whatsapp",
"service_code": "6",
"number": "18082796286",
"code": "Your WhatsApp code: 994-213. Don't share this code with others",
"status": "received",
"active": true,
"price": 1700,
"currency": "NGN",
"rented_at": "2026-10-20T14:02:11+00:00"
}
}
POST /webhooks/textsms HTTP/1.1
Host: yourapp.com
Content-Type: application/json
User-Agent: textsms.io-webhooks/1
X-Textsms-Event: number.expired
X-Textsms-Delivery: 4472
X-Textsms-Timestamp: 1792506243
X-Textsms-Signature: sha256=bc0f5c8f6d249f7729c79814cb129f4c995248ba2a3f151ab0b648acfd422512
{
"event": "number.expired",
"created_at": "2026-10-20T14:24:02+00:00",
"data": {
"id": 8892,
"order_id": "1b7e09c3d2a4f5e6a7b8",
"service": "Whatsapp",
"service_code": "6",
"number": "12082935689",
"code": "",
"status": "expired",
"active": false,
"price": 1700,
"currency": "NGN",
"rented_at": "2026-10-20T14:08:55+00:00",
"refunded": 1700
}
}
POST /webhooks/textsms HTTP/1.1
Host: yourapp.com
Content-Type: application/json
User-Agent: textsms.io-webhooks/1
X-Textsms-Event: number.cancelled
X-Textsms-Delivery: 4473
X-Textsms-Timestamp: 1792505646
X-Textsms-Signature: sha256=e2dc4b31f6f86ef8af6fa2e770f78bce1a177b1cb94060495f605fd5c43d838a
{
"event": "number.cancelled",
"created_at": "2026-10-20T14:14:05+00:00",
"data": {
"id": 8893,
"order_id": "c41d2e9f0a1b3c5d7e8f",
"service": "Telegram",
"service_code": "4",
"number": "16062646527",
"code": "",
"status": "cancelled",
"active": false,
"price": 1000,
"currency": "NGN",
"rented_at": "2026-10-20T14:11:33+00:00",
"refunded": 1000
}
}
POST /webhooks/textsms HTTP/1.1
Host: yourapp.com
Content-Type: application/json
User-Agent: textsms.io-webhooks/1
X-Textsms-Event: webhook.test
X-Textsms-Delivery: 4474
X-Textsms-Timestamp: 1792506601
X-Textsms-Signature: sha256=4b8aa30ea31bf4b3eb97931375d697a701af8b57ac4ae65190bf8d29067f68e0
{
"event": "webhook.test",
"created_at": "2026-10-20T14:30:00+00:00",
"data": {
"id": 0,
"order_id": "test",
"service": "Test",
"service_code": "test",
"number": "10000000000",
"code": "123456",
"status": "received",
"active": false,
"price": 0,
"currency": "NGN",
"rented_at": "2026-10-20T14:30:00+00:00",
"test": true,
"message": "This is a test event from textsms.io."
}
}
Reply with any 2xx status within 15 seconds (5 to connect). We ignore the response body, and we don't follow redirects, so a 3xx counts as a failure. A failure or timeout is retried with the same body and delivery ID, but a fresh timestamp and signature: 30 seconds after the first attempt fails, then 2 minutes, 10 minutes, 1 hour and 6 hours after each later failure. After six attempts, about seven hours, the delivery is marked failed and shows under Recent deliveries. Events are sent within about a minute, and not necessarily in order.
Changing your URL or rotating your secret applies to any delivery still waiting to be sent, retries included. Turning webhooks off drops them.
1. Read the raw request body as bytes, before any JSON parsing; re-encoded JSON will not match.
2. Join the X-Textsms-Timestamp value, a dot, and the raw body.
3. Compute the HMAC-SHA256 of that string with your whole signing secret (including
whsec_) as the key, as lowercase hex.
4. Put sha256= in front and compare it with X-Textsms-Signature
using a constant-time comparison, never ==.
5. Reject the request if the timestamp is more than 300 seconds from your clock. The timestamp is
signed, so this stops anyone replaying a captured request later.
<?php
$secret = getenv('TEXTSMS_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input'); // raw bytes
$ts = $_SERVER['HTTP_X_TEXTSMS_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_TEXTSMS_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($expected, $sig)) {
http_response_code(400);
exit;
}
$delivery = $_SERVER['HTTP_X_TEXTSMS_DELIVERY'];
if (already_handled($delivery)) { // your own store
http_response_code(200);
exit;
}
$event = json_decode($body, true);
$data = $event['data']; // $data['id'] is the rental ID
switch ($event['event']) {
case 'number.code_received': /* use $data['code'] */ break;
case 'number.expired':
case 'number.cancelled': /* refunded: $data['refunded'] */ break;
case 'webhook.test': break;
}
mark_handled($delivery);
http_response_code(200);
// Node.js (Express). express.raw keeps the body as bytes for the signature.
const crypto = require('crypto');
app.post('/webhooks/textsms', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Textsms-Timestamp') || '';
const sig = req.get('X-Textsms-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.TEXTSMS_WEBHOOK_SECRET)
.update(ts + '.')
.update(req.body)
.digest('hex');
const valid = /^\d+$/.test(ts)
&& Math.abs(Date.now() / 1000 - Number(ts)) <= 300
&& sig.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!valid) return res.sendStatus(400);
const delivery = req.get('X-Textsms-Delivery'); // deduplicate on this
const event = JSON.parse(req.body.toString('utf8'));
// event.event, event.data.id, event.data.code ...
res.sendStatus(200);
});
A test vector, the number.code_received example above exactly as it goes over the wire:
secret: whsec_docs_example_only
timestamp: 1792505021
body: {"event":"number.code_received","created_at":"2026-10-20T14:03:40+00:00","data":{"id":8891,"order_id":"9f2c41a0be77d1c4e5aa","service":"Whatsapp","service_code":"6","number":"18082796286","code":"Your WhatsApp code: 994-213. Don't share this code with others","status":"received","active":true,"price":1700,"currency":"NGN","rented_at":"2026-10-20T14:02:11+00:00"}}
signature: sha256=a8a0be60cd3788e32e24a38a358a18e068fe6a18f9b663c20656f9c8e89a13fd
Press Send test event on this page after saving your URL. A webhook.test arrives within about a minute, signed with your real secret, and costs nothing. Recent deliveries shows the status your server answered with. There is no API call for test events.
To see real number.* events without spending anything, rent the cheapest service, then cancel it for number.cancelled (numbers can be cancelled from two minutes after renting), or leave it 15 minutes for number.expired. Both refund the full price. number.code_received needs a real SMS: use the number on the service you rented it for.
Webhook URLs must be reachable from the internet; localhost and private addresses are refused. For local development, use a tunnel such as ngrok.