Reference

API

An sms-man compatible HTTP API. If you already talk to sms-man, point your base URL here and the calls carry over.

Base URL
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"
A key spends real balance. Keep it server-side — never ship one in a browser bundle or a mobile app.

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.

idempotency_key — optional, recommended

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 parametersYou get the original response back verbatim. No second number is leased and no second charge is placed.
Same key, different parametersHTTP 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 retriesSafe. Exactly one request wins and every other one receives its response.
Scope and lifetimeKeys 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.
Duplicate guard — automatic, for clients that send no key

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 doesIf 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.
WindowSixty seconds by default, configurable per key.
Turning it offSet it to 0 on your key if you legitimately want several concurrent numbers for the same service. Nothing is collapsed then.
PrecedenceAn 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.

Two things clients get wrong here. The stream is keyed on the activation UUID, not the integer 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.
Wire format
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"}
Client
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
States
INITOrder accepted, nothing reserved yet.
ACQUIRINGSourcing a number from the upstream pool.
WAITING_SMSThe number is live and listening.
CODE_RECEIVEDTerminal. code and sms are on the event; the charge is settled.
EXPIREDTerminal. The window closed with no SMS; the hold is refunded.
FAILEDTerminal. The activation could not be completed; the hold is refunded.
RELEASINGHanding the number back upstream.
CANCELLEDTerminal. 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.

Delivery is AT-LEAST-ONCE, not exactly-once. We retry any non-2xx response, up to five attempts with growing backoff, and then dead-letter it. A consumer that is slow but ultimately successful — one that processes the code and then times out before replying 200, say — will receive the same code again. Your endpoint must be idempotent: deduplicate on activation_id.
Payload
{
  "activation_id": "af6ae0b7-…",
  "service": "1003",
  "country": "idn",
  "mobile": "8519…",
  "code": "483920",
  "sms": "Your code is 483920"
}
Headers
x-smstoyou-signatureHMAC-SHA256 of the raw request body, keyed with your webhook secret, hex encoded. Verify it before parsing.
x-smstoyou-deliveryStable across retries of the same delivery, so it works as a dedupe key too.
A correct handler
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.

GET /control/get-balance

Your spendable balance as a decimal string. Always read money from here.

Parameters
token requiredYour API key.
Response
{
  "balance": "12.34"
}
GET /control/applications

Services we have confirmed availability for, with their ids.

Parameters
token requiredYour API key.
Response
[
  { "id": "1003", "title": "Yahoo" },
  { "id": "1038", "title": "Shopee" }
]
id is a STRING. It is the upstream service id and must be passed back verbatim — do not parse it as a number.
GET /control/countries

Countries we have confirmed availability for, with their ids.

Parameters
token requiredYour API key.
Response
[
  { "id": "idn", "title": "Indonesia" },
  { "id": "phl", "title": "Philippines" }
]
GET /control/limits

The service/country pairs we can currently route, cheapest first. Filter by either id, or omit both.

Parameters
token requiredYour API key.
application_id Restrict to one service.
country_id Restrict to one country.
Response
[
  { "application_id": "1003", "country_id": "idn", "cost": "0.40" }
]
THERE IS NO STOCK COUNT, AND THERE IS NOT GOING TO BE ONE. Our supplier publishes no availability feed at all — availability is learned from real acquisition attempts, so a pair listed here is one we believe we can serve, not a promise. If it turns out to be empty, get-number answers no_numbers immediately and you are charged nothing.
GET /control/get-prices

Full price table for pairs known to be available. Same filters as /control/limits.

Parameters
token requiredYour API key.
application_id Restrict to one service.
country_id Restrict to one country.
Response
[
  { "application_id": "1003", "country_id": "idn", "cost": "0.40", "count": 1 }
]
count is a legacy availability FLAG carried for sms-man compatibility, not a stock level. Treat it as a boolean; never show it to an end user as a quantity.
GET /control/get-number spends money

Lease a number. Places a hold on your balance and starts the activation.

Parameters
token requiredYour API key.
application_id requiredService id from /control/applications.
country_id requiredCountry 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.
Response
{
  "request_id": 904412,
  "number": "6281234557712",
  "application_id": "1003",
  "country_id": "idn"
}
This is the only endpoint that spends money. Keep the request_id — every later call is keyed on it. On no_balance: the balance check you hit first is ADVISORY. The authoritative refusal happens when funds are reserved inside a locked transaction, so with several requests in flight an early no_balance is reliable but its absence is not a promise the purchase will succeed.
GET /control/get-sms

Read the code for an activation. Returns wait_sms until one arrives.

Parameters
token requiredYour API key.
request_id requiredFrom /control/get-number.
Response
{
  "request_id": 904412,
  "sms_code": "774213",
  "sms_text": "774213 is your verification code."
}
Polling is free and served from cache — it never reaches our supplier, so poll as hard as you like. Or open the SSE stream and do not poll at all.
GET /control/set-status

Drive the activation: ask for another SMS, give up, or acknowledge success.

Parameters
token requiredYour API key.
request_id requiredFrom /control/get-number.
status requiredready — ask for the SMS again · close / reject — give up, release the number and refund you · used — acknowledge success (already settled, so a no-op)
Response
{
  "success": "true"
}
Refunds are exactly-once: sending close five times refunds once. ready is refused after a code was delivered — buy a new number instead.
POST /control/webhook

Register a callback URL. The signing secret is returned ONCE.

Parameters
token requiredYour API key (query parameter, as everywhere).
url requiredJSON body field. Must be https:// (loopback is allowed for local development).
Response
{
  "id": "0f2c…",
  "url": "https://example.com/hooks/sms",
  "secret": "a1b2c3…"
}
Store the secret now — it is not shown again. Then read the Webhooks section below before you write the handler: delivery is at-least-once.

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" }
CodeMeansDo this
wait_smsThe 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_tokenThe 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_balanceYour 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_numbersNothing available for that service/country pair right now.Nothing was charged. Check /control/limits and pick another country, or retry shortly.
idempotency_key_reusedHTTP 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_limitHTTP 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_suspendedHTTP 403. The account behind this key is suspended.Contact support. No endpoint, paid or free, will answer until it is lifted.
wrong_activation_idHTTP 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.