SMS-вебхуки с проверкой подписи
Когда приходит SMS-код, Turonay отправляет на ваш сервер подписанный HTTPS POST. Вам больше не нужно минутами опрашивать API, а подлинность каждого запроса вы проверяете подписью HMAC-SHA256. В руководстве — события, тело запроса, алгоритм подписи, правила повторных попыток и код проверки на PHP, Python и Node.js.
Зачем вебхук вместо опроса?
Опрашивать 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. Когда приходит код, вы получаете запрос такого вида:
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{
"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:
{
"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"
}| Поле | Тип | Описание |
|---|---|---|
event | string | Название события: code_received, cancelled или test. |
activationId | string | Идентификатор активации; тот же, что возвращает getNumber. |
phone | string | Номер телефона активации (с кодом страны). |
service | string | Код сервиса (например, wa). |
country | string | Идентификатор страны (например, 62). |
code | string | null | SMS-код; для cancelled — null. |
text | string | null | Текст SMS от оператора; может быть пустым, для cancelled — null. |
receivedAt | string | null | Момент записи кода в UTC в формате ISO 8601 (например, 2026-10-10T11:03:12Z). Для cancelled — null. |
sentAt | string | Момент отправки этой доставки, UTC, ISO 8601. Для точного времени используйте его. |
id | string | Идентификатор доставки (dlv_…); не меняется при повторных попытках. |
Заголовки запроса
В каждом запросе есть перечисленные ниже заголовки. Для проверки подписи нужны только метка времени и подпись; идентификатор доставки нужен для исключения дублей.
| Заголовок | Описание |
|---|---|
Content-Type | Всегда application/json. |
User-Agent | Имя клиента: Turonay-Webhook/1.0. |
X-Turonay-Event | Название события (code_received, cancelled, test). |
X-Turonay-Timestamp | Время отправки, секунды Unix. Входит в подпись. |
X-Turonay-Signature | sha256= + подпись HMAC-SHA256 в шестнадцатеричном виде. |
X-Turonay-Delivery | Идентификатор доставки; равен id в теле и не меняется при повторных попытках. |
Как проверить подпись
Подпись — это HMAC-SHA256, вычисленный с вашим секретом (он начинается с whsec_). Проверка состоит из шести шагов:
- Читайте тело как сырые байты. Не разбирайте JSON и не сериализуйте его заново: при изменении пробелов или порядка ключей подпись не совпадёт.
- Возьмите заголовки
X-Turonay-TimestampиX-Turonay-Signature. - Отклоните запрос, если метка времени отличается от текущего момента больше чем на 5 минут (защита от повторной отправки).
- Составьте строку: метка времени +
.+ сырое тело. - Вычислите HMAC-SHA256 этой строки с вашим секретом в шестнадцатеричном виде и добавьте перед ним
sha256=. - Сравните результат с подписью из заголовка сравнением за постоянное время (timing-safe). Если значения различаются, верните 401.
signature = "sha256=" + hex( HMAC_SHA256( secret, timestamp + "." + rawBody ) )Секрет создаётся автоматически при первом сохранении адреса и показывается полностью только в этот момент, потом он маскируется. Если подозреваете утечку, создайте новый: старый перестаёт работать сразу. Храните секрет в переменной окружения, а не в исходном коде.
Код проверки: PHP, Python, Node.js
Все три примера следуют одним правилам: сырое тело, допуск 5 минут, сравнение за постоянное время и быстрый ответ 2xx. Тяжёлую работу (база данных, второй вызов API) выполняйте после ответа, из очереди; время ответа не должно превышать 5 секунд.
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 secondsPython (Flask)
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 secondsNode.js (Express)
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 | Отклоняется: внутреннее доменное имя |
Включение вебхука в профиле
Вебхук настраивается в профиле ещё до написания кода:
- Войдите в аккаунт, перейдите в Профиль → API → Webhook и откройте блок Webhook.
- Введите адрес (например,
https://api.vashsite.com/turonay-webhook) и сохраните. При первом сохранении автоматически создаётся секрет (whsec_…); скопируйте его сразу, потом он отображается замаскированным. - Нажмите Отправить тест. На ваш адрес уходит событие
testс настоящей подписью, а результат с HTTP-статусом и временем отклика появляется на экране. Между тестами подождите несколько секунд. - Таблица Последние 10 доставок показывает время, событие, активацию, HTTP-статус, номер попытки и пометку об ошибке.
- Чтобы отключить вебхук, сохраните пустое поле. Чтобы заменить секрет, используйте кнопку «Создать новый секрет»; старый перестаёт работать сразу.
Вебхук + getStatus: самая надёжная схема
Сделайте вебхук основным путём, а getStatus — страховкой. Сбой сети, сервер, который не работал во время деплоя, или неудача всех 8 попыток могут привести к пропуску события, поэтому не забывайте об ожидающих активациях.
- При покупке номера сохраните активацию как «ожидает».
- Когда приходит вебхук, сначала исключите дубль по
id, обработайте код и закройте активацию. - Раз в несколько минут вызывайте
getStatusдля всё ещё ожидающих активаций; если вернулсяSTATUS_OK:код, передайте его в тот же обработчик. - При перезапуске приложения восстанавливайте открытые активации через
getActiveActivations.
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-ключ, сохраните адрес и нажмите «Отправить тест». За активации, на которые код не пришёл, вы не платите.