Главная › SMS API › SMS-вебхуки
Webhook-уведомления об OTP

SMS-вебхуки с проверкой подписи

Когда приходит SMS-код, Turonay отправляет на ваш сервер подписанный HTTPS POST. Вам больше не нужно минутами опрашивать API, а подлинность каждого запроса вы проверяете подписью HMAC-SHA256. В руководстве — события, тело запроса, алгоритм подписи, правила повторных попыток и код проверки на PHP, Python и Node.js.

5 сна ответ
8попыток доставки
HMAC-SHA256алгоритм подписи

Зачем вебхук вместо опроса?

Опрашивать getStatus каждые несколько секунд для активации, которая ждёт код, можно, но это дорого: при сотнях параллельных активаций число запросов быстро растёт, вы приближаетесь к лимиту запросов и видите каждый код с задержкой, равной интервалу опроса. Вебхук работает в обратную сторону: как только код записан, уведомление ставится в очередь и обычно доставляется вам в течение 1–2 минут без вашего запроса.

У него три практических плюса: уведомление приходит к вам само (обычно в течение 1–2 минут), число вызовов API (и давление на лимит запросов) падает, а сервер не ждёт впустую. Взамен вы берёте на себя две обязанности: проверять подпись, прежде чем доверять входящему запросу, и учитывать, что одно и то же событие может прийти повторно. Именно об этом разделы ниже.

События

Каждый запрос называет событие в поле event; то же значение передаётся в заголовке X-Turonay-Event.

СобытиеКогда отправляется
code_receivedКогда SMS-код по активации записан. В этот момент плата фиксируется; поля code и text заполнены.
cancelledКогда активация отменена до прихода кода: поставщик отменил номер или вы отправили setStatus со status=8. Заблокированная сумма возвращается, плата не взимается. Поля code, text и receivedAt равны null.
testКогда вы нажимаете «Отправить тест» в профиле. Содержит примерные значения и не связано с реальной активацией.
При тихом истечении времени cancelled не отправляется. Если время активации вышло и никто её не отменял, уведомление не приходит; отслеживайте такие активации сами через getStatus или getActiveActivations (время окончания даёт поле activationEndTime в ответе getNumberV2).

Если по той же активации позже придёт новый код (дополнительное SMS), он доставляется отдельным событием code_received с другим id.

Тело запроса (payload)

Тело — JSON в UTF-8, отправляется с Content-Type: application/json. Когда приходит код, вы получаете запрос такого вида:

http
POST /turonay-webhook HTTP/1.1
Host: api.example.com
Content-Type: application/json
User-Agent: Turonay-Webhook/1.0
X-Turonay-Event: code_received
X-Turonay-Timestamp: 1791630193
X-Turonay-Signature: sha256=3f1c…9ab2
X-Turonay-Delivery: dlv_9f2c4a7d1b3e5f60a8c2d4e6
json
{
  "event": "code_received",
  "activationId": "184213",
  "phone": "905301234567",
  "service": "wa",
  "country": "62",
  "code": "123456",
  "text": "Your WhatsApp code: 123-456",
  "receivedAt": "2026-10-10T11:03:12Z",
  "sentAt": "2026-10-10T11:03:13Z",
  "id": "dlv_9f2c4a7d1b3e5f60a8c2d4e6"
}

В событии отмены те же поля; только code, text и receivedAt равны null:

json
{
  "event": "cancelled",
  "activationId": "184214",
  "phone": "6281234567890",
  "service": "tg",
  "country": "6",
  "code": null,
  "text": null,
  "receivedAt": null,
  "sentAt": "2026-10-10T11:08:40Z",
  "id": "dlv_3b7e1c9a5d2f4860b1c3e5a7"
}
ПолеТипОписание
eventstringНазвание события: code_received, cancelled или test.
activationIdstringИдентификатор активации; тот же, что возвращает getNumber.
phonestringНомер телефона активации (с кодом страны).
servicestringКод сервиса (например, wa).
countrystringИдентификатор страны (например, 62).
codestring | nullSMS-код; для cancelled — null.
textstring | nullТекст SMS от оператора; может быть пустым, для cancelled — null.
receivedAtstring | nullМомент записи кода в UTC в формате ISO 8601 (например, 2026-10-10T11:03:12Z). Для cancelled — null.
sentAtstringМомент отправки этой доставки, UTC, ISO 8601. Для точного времени используйте его.
idstringИдентификатор доставки (dlv_…); не меняется при повторных попытках.

Заголовки запроса

В каждом запросе есть перечисленные ниже заголовки. Для проверки подписи нужны только метка времени и подпись; идентификатор доставки нужен для исключения дублей.

ЗаголовокОписание
Content-TypeВсегда application/json.
User-AgentИмя клиента: Turonay-Webhook/1.0.
X-Turonay-EventНазвание события (code_received, cancelled, test).
X-Turonay-TimestampВремя отправки, секунды Unix. Входит в подпись.
X-Turonay-Signaturesha256= + подпись HMAC-SHA256 в шестнадцатеричном виде.
X-Turonay-DeliveryИдентификатор доставки; равен id в теле и не меняется при повторных попытках.

Как проверить подпись

Подпись — это HMAC-SHA256, вычисленный с вашим секретом (он начинается с whsec_). Проверка состоит из шести шагов:

  1. Читайте тело как сырые байты. Не разбирайте JSON и не сериализуйте его заново: при изменении пробелов или порядка ключей подпись не совпадёт.
  2. Возьмите заголовки X-Turonay-Timestamp и X-Turonay-Signature.
  3. Отклоните запрос, если метка времени отличается от текущего момента больше чем на 5 минут (защита от повторной отправки).
  4. Составьте строку: метка времени + . + сырое тело.
  5. Вычислите HMAC-SHA256 этой строки с вашим секретом в шестнадцатеричном виде и добавьте перед ним sha256=.
  6. Сравните результат с подписью из заголовка сравнением за постоянное время (timing-safe). Если значения различаются, верните 401.
text
signature = "sha256=" + hex( HMAC_SHA256( secret, timestamp + "." + rawBody ) )

Секрет создаётся автоматически при первом сохранении адреса и показывается полностью только в этот момент, потом он маскируется. Если подозреваете утечку, создайте новый: старый перестаёт работать сразу. Храните секрет в переменной окружения, а не в исходном коде.

Код проверки: PHP, Python, Node.js

Все три примера следуют одним правилам: сырое тело, допуск 5 минут, сравнение за постоянное время и быстрый ответ 2xx. Тяжёлую работу (база данных, второй вызов API) выполняйте после ответа, из очереди; время ответа не должно превышать 5 секунд.

PHP

php
<?php
// webhook.php — receives Turonay SMS webhooks
$secret = getenv('TURONAY_WEBHOOK_SECRET');            // whsec_...
$raw    = file_get_contents('php://input');             // RAW bytes: never json_decode() before verifying
$ts     = $_SERVER['HTTP_X_TURONAY_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_X_TURONAY_SIGNATURE'] ?? '';

// 1) replay protection: reject anything more than 5 minutes away from now
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) { http_response_code(400); exit; }

// 2) signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody)
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig)) { http_response_code(401); exit; }   // constant-time compare

// 3) de-duplicate on the delivery id (identical across all retries)
$event = json_decode($raw, true);
$stmt  = $pdo->prepare('INSERT IGNORE INTO webhook_deliveries (id) VALUES (?)');   // $pdo = your PDO connection
$stmt->execute([$event['id']]);
if ($stmt->rowCount() === 0) { http_response_code(200); exit; }          // already processed

if ($event['event'] === 'code_received') {
    // $event['activationId'], $event['code'], $event['text'] ...
}
http_response_code(200);                                // answer within 5 seconds

Python (Flask)

python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["TURONAY_WEBHOOK_SECRET"].encode()   # whsec_...
seen = set()                                              # use Redis / a unique DB column in production


@app.post("/turonay-webhook")
def turonay_webhook():
    raw = request.get_data()                              # RAW bytes
    ts = request.headers.get("X-Turonay-Timestamp", "")
    sig = request.headers.get("X-Turonay-Signature", "")

    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(400)                                        # replay protection (5 min)

    expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), sig.encode()):   # constant-time compare
        abort(401)

    event = request.get_json(force=True)
    if event["id"] in seen:                               # idempotency: same id on every retry
        return "", 200
    seen.add(event["id"])

    if event["event"] == "code_received":
        print(event["activationId"], event["code"])
    return "", 200                                        # answer within 5 seconds

Node.js (Express)

js
const crypto = require("crypto");
const express = require("express");

const app = express();
const SECRET = process.env.TURONAY_WEBHOOK_SECRET;       // whsec_...
const seen = new Set();                                   // use Redis / a unique DB column in production

// express.raw keeps the body as a Buffer: the signature covers the exact bytes
app.post("/turonay-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-Turonay-Timestamp") || "";
  const sig = req.get("X-Turonay-Signature") || "";

  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.sendStatus(400);                           // replay protection (5 min)
  }

  const expected = "sha256=" + crypto.createHmac("sha256", SECRET)
    .update(ts + ".").update(req.body).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);                           // constant-time compare
  }

  const event = JSON.parse(req.body.toString("utf8"));
  if (seen.has(event.id)) return res.sendStatus(200);     // idempotency: same id on every retry
  seen.add(event.id);

  if (event.event === "code_received") console.log(event.activationId, event.code);
  res.sendStatus(200);                                    // answer within 5 seconds
});

app.listen(3000);

Политика повторных попыток

Ваш сервер должен ответить в течение 5 секунд статусом 2xx. Любой другой статус, тайм-аут или ошибка соединения считаются неудачей; тело ответа не читается. Перенаправления (3xx) не выполняются и тоже считаются неудачей.

Неудачная доставка повторяется не более 8 раз. Пауза начинается с 1 минуты и удваивается при каждой попытке до предела в 1 час (1, 2, 4, 8, 16, 32, 60 минут; всего около двух часов). Если все попытки неудачны, событие отбрасывается. Постоянные ошибки, например отклонение адреса, не повторяются.

Каждая попытка записывается в список «последние 10 доставок» в профиле со статусом HTTP и пометкой об ошибке; при проблемах смотрите сначала туда.

Повторы событий и идемпотентность

Доставка работает по принципу «как минимум один раз»: если ваш ответ не пришёл вовремя, то же событие придёт снова. Поле id каждой доставки (и заголовок X-Turonay-Delivery) одинаково во всех попытках, поэтому это идеальный ключ для исключения дублей.

Практическое правило: храните id в столбце с уникальным индексом; если запись уже есть, пропустите обработку, но всё равно верните 200. Порядок событий не гарантируется, события разных активаций могут перемешиваться.

Если вы читаете код ещё и через getStatus, исключайте дубли и по activationId: один и тот же код может дойти до вас и опросом, и вебхуком.

Правила безопасности для адреса вебхука

Ваш адрес заставляет наши серверы отправлять запрос, поэтому мы применяем строгие правила, не позволяющие направить наши серверы во внутренние сети. Правила проверяются при сохранении адреса и перед каждой доставкой. Адрес должен соответствовать всем условиям:

  • Только https://. Обычный http:// отклоняется, а TLS-сертификат должен быть действительным.
  • Только порт 443 или 8443. Если порт не указан, используется 443.
  • Не более 500 символов и без имени пользователя и пароля (user:pass@).
  • Хост должен быть доменным именем. IP-адреса (включая десятичную, восьмеричную и шестнадцатеричную записи), localhost, имена из одной метки и внутренние зоны вроде .local, .internal или .test отклоняются; домен не должен оканчиваться точкой.
  • Все IP-адреса, в которые разрешается домен, должны быть публичными. Частные сети, loopback, link-local, CGNAT, multicast, зарезервированные и документационные диапазоны, а также адреса наших собственных серверов отклоняются.
  • Собственный домен Turonay отклоняется, чтобы мы не зацикливались на собственном API.
  • Мы подключаемся строго к проверенному IP (защита от DNS rebinding) и не следуем перенаправлениям. Если ваш адрес перенаправляет, укажите конечный URL напрямую.
Пример адресаРезультат
https://api.example.com/turonay-webhookПодходит
http://api.example.com/hookОтклоняется: не https
https://203.0.113.10/hookОтклоняется: IP-адрес, а не доменное имя
https://api.example.com:8080/hookОтклоняется: порт не разрешён
https://user:pass@api.example.com/hookОтклоняется: содержит учётные данные
https://localhost/hookОтклоняется: внутреннее доменное имя

Включение вебхука в профиле

Вебхук настраивается в профиле ещё до написания кода:

  1. Войдите в аккаунт, перейдите в Профиль → API → Webhook и откройте блок Webhook.
  2. Введите адрес (например, https://api.vashsite.com/turonay-webhook) и сохраните. При первом сохранении автоматически создаётся секрет (whsec_…); скопируйте его сразу, потом он отображается замаскированным.
  3. Нажмите Отправить тест. На ваш адрес уходит событие test с настоящей подписью, а результат с HTTP-статусом и временем отклика появляется на экране. Между тестами подождите несколько секунд.
  4. Таблица Последние 10 доставок показывает время, событие, активацию, HTTP-статус, номер попытки и пометку об ошибке.
  5. Чтобы отключить вебхук, сохраните пустое поле. Чтобы заменить секрет, используйте кнопку «Создать новый секрет»; старый перестаёт работать сразу.

Вебхук + getStatus: самая надёжная схема

Сделайте вебхук основным путём, а getStatus — страховкой. Сбой сети, сервер, который не работал во время деплоя, или неудача всех 8 попыток могут привести к пропуску события, поэтому не забывайте об ожидающих активациях.

  1. При покупке номера сохраните активацию как «ожидает».
  2. Когда приходит вебхук, сначала исключите дубль по id, обработайте код и закройте активацию.
  3. Раз в несколько минут вызывайте getStatus для всё ещё ожидающих активаций; если вернулся STATUS_OK:код, передайте его в тот же обработчик.
  4. При перезапуске приложения восстанавливайте открытые активации через getActiveActivations.
python
import requests

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


def sweep(waiting_ids):          # run every few minutes
    for act_id in waiting_ids:
        text = requests.get(API, params={
            "api_key": "YOUR_API_KEY", "action": "getStatus", "id": act_id,
        }, timeout=15).text
        if text.startswith("STATUS_OK:"):
            handle_code(act_id, text.split(":", 1)[1])   # the same handler the webhook uses

Так вебхук доставляет уведомление (обычно в течение 1–2 минут), а опрос закрывает редкие пропуски; интервал опроса можно держать большим, поэтому число вызовов API остаётся небольшим. Примеры для конкретных языков — в руководствах Python, PHP и Node.js, все методы — в Документация API.

Частые вопросы

Что такое вебхук в SMS API и чем он отличается от опроса?

Вебхук — это подписанный HTTPS POST, который Turonay ставит в очередь для вашего сервера, как только код записан; обычно он доставляется в течение 1–2 минут. При опросе вы снова и снова вызываете getStatus, чтобы увидеть код; при вебхуке событие приходит к вам само. Это сокращает число вызовов API.

Какие события отправляются?

Отправляются code_received (SMS-код записан) и cancelled (отмена до прихода кода); кнопка теста в профиле отправляет событие test. Для активаций, которые просто истекли без чьей-либо отмены, уведомление cancelled не отправляется; отслеживайте их через getStatus.

Как проверить подпись?

Соедините значение X-Turonay-Timestamp, точку и сырое тело запроса; вычислите HMAC-SHA256 этой строки с вашим секретом в шестнадцатеричном виде и добавьте перед ним sha256=. Сравните результат с заголовком X-Turonay-Signature сравнением за постоянное время. Если метка времени старше 5 минут, запрос нужно отклонить.

Что будет, если мой ответ опоздает?

Если сервер не вернул 2xx за 5 секунд, доставка считается неудачной и повторяется до 8 попыток всего, с паузами, которые начинаются с 1 минуты и удваиваются до 1 часа. То же событие приходит с тем же id, поэтому исключайте дубли по id.

Почему мой адрес вебхука отклоняется?

Адрес должен быть https, использовать порт 443 или 8443, не содержать учётных данных и быть доменным именем, которое разрешается только в публичные IP-адреса. IP-адреса, localhost, внутренние доменные имена и адреса с перенаправлением не принимаются. Для локальной разработки можно использовать туннельный сервис.

Нужен ли getStatus, если я использую вебхуки?

Рекомендуется. Вебхук — основной путь, а getStatus — резерв на случай пропущенных событий и перезапусков. Время от времени проверяйте ожидающие активации через getStatus.

Что делать, если секрет утёк?

Создайте новый секрет в разделе API профиля. Старый перестанет работать сразу; немедленно обновите значение на своём сервере, иначе проверка подписи будет завершаться ошибкой.

Попробуйте вебхук сейчас

Создайте бесплатный аккаунт, получите API-ключ, сохраните адрес и нажмите «Отправить тест». За активации, на которые код не пришёл, вы не платите.