Уведомления через вебхуки
Откажитесь от опроса. Мы отправляем SMS код на ваш сервер сразу после получения, с подписью и повторными попытками.
Зачем вебхуки
Опрос тратит запросы и добавляет задержку. Вебхук доходит до вашего сервера в ту же секунду, когда приходит код.
- Никакого цикла опроса: дневная квота запросов уходит на реальную работу, а не на проверку статуса.
- Меньше задержка: код попадает в вашу систему сразу, а не при следующем опросе.
- Терминальные события тоже приходят: отмена и истечение срока приходят со статусом возврата, ваш учёт остаётся в синхроне.
Настройка
Зарегистрируйте один URL на аккаунт с помощью API ключа. Секрет подписи возвращается один раз.
curl -X POST https://smsbulk.net/api/users/webhook \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-app.com/smsbulk/webhook"}'Ответ
{
"id": "wep_2f8c1a9d",
"url": "https://your-app.com/smsbulk/webhook",
"sourceFilter": "API",
"secret": "whsec_5f3a9c1e8b2d47a6903c5e1f7d8b4a26"
}Сохраните секрет сразу
Открытый секрет показывается только в этом ответе. Последующие чтения возвращают маскированное значение. Если потеряли, обновите секрет в панели или через endpoint rotate.
Требования к URL
- HTTPS на порту 443. Обычный HTTP и другие порты отклоняются.
- Публичный адрес. Частные диапазоны, loopback и адреса облачных метаданных отклоняются при регистрации и повторно при каждой доставке.
- Без редиректов. Ответ 3xx не отслеживается и считается неудачной попыткой.
| Метод | Путь | Назначение |
|---|---|---|
| POST | /api/users/webhook | Регистрирует или заменяет endpoint, один раз возвращает секрет |
| GET | /api/users/webhook | Читает текущий endpoint, секрет маскирован |
| PATCH | /api/users/webhook | Меняет URL или фильтр канала, включает отключённый endpoint |
| POST | /api/users/webhook/rotate | Обновляет секрет подписи с переходным окном |
| POST | /api/users/webhook/test | Отправляет тестовое событие без данных активации |
| DELETE | /api/users/webhook | Удаляет endpoint и останавливает все уведомления |
| GET | /api/users/webhook/deliveries | Последние 20 попыток доставки, только метаданные |
События
Три события, все об одной активации.
| Событие | Когда отправляется |
|---|---|
| activation.code_received | Когда приходит SMS код. На это событие реагирует большинство интеграций. |
| activation.cancelled | Когда активация отменена. В теле указана причина и был ли возврат средств. |
| activation.expired | Когда время активации истекло без кода. В теле указан статус возврата. |
Фильтр канала
По умолчанию уведомления создают только заказы через API. Укажите sourceFilter равным null, если нужны и заказы из панели.
Запрос и тело
Каждая доставка это POST с телом JSON. Поля выбираются по одному: внутренняя маршрутизация и детали себестоимости никогда не попадают в тело.
Форма запроса
POST /smsbulk/webhook HTTP/1.1
Content-Type: application/json
User-Agent: SMSBulk-Webhook/1
X-SMSBulk-Signature: t=1785312000,v1=8f2a...c41d
X-SMSBulk-Event-Id: evt_del_9f2c1a
X-SMSBulk-Event-Type: activation.code_received
X-SMSBulk-Delivery-Attempt: 1activation.code_received
{
"id": "evt_del_9f2c1a",
"type": "activation.code_received",
"created": 1785312000,
"data": {
"activation_id": "cms2u6x80d1twmkudp2cfbqo4",
"status": "RECEIVED",
"phone_number": "447700900123",
"service": "wa",
"country": "GB",
"code": "483920",
"price": "0.42",
"currency": "USD",
"source": "API",
"created_at": "2026-08-04T09:12:00.000Z",
"sms_text": "Your code is 483920",
"received_at": "2026-08-04T09:12:34.000Z"
}
}activation.cancelled и activation.expired
{
"id": "evt_del_7b31de",
"type": "activation.cancelled",
"created": 1785312600,
"data": {
"activation_id": "cms2u6x80d1twmkudp2cfbqo4",
"status": "CANCELLED",
"phone_number": "447700900123",
"service": "wa",
"country": "GB",
"code": null,
"price": "0.42",
"currency": "USD",
"source": "API",
"created_at": "2026-08-04T09:12:00.000Z",
"reason": "user_cancel",
"refunded": true,
"refund_amount": "0.42"
}
}В будущих версиях могут появиться новые поля. Игнорируйте незнакомые поля вместо того, чтобы падать на них.
Проверка подписи
Каждый запрос несёт подпись HMAC SHA256, построенную из метки времени и сырого тела.
X-SMSBulk-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>
signed_string = "<t>.<raw_request_body>"Проверяйте сырые байты
Считайте подпись по точному телу, которое получили. Если разобрать JSON и собрать его заново, порядок ключей и пробелы изменятся, и подпись не совпадёт.
Значений v1 может быть несколько
Во время смены секрета заголовок несёт два значения v1. Собирайте их в список, а не в словарь: словарь оставит только последнее, и новый секрет тихо останется непроверенным.
Node.js
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const pairs = header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i), p.slice(i + 1)];
});
const t = pairs.find(([k]) => k === 't')?.[1];
// During a rotate window more than one signature arrives: collect them all.
const signatures = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || signatures.length === 0) return false;
// Replay window: reject anything older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return signatures.some(
(s) =>
s.length === expected.length &&
crypto.timingSafeEqual(
Buffer.from(s, 'hex'),
Buffer.from(expected, 'hex'),
),
);
}Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
pairs = [p.split("=", 1) for p in header.split(",")]
t = next((v for k, v in pairs if k == "t"), None)
# During a rotate window more than one signature arrives: collect them all.
signatures = [v for k, v in pairs if k == "v1"]
if t is None or not signatures:
return False
# Replay window: reject anything older than 5 minutes.
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)Метка времени входит в подписываемую строку, поэтому можно отклонять уведомления старше нескольких минут. Сравнивайте подписи за постоянное время.
Ответ и повторы
Отвечайте быстро. Мы ждём ответ 10 секунд.
Делайте
- Возвращайте 2xx сразу после сохранения события.
- Ставьте тяжёлую работу в очередь и отвечайте первым делом.
- Считайте свой 4xx ошибкой: он останавливает повторы.
Не делайте
- Не запускайте долгие задачи до ответа, запрос истекает через 10 секунд.
- Не отвечайте 3xx, редиректы не отслеживаются.
- Не формируйте большое тело ответа, оно отбрасывается без чтения.
| Попытка | Пауза перед | activation.code_received | cancelled и expired |
|---|---|---|---|
| 1 | сразу | ||
| 2 | 10s | ||
| 3 | 60s | ||
| 4 | 5m | ||
| 5 | 15m | ||
| 6 | 30m |
Повторы происходят при сетевых ошибках, тайм-аутах, 408, 429 и 5xx. Любой другой 4xx сразу останавливает лестницу. code_received останавливается на 4 попытках, потому что активация завершается задолго до поздних шагов.
Дедупликация
Используйте X-SMSBulk-Event-Id как ключ идемпотентности. Повторы того же события приходят с тем же id.
Как минимум один раз, а не ровно один раз
На активацию и тип события создаётся одна запись, поэтому дубликаты редки. Но восстановление зависшей задачи может доставить ту же запись повторно. Сохраняйте id события и игнорируйте уже обработанный id.
Предохранитель
Мёртвый endpoint не вызывается бесконечно.
- После 20 подряд неудачных доставок endpoint отключается.
- Вы получаете письмо, а в панели видна причина отключения.
- События, созданные при отключённом endpoint, не отправляются повторно. Догоняйте через GET /v1/activations.
Смена секрета
Смените секрет подписи без простоя.
- Endpoint rotate один раз возвращает новый секрет.
- В переходном окне отправляются и новая, и предыдущая подпись.
- Переходите на новый секрет в своём темпе, после окна отправляется только новая подпись.
Настройте свой endpoint
Зарегистрируйте URL, отправьте тестовое событие и следите за списком доставок в панели.
