Home › SMS API › SMS API in PHP
PHP · cURL · SMS verification API

Receive SMS Codes with PHP

Here is a complete, copyable example of using the Turonay SMS verification API from PHP with the cURL extension: buy a virtual number, read the verification code that arrives, then close or cancel the activation. Because Turonay uses the handler_api layout, the same PHP code is compatible with existing scripts and packages that know that protocol.

The example needs no Composer package and runs on PHP 7.4 and later; the cURL extension is enough. It starts with a balance call, then adds the full flow, the JSON endpoints, a webhook receiver and error handling. For a general overview see SMS API, and for all endpoints see API reference.

What you need

Get these ready before trying the code:

  • A Turonay API key: sign in and copy it from the API section of your profile. Read it in PHP from an environment variable with getenv() and never commit it to a repository.
  • Balance: when a number is bought its price is reserved from your balance. All amounts are in Turkish lira (TRY).
  • PHP 7.4 or newer with the curl extension enabled (shared hosting usually ships it).
When you are charged: getNumber only reserves the price, and the real charge happens when the SMS code arrives. If you cancel before a code arrives or the 20 minute window expires, the reservation is released and you pay nothing. After a code has arrived, cancelling is refused with EARLY_CANCEL_DENIED.

Setup: check the cURL extension

You do not need to install any package. From the command line confirm your PHP version and that the curl extension is loaded, then put the API key into an environment variable. If the code will run on a web server, define the same variable in your PHP-FPM or virtual host configuration.

bash
php -v                          # PHP 7.4 or newer (8.x recommended)
php -m | grep -i curl           # the curl extension must be listed
export TURONAY_API_KEY="paste-your-api-key-here"

First call: read your balance

The quickest way to test the connection is a getBalance request. The response is plain text, not JSON: success looks like ACCESS_BALANCE:125.4000 and a wrong key returns BAD_KEY. The body may end with a newline, so remember to use trim().

php
<?php
$url = 'https://turonay.com/stubs/handler_api.php?' . http_build_query([
    'api_key' => getenv('TURONAY_API_KEY'),
    'action'  => 'getBalance',
]);

$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20]);
$text = trim((string) curl_exec($ch));   // plain text, not JSON: "ACCESS_BALANCE:125.4000"

if (strpos($text, 'ACCESS_BALANCE:') === 0) {
    echo 'Balance (TRY): ', substr($text, strlen('ACCESS_BALANCE:')), PHP_EOL;
} else {
    echo 'Error: ', $text, PHP_EOL;       // e.g. BAD_KEY
}

Full flow in PHP: get a number, poll the code, finish

The file below contains the entire life cycle. 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 PHP reference client, so turning it into a class later is easy. Run it from the command line with php sms_flow.php.

php
<?php
declare(strict_types=1);

// sms_flow.php - buy a number, wait for the SMS code, finish or cancel.  Needs ext-curl.
const API = 'https://turonay.com/stubs/handler_api.php';

/** The API answered with a plain-text error code (NO_BALANCE, NO_NUMBERS, ...). */
class ApiError extends RuntimeException {}
/** Network trouble, HTTP 429 or 5xx, NO_CONNECTION: safe to retry later. */
class Transient extends ApiError {}
class CodeTimeout extends RuntimeException {}

function apiCall(string $action, array $params = []): string
{
    $key = (string) getenv('TURONAY_API_KEY');
    $params = array_filter($params, static function ($v) { return $v !== null; });
    $query = ['api_key' => $key, 'action' => $action] + $params;

    $ch = curl_init(API . '?' . http_build_query($query));
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 20,
    ]);
    $body = curl_exec($ch);
    $http = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    if ($body === false || $http !== 200) {
        throw new Transient('HTTP ' . $http);        // never print the URL: it holds your api_key
    }
    $text = trim((string) $body);                    // always trim: bodies are plain text
    if ($text === 'NO_CONNECTION') {
        throw new Transient($text);
    }
    return $text;
}

function getBalance(): string
{
    $text = apiCall('getBalance');
    if (strpos($text, 'ACCESS_BALANCE:') !== 0) {
        throw new ApiError($text);
    }
    return substr($text, strlen('ACCESS_BALANCE:'));
}

/** @return array [activationId, phone]. The price is only RESERVED here, not charged. */
function getNumber(string $service, string $country, ?float $maxPrice = null): array
{
    $text = apiCall('getNumber', [
        'service'  => $service,
        'country'  => $country,
        'maxPrice' => $maxPrice === null ? null : number_format($maxPrice, 2, '.', ''),
    ]);
    $parts = explode(':', $text);
    if ($parts[0] === 'ACCESS_NUMBER' && count($parts) >= 3 && $parts[1] !== '' && $parts[2] !== '') {
        return [$parts[1], $parts[2]];
    }
    throw new ApiError($text);   // NO_BALANCE, NO_NUMBERS, BAD_SERVICE, WRONG_MAX_PRICE:<price> ...
}

/** @return array [state, code] where state is 'wait', 'ok' or 'cancel'. */
function getStatus(string $id): array
{
    $text = apiCall('getStatus', ['id' => $id]);
    if (strpos($text, 'STATUS_OK:') === 0) {
        return ['ok', substr($text, strlen('STATUS_OK:'))];
    }
    if ($text === 'STATUS_CANCEL') {
        return ['cancel', null];
    }
    if ($text === 'STATUS_WAIT_CODE' || $text === 'STATUS_WAIT_RESEND' || strpos($text, 'STATUS_WAIT_RETRY:') === 0) {
        return ['wait', null];   // WAIT_RETRY carries the OLD code: still waiting for the new one
    }
    throw new ApiError($text);   // NO_ACTIVATION, BAD_KEY ...
}

function setStatus(string $id, int $status): string
{
    return apiCall('setStatus', ['id' => $id, 'status' => $status]);
}

function requestAnotherSms(string $id): string { return setStatus($id, 3); }  // free, ACCESS_RETRY_GET
function finish(string $id): string { return setStatus($id, 6); }              // ACCESS_ACTIVATION, only after a code
function cancel(string $id): string { return setStatus($id, 8); }              // ACCESS_CANCEL / EARLY_CANCEL_DENIED

/** Poll every $interval seconds for at most $timeout seconds (20 minutes). */
function waitForCode(string $id, int $timeout = 1200, int $interval = 5): string
{
    $deadline = microtime(true) + $timeout;
    $delay = $interval;
    while (microtime(true) < $deadline) {
        try {
            [$state, $code] = getStatus($id);
            $delay = $interval;
            if ($state === 'ok') {
                return (string) $code;
            }
            if ($state === 'cancel') {
                throw new ApiError('STATUS_CANCEL');
            }
        } catch (Transient $e) {
            $delay = min($delay * 2, 60);            // back off on HTTP 429 / network trouble
        }
        $left = $deadline - microtime(true);
        if ($left <= 0) {
            break;
        }
        sleep((int) max(1, min($delay, ceil($left))));
    }
    throw new CodeTimeout("no SMS within $timeout seconds");
}

function main(): int
{
    [$service, $country] = ['wa', '62'];             // WhatsApp, Turkey (see getPrices / getCountries)
    try {
        echo 'Balance (TRY): ', getBalance(), PHP_EOL;
        [$id, $phone] = getNumber($service, $country, 15.00);
    } catch (Transient $e) {
        echo 'Temporary problem, try again later: ', $e->getMessage(), PHP_EOL;
        return 1;
    } catch (ApiError $e) {
        $reason = $e->getMessage();
        if (strpos($reason, 'WRONG_MAX_PRICE') === 0) {
            $pos = strpos($reason, ':');
            echo 'Current price is above your limit: ', $pos === false ? '?' : substr($reason, $pos + 1), PHP_EOL;
        } elseif ($reason === 'NO_NUMBERS') {
            echo 'No numbers in stock right now, try again in a minute', PHP_EOL;
        } elseif ($reason === 'NO_BALANCE') {
            echo 'Not enough balance, top up first', PHP_EOL;
        } else {
            echo 'API error: ', $reason, PHP_EOL;
        }
        return 1;
    }

    echo "Use this number: $phone (activation $id)", PHP_EOL;
    try {
        $code = waitForCode($id);
    } catch (CodeTimeout $e) {
        $reply = cancel($id);                        // nothing is charged when no code arrived
        if ($reply !== 'EARLY_CANCEL_DENIED') {
            echo "Timed out, activation cancelled: $reply", PHP_EOL;
            return 1;
        }
        [, $code] = getStatus($id);                  // the code arrived in the last second
    } catch (ApiError $e) {                          // STATUS_CANCEL: the supplier cancelled, nothing charged
        echo 'Activation cancelled: ', $e->getMessage(), PHP_EOL;
        return 1;
    }

    echo "SMS code: $code", PHP_EOL;
    echo finish($id), PHP_EOL;                       // ACCESS_ACTIVATION
    return 0;
}

exit(main());

The main decisions in the code:

  • If the current price exceeds your maxPrice, no number is bought and WRONG_MAX_PRICE:<price> is returned; you can read the price and raise the limit deliberately.
  • NO_NUMBERS is a temporary stock problem and NO_BALANCE needs a top-up. Both are caught as ApiError, while network and rate-limit problems are separated as Transient.
  • STATUS_WAIT_CODE and STATUS_WAIT_RETRY:<old code> both mean "wait"; the code in the second belongs to the previous SMS and must not be used as the new one.
  • After 20 minutes the activation is cancelled by sending 8 through setStatus; if the code arrived at the last moment the EARLY_CANCEL_DENIED reply makes the script read the code again. Once a code is received the activation is closed with 6.

JSON endpoints: getNumberV2 and getStatusV2

If you prefer named fields, getNumberV2 returns JSON that json_decode turns into an array: activationId, phoneNumber, activationCost, currency, activationEndTime. When a code arrives getStatusV2 returns JSON with sms.code and sms.text. Keep two details in mind: error codes are plain text on V2 as well, and until a code exists getStatusV2 returns plain-text states such as STATUS_WAIT_CODE. So if the json_decode result is not an array, treat the body as text. activationEndTime is in server time (Europe/Istanbul, UTC+3), formatted Y-m-d H:i:s without a time zone.

php
function getNumberV2(string $service, string $country, ?float $maxPrice = null): array
{
    $text = apiCall('getNumberV2', [
        'service'  => $service,
        'country'  => $country,
        'maxPrice' => $maxPrice === null ? null : number_format($maxPrice, 2, '.', ''),
    ]);
    $data = json_decode($text, true);
    if (!is_array($data)) {
        throw new ApiError($text);   // errors stay plain text, even in V2
    }
    return $data;                    // activationId, phoneNumber, activationCost, activationEndTime ...
}

/** @return array|string array with the SMS once it arrived, otherwise the plain-text status. */
function getStatusV2(string $id)
{
    $text = apiCall('getStatusV2', ['id' => $id]);
    $data = json_decode($text, true);
    if (!is_array($data)) {
        return $text;                // STATUS_WAIT_CODE, STATUS_WAIT_RETRY:<old>, STATUS_CANCEL ...
    }
    return ['code' => $data['sms']['code'] ?? null, 'text' => $data['sms']['text'] ?? null];
}

$order = getNumberV2('wa', '62', 15.00);
echo $order['activationId'], ' ', $order['phoneNumber'], ' ', $order['activationCost'],
     ' until ', $order['activationEndTime'], PHP_EOL;

Requesting another SMS (setStatus 3)

If the first SMS did not help or the service wants a second verification, you can request a new SMS on the same number. Pass 3 to setStatus: it is free and you pay only once per activation. The reply is ACCESS_RETRY_GET; until the new SMS arrives getStatus returns STATUS_WAIT_RETRY with the old code, and waitForCode() counts that as waiting. Close the activation with 6 only after the last code.

php
// The first code has arrived and you need a second SMS on the same number (free).
$firstCode = waitForCode($id);
$reply = 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".
    $secondCode = waitForCode($id);
    echo "First: $firstCode Second: $secondCode", PHP_EOL;
}
finish($id);                                   // close only after the LAST code

Webhook receiver: plain PHP and signature verification

If you want Turonay to notify you 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) your address receives a signed JSON POST. The signature is in the X-Turonay-Signature header and equals sha256= + HMAC-SHA256(secret, timestamp + "." + raw body); the timestamp comes in the X-Turonay-Timestamp header.

php
<?php
declare(strict_types=1);

// turonay_webhook.php - public HTTPS endpoint on YOUR server (port 443 or 8443).
$secret = (string) getenv('TURONAY_WEBHOOK_SECRET');      // whsec_... from your profile page

/** sha256=HMAC_SHA256(secret, timestamp . "." . raw body), 5 minute replay window. */
function verifyWebhookSignature(string $secret, string $timestamp, string $rawBody, string $signature, int $tolerance = 300): bool
{
    if (!preg_match('/^[0-9]{1,12}$/', $timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, trim($signature));      // constant time
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    http_response_code(405);
    exit;
}
if ($secret === '') {
    http_response_code(500);
    exit;
}

$raw = (string) file_get_contents('php://input');         // the exact bytes, before json_decode
if (strlen($raw) > 65536 || !verifyWebhookSignature(
    $secret,
    $_SERVER['HTTP_X_TURONAY_TIMESTAMP'] ?? '',
    $raw,
    $_SERVER['HTTP_X_TURONAY_SIGNATURE'] ?? ''
)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
if (!is_array($event)) {
    http_response_code(400);
    exit;
}

// Retries repeat the same "id": store it (unique index) and skip events you already handled.
switch ($event['event'] ?? '') {
    case 'code_received':
        error_log("code {$event['code']} for activation {$event['activationId']} ({$event['phone']})");
        break;
    case 'cancelled':
        error_log("activation {$event['activationId']} cancelled, nothing charged");
        break;
}

http_response_code(200);                                  // answer 2xx within 5 seconds

In PHP read the raw body with php://input, verify it before calling json_decode, compare with hash_equals to resist timing attacks, and reject timestamps older than 5 minutes. If you do not answer 2xx within 5 seconds the delivery is retried (up to 8 times), so do not process the same id twice. Push long jobs to a queue and return 200 right away. See Webhooks for all event fields.

Error codes

Application errors arrive as plain text with HTTP 200; network trouble, 429 and 5xx are turned into the Transient exception and can be retried safely. The table below lists the codes you may see in the response body and the recommended reaction.

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 script at Turonay

If you have a PHP script or package that uses the handler_api layout, in most integrations it is enough to switch the base URL to Turonay's /stubs/handler_api.php address and the key to your Turonay key; still, test the flow before going live. If the address is hard-coded, moving it into one configuration value will make future changes easier too.

php
<?php
// Before: define('SMS_API_URL', 'https://provider.example.com/stubs/handler_api.php');
define('SMS_API_URL', getenv('SMS_API_URL') ?: 'https://turonay.com/stubs/handler_api.php');
define('SMS_API_KEY', (string) getenv('SMS_API_KEY'));   // your Turonay API key

// Scripts and libraries that build their own query string keep working unchanged:
// SMS_API_URL . '?api_key=' . SMS_API_KEY . '&action=getBalance'

After the switch remember that prices are in Turkish lira, and that service codes and country ids must match the output of getPrices and getCountries. For current prices see API pricing.

Frequently asked questions

How do I use a handler_api compatible API from PHP?

Turonay speaks the handler_api protocol. With cURL 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 have a PHP script, you only change the base URL and the key.

Can I use file_get_contents instead of cURL in PHP?

You can, but cURL exposes timeouts, connect time and the HTTP status code more clearly. cURL is recommended so you can tell an HTTP 429 rate limit apart from network errors.

Is money taken if the code never comes?

No. getNumber only makes a reservation. You are charged when the SMS code arrives; if you cancel before a code arrives, the supplier cancels, or 20 minutes pass, the amount returns to your available balance.

Does the polling loop work on shared hosting?

It works as a command-line script (cron or SSH); waiting 20 minutes inside a web request will hit a timeout. For web apps it is better to use a webhook or move polling into a background job.

How do I request a second SMS on the same number?

Pass status=3 to setStatus. It is free and you pay only once per activation. The reply is ACCESS_RETRY_GET; keep waiting for the new code with getStatus and close the activation with status=6 after the last code.