Receive SMS Codes with Node.js
Here is a complete example of using the Turonay SMS verification API with the fetch built into Node.js 18 and later: buy a number, poll for the SMS code, then complete or cancel the activation. Turonay works with the handler_api protocol, so this code is also compatible with bots and libraries that use the same protocol.
No HTTP package is needed; we install Express only for the webhook receiver. The page starts with a balance call, then walks through the full flow written with async/await, the JSON endpoints, requesting another SMS, a webhook with signature verification and the error table. For an overview see SMS API, and for parameter details see API reference.
Prerequisites
Three things need to be ready before you start:
- An API key: after signing up you find it in the API section of your profile. The key can spend your balance, so use it only in server-side Node.js code, read it from
process.envand never send it to the browser. - Balance:
getNumberreserves the price; amounts are in Turkish lira (TRY). - Node.js 18 or newer:
fetchandAbortSignal.timeoutare built in.
EARLY_CANCEL_DENIED.Setup: no dependencies
Check your version and put the key in an environment variable. Only install express if you will run the webhook receiver; the API calls need no package. The examples use CommonJS, so no extra configuration is needed either.
node --version # v18 or newer: fetch() is built in, no HTTP package needed
npm install express # only needed for the webhook receiver below
export TURONAY_API_KEY="paste-your-api-key-here"First request: read your balance
The shortest working example is a getBalance request. The API uses GET and answers in plain text, not JSON: success is ACCESS_BALANCE:125.4000 and a wrong key gives BAD_KEY. Do not call res.json(); read res.text() first and trim() it.
// balance.js - run with: node balance.js
const API = "https://turonay.com/stubs/handler_api.php";
async function main() {
const query = new URLSearchParams({
api_key: process.env.TURONAY_API_KEY,
action: "getBalance",
});
const res = await fetch(`${API}?${query}`, { signal: AbortSignal.timeout(20_000) });
const text = (await res.text()).trim(); // plain text, not JSON: "ACCESS_BALANCE:125.4000"
if (text.startsWith("ACCESS_BALANCE:")) {
console.log("Balance (TRY):", text.split(":")[1]);
} else {
console.log("Error:", text); // e.g. BAD_KEY
}
}
main();Full flow in Node.js: get a number, wait for the code, close
The script below runs the whole life cycle with async/await. getNumber() buys with a price cap (maxPrice), waitForCode() calls getStatus every 5 seconds for at most 20 minutes, and depending on the result finish() or cancel() runs. The function names match Turonay's Node reference client. Run it with node sms-flow.js.
// sms-flow.js - buy a number, wait for the SMS code, finish or cancel. Node 18+, no packages.
const API = "https://turonay.com/stubs/handler_api.php";
const API_KEY = process.env.TURONAY_API_KEY;
// The API answered with a plain-text error code (NO_BALANCE, NO_NUMBERS, ...).
class ApiError extends Error {}
// Network trouble, HTTP 429 or 5xx, NO_CONNECTION: safe to retry later.
class Transient extends ApiError {}
class CodeTimeout extends Error {}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(action, params = {}) {
const query = new URLSearchParams({ api_key: API_KEY, action });
for (const [key, value] of Object.entries(params)) {
if (value != null) query.set(key, String(value));
}
let res;
try {
res = await fetch(`${API}?${query}`, { signal: AbortSignal.timeout(20_000) });
} catch (err) {
throw new Transient(`network error: ${err.name}`); // never log the URL: it holds your api_key
}
if (res.status !== 200) throw new Transient(`HTTP ${res.status}`);
const text = (await res.text()).trim(); // always trim: bodies are plain text
if (text === "NO_CONNECTION") throw new Transient(text);
return text;
}
async function getBalance() {
const text = await call("getBalance");
if (!text.startsWith("ACCESS_BALANCE:")) throw new ApiError(text);
return text.split(":")[1];
}
// Returns [activationId, phone]. The price is only RESERVED here, not charged.
async function getNumber(service, country, maxPrice = null) {
const text = await call("getNumber", {
service,
country,
maxPrice: maxPrice == null ? null : maxPrice.toFixed(2),
});
const [tag, id, phone] = text.split(":");
if (tag === "ACCESS_NUMBER" && id && phone) return [id, phone];
throw new ApiError(text); // NO_BALANCE, NO_NUMBERS, BAD_SERVICE, WRONG_MAX_PRICE:<price> ...
}
// Returns ["wait" | "ok" | "cancel", code | null].
async function getStatus(id) {
const text = await call("getStatus", { id });
if (text.startsWith("STATUS_OK:")) return ["ok", text.slice("STATUS_OK:".length)];
if (text === "STATUS_CANCEL") return ["cancel", null];
if (text === "STATUS_WAIT_CODE" || text === "STATUS_WAIT_RESEND" || text.startsWith("STATUS_WAIT_RETRY:")) {
return ["wait", null]; // WAIT_RETRY carries the OLD code: still waiting for the new one
}
throw new ApiError(text); // NO_ACTIVATION, BAD_KEY ...
}
const setStatus = (id, status) => call("setStatus", { id, status });
const requestAnotherSms = (id) => setStatus(id, 3); // free, ACCESS_RETRY_GET
const finish = (id) => setStatus(id, 6); // ACCESS_ACTIVATION, only after a code
const cancel = (id) => setStatus(id, 8); // ACCESS_CANCEL / EARLY_CANCEL_DENIED
// Poll every `interval` seconds for at most `timeout` seconds (20 minutes).
async function waitForCode(id, { timeout = 1200, interval = 5 } = {}) {
const deadline = Date.now() + timeout * 1000;
let delay = interval;
while (Date.now() < deadline) {
try {
const [state, code] = await getStatus(id);
delay = interval;
if (state === "ok") return code;
if (state === "cancel") throw new ApiError("STATUS_CANCEL");
} catch (err) {
if (!(err instanceof Transient)) throw err;
delay = Math.min(delay * 2, 60); // back off on HTTP 429 / network trouble
}
await sleep(Math.max(0, Math.min(delay * 1000, deadline - Date.now())));
}
throw new CodeTimeout(`no SMS within ${timeout} seconds`);
}
async function main() {
const [service, country] = ["wa", "62"]; // WhatsApp, Turkey (see getPrices / getCountries)
let id;
let phone;
try {
console.log("Balance (TRY):", await getBalance());
[id, phone] = await getNumber(service, country, 15.0);
} catch (err) {
if (!(err instanceof ApiError)) throw err;
if (err instanceof Transient) console.error("Temporary problem, try again later:", err.message);
else if (err.message.startsWith("WRONG_MAX_PRICE")) console.error("Current price is above your limit:", err.message.split(":")[1] ?? "?");
else if (err.message === "NO_NUMBERS") console.error("No numbers in stock right now, try again in a minute");
else if (err.message === "NO_BALANCE") console.error("Not enough balance, top up first");
else console.error("API error:", err.message);
return 1;
}
console.log(`Use this number: ${phone} (activation ${id})`);
let code;
try {
code = await waitForCode(id);
} catch (err) {
if (err instanceof CodeTimeout) {
const reply = await cancel(id); // nothing is charged when no code arrived
if (reply !== "EARLY_CANCEL_DENIED") {
console.error("Timed out, activation cancelled:", reply);
return 1;
}
code = (await getStatus(id))[1]; // the code arrived in the last second
} else if (err instanceof ApiError) {
console.error("Activation cancelled:", err.message); // STATUS_CANCEL: nothing charged
return 1;
} else {
throw err;
}
}
console.log("SMS code:", code);
console.log(await finish(id)); // ACCESS_ACTIVATION
return 0;
}
main().then((status) => { process.exitCode = status; });The cases the script handles:
- If the current price exceeds
maxPrice, no number is bought andWRONG_MAX_PRICE:<price>is returned; you can read the current price from the error message. NO_NUMBERSis a temporary lack of stock andNO_BALANCEneeds a top-up. Network errors, HTTP 429 and 5xx are markedTransientand retried inside the polling loop with a doubling delay (up to 60 s).STATUS_WAIT_CODEandSTATUS_WAIT_RETRY:<old code>both mean "keep waiting"; the second one carries the code of the previous SMS.- After 20 minutes the activation is cancelled with 8; if a code arrived in the last second
EARLY_CANCEL_DENIEDcomes back and the code is read again. Once a code is received the activation is closed with 6.
JSON endpoints: getNumberV2 and getStatusV2
To work with objects, getNumberV2 returns JSON that you read with JSON.parse: activationId, phoneNumber, activationCost, currency, activationEndTime. When a code arrives getStatusV2 returns JSON with sms.code and sms.text. But error codes are plain text on V2 as well, and before a code exists getStatusV2 returns a plain-text state, so a try/catch that falls back to the text when JSON.parse fails is essential. activationEndTime is in server time (Europe/Istanbul, UTC+3), formatted Y-m-d H:i:s without a time zone.
async function getNumberV2(service, country, maxPrice = null) {
const text = await call("getNumberV2", {
service,
country,
maxPrice: maxPrice == null ? null : maxPrice.toFixed(2),
});
try {
return JSON.parse(text); // activationId, phoneNumber, activationCost, activationEndTime ...
} catch {
throw new ApiError(text); // errors stay plain text, even in V2
}
}
// Object with the SMS once it arrived, otherwise the plain-text status (a string).
async function getStatusV2(id) {
const text = await call("getStatusV2", { id });
let data;
try {
data = JSON.parse(text);
} catch {
return text; // STATUS_WAIT_CODE, STATUS_WAIT_RETRY:<old>, STATUS_CANCEL ...
}
return { code: data?.sms?.code ?? null, text: data?.sms?.text ?? null };
}
(async () => {
const order = await getNumberV2("wa", "62", 15.0);
console.log(order.activationId, order.phoneNumber, order.activationCost, "until", order.activationEndTime);
})();Requesting another SMS (setStatus 3)
When you need a new SMS on the same number, send 3 through setStatus. It is free, and you pay only once per activation. The reply is ACCESS_RETRY_GET and until the new SMS arrives getStatus returns STATUS_WAIT_RETRY with the old code; waitForCode() counts that as waiting. Close the activation with 6 only after the last code. The snippet below runs inside an async function.
// Inside an async function: you have a first code and need a second SMS on the same number (free).
const firstCode = await waitForCode(id);
const reply = await requestAnotherSms(id); // setStatus status=3 -> ACCESS_RETRY_GET
if (reply === "ACCESS_RETRY_GET") {
// While the new SMS is on its way getStatus answers STATUS_WAIT_RETRY:<first code>,
// which waitForCode() treats as "still waiting".
const secondCode = await waitForCode(id);
console.log("First:", firstCode, "Second:", secondCode);
}
await finish(id); // close only after the LAST codeWebhook receiver: Express and signature verification
If you want notifications instead of polling, set an HTTPS webhook address in the API section of your profile (domain name, port 443 or 8443). When a code arrives (code_received) or an activation is cancelled (cancelled) you receive a signed JSON POST. The headers are X-Turonay-Event, X-Turonay-Timestamp, X-Turonay-Signature and X-Turonay-Delivery, and the signature is sha256= + HMAC-SHA256(secret, timestamp + "." + raw body).
// webhook.js npm install express | TURONAY_WEBHOOK_SECRET=whsec_... node webhook.js
const crypto = require("node:crypto");
const express = require("express");
const SECRET = process.env.TURONAY_WEBHOOK_SECRET; // whsec_... from your profile page
const seen = new Set(); // demo only: use Redis/DB with a TTL
// sha256=HMAC_SHA256(secret, timestamp + "." + raw body), 5 minute replay window.
function verifyWebhookSignature(secret, timestamp, rawBody, signature, toleranceSec = 300) {
if (!/^[0-9]{1,12}$/.test(timestamp ?? "")) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false;
const expected = "sha256=" + crypto.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from((signature ?? "").trim());
return a.length === b.length && crypto.timingSafeEqual(a, b); // constant time
}
const app = express();
// express.raw keeps the exact bytes; express.json() would re-serialise them.
app.post("/turonay/webhook", express.raw({ type: "*/*", limit: "64kb" }), (req, res) => {
if (!SECRET) return res.sendStatus(500);
const raw = req.body;
if (!Buffer.isBuffer(raw)) return res.sendStatus(400);
if (!verifyWebhookSignature(SECRET, req.get("X-Turonay-Timestamp"), raw, req.get("X-Turonay-Signature"))) {
return res.sendStatus(401);
}
let event;
try {
event = JSON.parse(raw.toString("utf8"));
} catch {
return res.sendStatus(400);
}
if (!seen.has(event.id)) { // retries repeat the same delivery id
seen.add(event.id);
if (event.event === "code_received") {
console.log("code", event.code, "for activation", event.activationId, event.phone);
} else if (event.event === "cancelled") {
console.log("activation", event.activationId, "cancelled, nothing charged");
}
}
res.sendStatus(200); // answer 2xx within 5 seconds
});
app.listen(8080, () => console.log("listening on :8080 (put HTTPS in front)"));The most common mistake in Express is using express.json(): once the body is parsed and re-serialised the signature no longer matches. Use express.raw() to get the raw Buffer. crypto.timingSafeEqual throws if the two buffers differ in length, so compare lengths first. Reject timestamps older than 5 minutes, answer 2xx within 5 seconds and skip an id you have already seen (up to 8 retries are made). See Webhooks for details.
Error codes
Application errors arrive as plain text with HTTP 200; network errors, 429 and 5xx are converted to the Transient class in the example. The table lists the codes you may see in the response body and the recommended reactions.
| Code | Meaning | What to do |
|---|---|---|
BAD_KEY | The API key is empty, wrong or blocked. | Copy the key from your profile again and make sure no whitespace is left at either end. |
NO_BALANCE | Your available balance is lower than the number's price. | Top up your balance. Amounts that are already reserved are subtracted from the available balance. |
NO_NUMBERS | No numbers in stock for that service and country right now (also returned during maintenance). | Retry in a few minutes or pick another country. |
BAD_SERVICE | The service code or country id is invalid or not on sale. | Check valid codes with getPrices and getCountries. |
WRONG_MAX_PRICE:<price> | The current price is above your maxPrice limit (or the limit is malformed). No number is bought and nothing is reserved. | Read the current price after the colon; if you accept it, raise the limit and retry. |
NO_ACTIVATION | The activation id does not exist or does not belong to your account. | Use the id from the getNumber response exactly as returned. |
BAD_STATUS | Invalid status, or status=6 sent before a code arrived. | Wait for the code first, and send only 1, 3, 6 or 8. |
EARLY_CANCEL_DENIED | Cancelling is refused because the code has already arrived. | Read the code with getStatus and close the activation with 6; the charge is final. |
NO_CONNECTION / HTTP 429 | Temporary problem or rate limit. | Retry with a growing delay (up to 60 seconds) and poll less often. |
STATUS_CANCEL | The activation was cancelled (by you or by the supplier). | Stop waiting; nothing is charged. Buy a new number if you still need one. |
Pointing an existing handler_api client at Turonay
If you have a Node.js bot or library that uses the handler_api layout you may not need to rewrite it: in most integrations it is enough to make the base URL Turonay's /stubs/handler_api.php address and the key your Turonay API key. Reading the address from a .env file or an environment variable turns the switch into a one-line change.
// Before: const BASE_URL = "https://provider.example.com/stubs/handler_api.php";
const BASE_URL = process.env.SMS_API_URL ?? "https://turonay.com/stubs/handler_api.php";
const API_KEY = process.env.SMS_API_KEY; // your Turonay API key
// Libraries and bots with a base-URL option need only these two values, e.g.:
// const client = new SomeHandlerApiClient({ apiKey: API_KEY, baseUrl: BASE_URL });Remember that prices are in Turkish lira and that service codes and country ids must match the output of getPrices and getCountries. For prices see API pricing.
Frequently asked questions
How do I use a handler_api compatible API in Node.js?
Turonay speaks the handler_api protocol. With the built-in fetch you send a GET request to the API address with the api_key and action parameters and parse the plain-text reply. If you already use a library, you only change the base URL and the key.
Do I need axios or node-fetch?
No. fetch is built into Node.js 18 and later. If you prefer axios the logic stays the same; just remember to read the response as plain text rather than JSON.
Am I charged if the SMS does not arrive?
No. getNumber only reserves the price and the charge happens when the SMS code arrives. If you cancel before a code arrives or 20 minutes pass, the reservation is released.
Why does the webhook need express.raw?
The signature is computed over the exact bytes of the request body. When express.json() parses and re-serialises the body, whitespace and field order can change and signature verification fails. Verify the raw body first, then convert it to JSON.
Can I get a second SMS on the same number?
Yes. Send status=3 through setStatus to request a new SMS on the same number. It is free and you pay only once per activation. Close the activation with status=6 after the last code.