textsms.io
Log in Sign up

API & webhooks

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.

Your key lives in your account

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.

Reference
Base URL https://textsms.io/api/v1
Endpoints
GET /services?country=US&q=whatsapp
The catalogue, with prices. Both filters are optional. Use each row's id to rent.
GET /balance
Wallet balance, in NGN.
POST /rent
{"service_id": 42}. Charges your wallet and returns a number. Send an Idempotency-Key header so a retry can't buy twice.
GET /status?id=123
One rental, checking for a code as it answers (?order_id= works too). Use webhooks instead where you can.
GET /active
Everything still outstanding. Does not poll for codes.
POST /cancel
{"id": 123}. Refunds a number that never got a code.
POST /finish
{"id": 123}. Closes one you're done with. No refund.
GET /history?page=1&per_page=25&status=received
Closed rentals, newest first. per_page is 1 to 100 (default 25); status is received or cancelled (which includes expired), or left out for both.
Responses

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.

GET /services?country=US&q=whatsapp
{
  "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.

GET /balance
{
  "ok": true,
  "balance": 48300,
  "currency": "NGN"
}
POST /rent
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.

GET /status?id=8891
{
  "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.

GET /active
{
  "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.

GET /history?page=1&per_page=25
{
  "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
}
POST /cancel  {"id": 8893}
{
  "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_early409
Less than two minutes since renting. Try again after that.
code_received409
A code has arrived, so the number was used. Call /finish instead.
already_closed409
It was already cancelled, finished, or expired (expired numbers are refunded automatically).
provider_error502
We couldn't release it just now. It is still open and still yours; try again shortly.
POST /finish  {"id": 8891}
{
  "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."
  }
}
Errors

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."
  }
}
unauthenticated401
Missing, wrong, rotated or disabled API key.
insufficient_balance402
Not enough in your wallet for this number. Nothing charged.
not_found404
No rental with that id on your account.
service_unavailable404 / 422
That service id is not on sale (or not priced). Fetch /services again. Nothing charged.
method_not_allowed405
GET on a POST endpoint, or the other way round.
in_progress409
A rent with this Idempotency-Key is still running. Retry in a few seconds.
too_early, code_received, already_closed, no_code409
The rental is in the wrong state for that action. See cancel and finish above.
bad_request422
A required field is missing or invalid; the message says which.
rate_limited429
Over the limit. Wait for the Retry-After seconds.
provider_error502
On /rent: no number available right now. Nothing charged; try again shortly or pick another service. On /cancel: see above.

Limits: 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).

Timeouts: was my rent processed?

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": true
The first call went through. This is that rental, charged once.
409 in_progress
The first call is still running. Wait a few seconds and send it again.
200 without replay
The first call failed and charged nothing; this one rented the number.
provider_error, insufficient_balance, service_unavailable
Neither call rented a number, and nothing was charged.
429 rate_limited
Says nothing about the first call. Wait for Retry-After, then send the same key again.

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

Going live

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.

Webhooks

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_received
An SMS arrived for the number. Sent once per rental, for the first message.
number.expired
The number ran out of time with no SMS. The full price is back in your wallet.
number.cancelled
The number was cancelled, through /cancel or on the website. The full price is back in your wallet.
webhook.test
You pressed Send test event. Carries a sample rental with id 0.
Headers
Content-Type
application/json
User-Agent
textsms.io-webhooks/1
X-Textsms-Event
The event name, the same as event in the body.
X-Textsms-Deliveryinteger
The delivery ID. It stays the same on every retry of an event, and every new event gets a new one. Deduplicate on this.
X-Textsms-Timestampinteger
Unix time in seconds (UTC) when this attempt was sent, for example 1792504621. A retry carries a new timestamp.
X-Textsms-Signature
sha256= followed by 64 lowercase hex characters: the HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your signing secret. See Verifying the signature.
Body

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

eventstring
The event name.
created_atstring
When the event happened, ISO 8601 in UTC. It is the same on every retry.
dataobject
The rental, with these fields:
data.idinteger
The rental ID, the same as rental.id from /rent. Match events to your purchases with this.
data.order_idstring
Our order reference, 20 hex characters, unique per rental. Customers see it in their history; quote it to support.
data.servicestring
Service name, such as Whatsapp.
data.service_codestring
The service's service_code from /services.
data.numberstring
The phone number: digits with the country code and no +, such as 18082796286.
data.codestring
The SMS exactly as it arrived. Often just the code, but it can be the whole message (for example Your WhatsApp code: 994-213…), so pull out the digits you need. Empty until an SMS arrives.
data.statusstring
waiting, received, cancelled, or expired. expired appears only in the number.expired webhook; the API reports an expired rental as cancelled, which means closed and refunded.
data.activeboolean
true while the rental is open. A number that has received its code stays open until you call /finish.
data.pricenumber
What the rental cost, in data.currency.
data.currencystring
NGN.
data.rented_atstring
When the number was rented, ISO 8601 in UTC.
data.refundednumber
Only on number.expired and number.cancelled: the amount put back in your wallet.
data.test, data.message
Only on webhook.test.
Example requests

These 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."
  }
}
Responding and retries

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.

Verifying the signature

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
Testing

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.