API
An sms-man compatible HTTP API. If you already talk to sms-man, point your base URL here and the calls carry over.
https://sms-orchestrator.6scphmjxvp.workers.dev
Set once as API_BASE and reuse it. Every path below is
relative to it.
Quickstart
Four calls, start to finish.
# 1. what can we route, and at what price?
curl "$API_BASE/control/limits?token=$KEY&application_id=1003"
# 2. lease a number — the only call that spends money, so send a key you can retry on
curl "$API_BASE/control/get-number?token=$KEY\
&application_id=1003&country_id=idn&idempotency_key=order-8842"
# -> {"request_id":904412,"number":"6281234557712","application_id":"1003","country_id":"idn"}
# 3. wait for the code. one connection, no polling.
# NOTE the stream is keyed on the ACTIVATION UUID, not the integer request_id.
curl -N "$API_BASE/sse/af6ae0b7-…"
# 4. acknowledge success (already settled — this is a no-op you can skip)
curl "$API_BASE/control/set-status?token=$KEY&request_id=904412&status=used"
# …or give up, which releases the number and refunds you, exactly once
curl "$API_BASE/control/set-status?token=$KEY&request_id=904412&status=close"Authentication
Every endpoint takes token as a query parameter. There are
no headers to set and no OAuth dance. A bad or revoked key returns wrong_token.
# every call carries the key as a query parameter curl "$API_BASE/control/get-balance?token=sms_live_xxxxxxxxxxxxxxxx"
Retries and duplicate protection
Read this before you write retry logic. Buying a number spends money; every other endpoint does not, so retry those freely and give this one your attention.
Pass your own unique string to get-number. If the same key
arrives again from the same API key, you get the original response back verbatim — no second
number is leased and no second charge is placed. Safe to retry after a timeout, as many
times as you like.
# safe to retry after a timeout, as many times as you like curl "$API_BASE/control/get-number?token=$KEY\ &application_id=1003&country_id=idn\ &idempotency_key=order-8842-attempt"
| Same key, same parameters | You get the original response back verbatim. No second number is leased and no second charge is placed. |
| Same key, different parameters | HTTP 409 idempotency_key_reused. Serving the first response would quietly hide what is almost always a caller bug, so it is a conflict instead. |
| Concurrent retries | Safe. Exactly one request wins and every other one receives its response. |
| Scope and lifetime | Keys are scoped to your API key, so they only need to be unique to you. They expire after 24 hours, after which the same string may be reused. |
sms-man clients drop in unmodified and never send an idempotency key, and rejecting them for that would defeat the point of the façade. So a short window protects them anyway.
| What it does | If you send NO idempotency_key, a repeat request for the same service and country on the same API key — while an earlier one is still waiting for its SMS — returns that EXISTING activation instead of leasing another number. |
| Window | Sixty seconds by default, configurable per key. |
| Turning it off | Set it to 0 on your key if you legitimately want several concurrent numbers for the same service. Nothing is collapsed then. |
| Precedence | An explicit idempotency_key always wins. The guard only applies to requests that carry none. |
Realtime
GET /sse/:activationId is a Server-Sent Events stream of the
activation's state changes, ending with the code. Use it instead of calling /control/get-sms in a loop — you get the code the moment it
lands, over one connection.
request_id — that integer exists only for sms-man compatibility. And the events are named: a plain onmessage handler fires only
for unnamed frames and will sit silent forever.event: open
data: {"activationId":"af6ae0b7-…"}
event: state
data: {"activationId":"af6ae0b7-…","status":"WAITING_SMS","mobile":"8519…","updatedAt":1788…}
event: state
data: {"activationId":"af6ae0b7-…","status":"CODE_RECEIVED","mobile":"8519…","code":"483920","sms":"Your code is 483920","updatedAt":1788…}
event: code
data: {"activationId":"af6ae0b7-…","code":"483920","sms":"Your code is 483920"}
event: done
data: {"activationId":"af6ae0b7-…","status":"CODE_RECEIVED"} const es = new EventSource(`${API_BASE}/sse/${activationId}`);
// The events are NAMED. A plain es.onmessage handler fires only for unnamed
// frames and will sit silent forever against this stream.
es.addEventListener('state', (ev) => {
const s = JSON.parse(ev.data); // INIT -> ACQUIRING -> WAITING_SMS -> …
render(s.status, s.mobile);
});
es.addEventListener('code', (ev) => {
const { code } = JSON.parse(ev.data);
use(code);
});
es.addEventListener('done', () => es.close()); // the server closes too | INIT | Order accepted, nothing reserved yet. |
| ACQUIRING | Sourcing a number from the upstream pool. |
| WAITING_SMS | The number is live and listening. |
| CODE_RECEIVED | Terminal. code and sms are on the event; the charge is settled. |
| EXPIRED | Terminal. The window closed with no SMS; the hold is refunded. |
| FAILED | Terminal. The activation could not be completed; the hold is refunded. |
| RELEASING | Handing the number back upstream. |
| CANCELLED | Terminal. You gave up with close or reject; the hold is refunded. |
Webhooks
Register a callback with POST /control/webhook and we push
the code to you instead of you waiting for it.
activation_id.{
"activation_id": "af6ae0b7-…",
"service": "1003",
"country": "idn",
"mobile": "8519…",
"code": "483920",
"sms": "Your code is 483920"
} | x-smstoyou-signature | HMAC-SHA256 of the raw request body, keyed with your webhook secret, hex encoded. Verify it before parsing. |
| x-smstoyou-delivery | Stable across retries of the same delivery, so it works as a dedupe key too. |
import { createHmac, timingSafeEqual } from 'node:crypto';
app.post('/hooks/sms', async (req, res) => {
const body = req.rawBody; // the exact bytes, unparsed
// 1. verify the signature BEFORE trusting anything in the payload
const expected = createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex');
const given = req.get('x-smstoyou-signature') ?? '';
if (given.length !== expected.length ||
!timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
return res.sendStatus(401);
}
const event = JSON.parse(body);
// 2. DEDUPE. Delivery is at-least-once: you WILL sometimes see the same
// activation twice. An INSERT that a unique constraint rejects is the
// whole implementation.
const fresh = await db.insertIfAbsent('seen_activations', event.activation_id);
// 3. reply 2xx promptly, then do the slow part
res.sendStatus(200);
if (fresh) await handleCode(event);
}); Reply 2xx promptly and do the slow work after replying —
that is what keeps a successful delivery from being retried as a failure.
/control/get-balance Your spendable balance as a decimal string. Always read money from here.
| token required | Your API key. |
{
"balance": "12.34"
} /control/applications Services we have confirmed availability for, with their ids.
| token required | Your API key. |
[
{ "id": "1003", "title": "Yahoo" },
{ "id": "1038", "title": "Shopee" }
] /control/countries Countries we have confirmed availability for, with their ids.
| token required | Your API key. |
[
{ "id": "idn", "title": "Indonesia" },
{ "id": "phl", "title": "Philippines" }
] /control/limits The service/country pairs we can currently route, cheapest first. Filter by either id, or omit both.
| token required | Your API key. |
| application_id | Restrict to one service. |
| country_id | Restrict to one country. |
[
{ "application_id": "1003", "country_id": "idn", "cost": "0.40" }
] /control/get-prices Full price table for pairs known to be available. Same filters as /control/limits.
| token required | Your API key. |
| application_id | Restrict to one service. |
| country_id | Restrict to one country. |
[
{ "application_id": "1003", "country_id": "idn", "cost": "0.40", "count": 1 }
] /control/get-number spends moneyLease a number. Places a hold on your balance and starts the activation.
| token required | Your API key. |
| application_id required | Service id from /control/applications. |
| country_id required | Country id from /control/countries. |
| idempotency_key | Your own unique string. Retrying with the same key returns the ORIGINAL response verbatim — no second number, no second charge. Strongly recommended: see Retries below. |
{
"request_id": 904412,
"number": "6281234557712",
"application_id": "1003",
"country_id": "idn"
} /control/get-sms Read the code for an activation. Returns wait_sms until one arrives.
| token required | Your API key. |
| request_id required | From /control/get-number. |
{
"request_id": 904412,
"sms_code": "774213",
"sms_text": "774213 is your verification code."
} /control/set-status Drive the activation: ask for another SMS, give up, or acknowledge success.
| token required | Your API key. |
| request_id required | From /control/get-number. |
| status required | ready — ask for the SMS again · close / reject — give up, release the number and refund you · used — acknowledge success (already settled, so a no-op) |
{
"success": "true"
} /control/webhook Register a callback URL. The signing secret is returned ONCE.
| token required | Your API key (query parameter, as everywhere). |
| url required | JSON body field. Must be https:// (loopback is allowed for local development). |
{
"id": "0f2c…",
"url": "https://example.com/hooks/sms",
"secret": "a1b2c3…"
} Errors
Every error carries sms-man's own error_code envelope, so an
existing sms-man client parses it unchanged. Unlike sms-man we also set a real HTTP status —
401 for a bad token, 429 for a rate limit, 409 for a reused idempotency key — so a generic
client can react without parsing the body. The one exception is wait_sms, which is HTTP 200 because it is not a failure.
{ "error_code": "no_numbers", "error_msg": "no numbers available" } | Code | Means | Do this |
|---|---|---|
| wait_sms | The activation is live but no SMS has arrived yet. | Not an error. Stay on the SSE stream; it fires the moment the code lands. |
| wrong_token | The token is missing, malformed, or revoked. | Check the key, or mint a new one on the API keys screen. Unknown and revoked are deliberately indistinguishable. |
| no_balance | Your balance will not cover the number you asked for. | Credit is issued by an operator — contact support. Treat this the same wherever it appears; a passed balance check reserves nothing. |
| no_numbers | Nothing available for that service/country pair right now. | Nothing was charged. Check /control/limits and pick another country, or retry shortly. |
| idempotency_key_reused | HTTP 409. That idempotency_key was already used with different parameters. | Fix the caller — this is almost always a key being reused across two genuinely different orders. |
| rate_limit | HTTP 429. Too many requests on this key. | Back off and retry. The per-key limit is shown next to the key on the API keys screen. |
| account_suspended | HTTP 403. The account behind this key is suspended. | Contact support. No endpoint, paid or free, will answer until it is lifted. |
| wrong_activation_id | HTTP 404. No activation with that request_id belongs to your account. | Check the id. Activations are scoped to their owner, so another account’s id looks identical to one that does not exist. |