Home › SMS API › SMS API in Python
Python · requests · SMS verification API

Receive SMS Codes with Python

This guide gives you a copy-paste Python example that runs the whole Turonay SMS verification flow: buy a number, wait for the SMS code, print it, then finish the activation or cancel it when time runs out. Turonay speaks the handler_api protocol, so the same code also works with bots and libraries written for that protocol.

The page moves step by step: prerequisites, a balance call, the full flow, the JSON (V2) endpoints, a second SMS, a webhook with signature verification, and the error codes. For a general introduction see SMS API, and for every endpoint in detail see API reference.

Before you start

You need three things to run the example:

  • A Turonay account and API key: after signing up, copy your key from the API section of your profile. Keep it on your server in an environment variable and never ship it in a browser or mobile app.
  • Balance: a getNumber call reserves the number's price from your balance. All amounts are in Turkish lira (TRY).
  • Python 3.8 or newer and the requests library.
How billing works: when you get a number the price is only reserved. You are charged at the moment the SMS code really arrives. If you cancel before a code arrives, or the 20 minute activation expires, the reservation is released and you pay nothing. Once a code has arrived, cancelling is refused (EARLY_CANCEL_DENIED).

Install: a virtualenv and requests

Create a virtual environment and install the single dependency. Flask is only needed for the webhook receiver further down, so skip it if you only make API calls. The examples read your API key from the TURONAY_API_KEY environment variable.

bash
python3 -m venv .venv && source .venv/bin/activate
pip install requests          # HTTP client used for every API call
pip install flask             # only needed for the webhook receiver below
export TURONAY_API_KEY="paste-your-api-key-here"

First request: check your balance

The smallest working example is a getBalance call. The API always uses GET and answers in plain text, not JSON: success looks like ACCESS_BALANCE:125.4000 and a wrong key returns BAD_KEY. Always strip() the body, because it may end with a newline.

python
import os
import requests

API = "https://turonay.com/stubs/handler_api.php"

resp = requests.get(
    API,
    params={"api_key": os.environ["TURONAY_API_KEY"], "action": "getBalance"},
    timeout=20,
)
text = resp.text.strip()            # plain text, not JSON: "ACCESS_BALANCE:125.4000"
if text.startswith("ACCESS_BALANCE:"):
    print("Balance (TRY):", text.split(":", 1)[1])
else:
    print("Error:", text)            # e.g. BAD_KEY

Full flow in Python: get a number, wait for the SMS code

The script below runs the entire life cycle. get_number buys a number with a price cap (maxPrice), wait_for_code calls getStatus every 5 seconds for at most 20 minutes, and depending on the result finish or cancel is called. The function names match Turonay's Python reference client, so moving the example into a class of your own later is easy.

python
#!/usr/bin/env python3
"""sms_flow.py - buy a number, wait for the SMS code, finish or cancel.  pip install requests"""
import os
import sys
import time
from decimal import Decimal

import requests

API = "https://turonay.com/stubs/handler_api.php"
API_KEY = os.environ["TURONAY_API_KEY"]


class ApiError(Exception):
    """The API answered with a plain-text error code (NO_BALANCE, NO_NUMBERS, ...)."""


class Transient(ApiError):
    """Network trouble, HTTP 429 or 5xx, NO_CONNECTION: safe to retry later."""


def call(action, **params):
    query = {k: v for k, v in params.items() if v is not None}
    query.update(api_key=API_KEY, action=action)
    try:
        resp = requests.get(API, params=query, timeout=20)
    except requests.RequestException as exc:
        # Do not chain the original error: its message contains your api_key.
        raise Transient("network error: " + type(exc).__name__) from None
    if resp.status_code != 200:
        raise Transient("HTTP %d" % resp.status_code)
    text = resp.text.strip()            # always strip: bodies are plain text
    if text == "NO_CONNECTION":
        raise Transient(text)
    return text


def get_balance():
    text = call("getBalance")
    if not text.startswith("ACCESS_BALANCE:"):
        raise ApiError(text)
    return Decimal(text.split(":", 1)[1])


def get_number(service, country, max_price=None):
    """Returns (activation_id, phone). The price is only RESERVED here, not charged."""
    text = call("getNumber", service=service, country=country,
                maxPrice=None if max_price is None else "%.2f" % max_price)
    parts = text.split(":")
    if parts[0] == "ACCESS_NUMBER" and len(parts) >= 3 and parts[1] and parts[2]:
        return parts[1], parts[2]
    raise ApiError(text)    # NO_BALANCE, NO_NUMBERS, BAD_SERVICE, WRONG_MAX_PRICE:<price> ...


def get_status(activation_id):
    """Returns ("wait" | "ok" | "cancel", code_or_None)."""
    text = call("getStatus", id=activation_id)
    if text.startswith("STATUS_OK:"):
        return "ok", text.split(":", 1)[1]
    if text == "STATUS_CANCEL":
        return "cancel", None
    if text in ("STATUS_WAIT_CODE", "STATUS_WAIT_RESEND") or text.startswith("STATUS_WAIT_RETRY:"):
        return "wait", None    # WAIT_RETRY carries the OLD code: still waiting for the new one
    raise ApiError(text)       # NO_ACTIVATION, BAD_KEY ...


def set_status(activation_id, status):
    return call("setStatus", id=activation_id, status=status)


def request_another_sms(activation_id):
    return set_status(activation_id, 3)    # free, ACCESS_RETRY_GET


def finish(activation_id):
    return set_status(activation_id, 6)    # ACCESS_ACTIVATION, only valid after a code


def cancel(activation_id):
    return set_status(activation_id, 8)    # ACCESS_CANCEL, or EARLY_CANCEL_DENIED after a code


def wait_for_code(activation_id, timeout=1200, interval=5):
    """Poll every `interval` seconds for at most `timeout` seconds (20 minutes)."""
    deadline = time.monotonic() + timeout
    delay = interval
    while time.monotonic() < deadline:
        try:
            state, code = get_status(activation_id)
            delay = interval
            if state == "ok":
                return code
            if state == "cancel":
                raise ApiError("STATUS_CANCEL")
        except Transient:
            delay = min(delay * 2, 60)       # back off on HTTP 429 / network trouble
        time.sleep(max(0, min(delay, deadline - time.monotonic())))
    raise TimeoutError("no SMS within %d seconds" % timeout)


def main():
    service, country = "wa", "62"            # WhatsApp, Turkey (see getPrices / getCountries)
    try:
        print("Balance (TRY):", get_balance())
        activation_id, phone = get_number(service, country, max_price=15.00)
    except Transient as exc:
        sys.exit("Temporary problem, try again later: %s" % exc)
    except ApiError as exc:
        reason = str(exc)
        if reason.startswith("WRONG_MAX_PRICE"):
            print("Current price is above your limit:", reason.partition(":")[2] or "?")
        elif reason == "NO_NUMBERS":
            print("No numbers in stock right now, try again in a minute")
        elif reason == "NO_BALANCE":
            print("Not enough balance, top up first")
        else:
            print("API error:", reason)
        sys.exit(1)

    print("Use this number: %s (activation %s)" % (phone, activation_id))
    try:
        code = wait_for_code(activation_id)
    except TimeoutError:
        reply = cancel(activation_id)        # nothing is charged when no code arrived
        if reply != "EARLY_CANCEL_DENIED":
            sys.exit("Timed out, activation cancelled: %s" % reply)
        code = get_status(activation_id)[1]  # the code arrived in the last second
    except ApiError as exc:                  # STATUS_CANCEL: the supplier cancelled, nothing charged
        sys.exit("Activation cancelled: %s" % exc)

    print("SMS code:", code)
    print(finish(activation_id))             # ACCESS_ACTIVATION


if __name__ == "__main__":
    main()

The script makes these decisions:

  • You set a price ceiling with maxPrice. If the current price is higher, no number is bought and WRONG_MAX_PRICE:<current price> comes back, so you can retry with a new ceiling.
  • NO_NUMBERS (out of stock) is temporary, so try again in a few minutes; NO_BALANCE means you need to top up.
  • STATUS_WAIT_CODE and STATUS_WAIT_RETRY:<old code> both mean "still waiting". The second one carries the code of a previous SMS, which you must not mistake for the new one.
  • When time runs out the script sends 8 through setStatus. If the code arrived in the last second the API answers EARLY_CANCEL_DENIED, and the script reads the code once more and completes the job. Once a code is received the activation is closed with 6.

JSON responses: getNumberV2 and getStatusV2

If you would rather not parse plain text, getNumberV2 returns the number as JSON with activationId, phoneNumber, activationCost, currency and activationEndTime. After a code arrives getStatusV2 returns JSON with sms.code and sms.text. Two details matter: errors are still plain text on the V2 endpoints, and before a code exists getStatusV2 returns the same plain text as getStatus. The safest approach is to try json.loads first and fall back to the text. activationEndTime is in server time (Europe/Istanbul, UTC+3), formatted Y-m-d H:i:s without a time zone.

python
import json


def get_number_v2(service, country, max_price=None):
    text = call("getNumberV2", service=service, country=country,
                maxPrice=None if max_price is None else "%.2f" % max_price)
    try:
        data = json.loads(text)
    except ValueError:
        raise ApiError(text)    # errors stay plain text, even in V2
    return data                 # activationId, phoneNumber, activationCost, activationEndTime ...


def get_status_v2(activation_id):
    """dict with the SMS once it arrived, otherwise the plain-text status (a str)."""
    text = call("getStatusV2", id=activation_id)
    try:
        data = json.loads(text)
    except ValueError:
        return text             # STATUS_WAIT_CODE, STATUS_WAIT_RETRY:<old>, STATUS_CANCEL ...
    sms = (data or {}).get("sms") or {}
    return {"code": sms.get("code"), "text": sms.get("text")}


order = get_number_v2("wa", "62", max_price=15.00)
print(order["activationId"], order["phoneNumber"], order["activationCost"], "until", order["activationEndTime"])

Requesting another SMS on the same number

If the first SMS did not help, or the service asks for a second verification, you can request 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 together with the old code. Close the activation with 6 only after you have the last code.

python
# The first code has arrived and you need a second SMS on the same number (free).
first_code = wait_for_code(activation_id)
reply = request_another_sms(activation_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 wait_for_code() treats as "still waiting".
    second_code = wait_for_code(activation_id)
    print("First:", first_code, "Second:", second_code)
finish(activation_id)                             # close only after the LAST code

Webhook receiver: Flask and signature verification

Instead of polling constantly, you can let Turonay tell you when the code arrives. Enter an HTTPS address in the API section of your profile (it must use a domain name and port 443 or 8443); Turonay then sends a signed JSON POST when a code arrives (code_received) or an activation is cancelled (cancelled). The headers are X-Turonay-Event, X-Turonay-Timestamp, X-Turonay-Signature and X-Turonay-Delivery. The signature is HMAC-SHA256 over timestamp + "." + raw body, sent with a sha256= prefix.

python
# webhook_app.py   pip install flask   |   flask --app webhook_app run --port 8080
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024            # events are tiny
SECRET = os.environ["TURONAY_WEBHOOK_SECRET"]           # whsec_... from your profile page
SEEN = set()                                            # demo only: use Redis/DB with a TTL


def verify_webhook_signature(secret, timestamp, raw_body, signature, tolerance=300):
    """sha256=HMAC_SHA256(secret, timestamp + "." + raw body), 5 minute replay window."""
    try:
        age = abs(time.time() - int(timestamp))
    except (TypeError, ValueError):
        return False
    if age > tolerance:
        return False
    mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
    expected = ("sha256=" + mac.hexdigest()).encode()
    return hmac.compare_digest(expected, (signature or "").strip().encode())   # constant time


@app.post("/turonay/webhook")
def turonay_webhook():
    raw = request.get_data()            # the exact bytes, before any JSON parsing
    if not verify_webhook_signature(
        SECRET,
        request.headers.get("X-Turonay-Timestamp", ""),
        raw,
        request.headers.get("X-Turonay-Signature", ""),
    ):
        return "", 401
    event = request.get_json(force=True, silent=True)
    if not isinstance(event, dict):
        return "", 400
    if event.get("id") in SEEN:         # retries repeat the same delivery id
        return "", 200
    SEEN.add(event.get("id"))
    if event.get("event") == "code_received":
        print("code", event["code"], "for activation", event["activationId"], event["phone"])
    elif event.get("event") == "cancelled":
        print("activation", event["activationId"], "cancelled, nothing charged")
    return "", 200                      # answer 2xx within 5 seconds; do slow work elsewhere

Three security rules: verify the signature on the raw bytes before parsing JSON, compare in constant time with hmac.compare_digest, and reject timestamps older than 5 minutes. If you do not return 2xx within 5 seconds Turonay retries the delivery (up to 8 attempts), so the same event can arrive twice; ignore repeats using the id field. See Webhooks for every event field. If a webhook is ever missed, the wait_for_code loop keeps working as a backup.

Error codes and what to do

The API returns application errors as plain text with HTTP 200, while network and rate-limit problems arrive as HTTP status codes. In the example the Transient class marks conditions you can retry (network error, 429, 5xx, NO_CONNECTION) and ApiError carries permanent errors. The table summarises the codes you will meet most often.

CodeMeaningWhat to do
BAD_KEYThe 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_BALANCEYour 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_NUMBERSNo numbers in stock for that service and country right now (also returned during maintenance).Retry in a few minutes or pick another country.
BAD_SERVICEThe 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_ACTIVATIONThe activation id does not exist or does not belong to your account.Use the id from the getNumber response exactly as returned.
BAD_STATUSInvalid status, or status=6 sent before a code arrived.Wait for the code first, and send only 1, 3, 6 or 8.
EARLY_CANCEL_DENIEDCancelling 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 429Temporary problem or rate limit.Retry with a growing delay (up to 60 seconds) and poll less often.
STATUS_CANCELThe 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 already have a bot or library written for the handler_api protocol you may not need to rewrite it: in most integrations it is enough to change the base (API) URL to the Turonay address ending in /stubs/handler_api.php and swap the key for your Turonay API key. In Python an environment variable is the cleanest way to do that.

python
import os

# Before: BASE_URL = "https://provider.example.com/stubs/handler_api.php"
BASE_URL = os.environ.get("SMS_API_URL", "https://turonay.com/stubs/handler_api.php")
API_KEY = os.environ["SMS_API_KEY"]        # your Turonay API key

# Libraries and bots with a base-URL option need only these two values, e.g.:
# client = SomeHandlerApiClient(api_key=API_KEY, base_url=BASE_URL)

Check two things before switching: amounts are in Turkish lira, and the service codes and country ids must match the output of getPrices and getCountries. For prices see API pricing.

Frequently asked questions

Which URL do I use for a handler_api compatible API in Python?

Turonay speaks the handler_api protocol, so you send GET requests to the Turonay handler_api address with the api_key and action parameters. In existing Python code you only change the base URL and the key; the address is shown in the examples above.

Am I charged if the SMS never arrives?

No. The getNumber call only reserves the price. You are charged the moment the SMS code arrives; if you cancel before a code arrives, the supplier cancels, or 20 minutes pass, the reservation is released.

How often should I poll in Python?

Calling getStatus every 5 seconds is enough; do not go below 3 seconds. If you get HTTP 429, double your delay. A webhook is more efficient, and then the loop stays only as a backup.

Can I use asyncio or aiohttp?

Yes. The protocol is plain GET requests, so aiohttp or httpx can replace requests. The logic stays the same: parse the plain text, treat STATUS_WAIT_RETRY as still waiting, and cap the wait at 20 minutes.

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.