Вебхуки
События, тело и подпись, повторы доставки.
Вебхуки
Укажите https-адрес в кабинете (раздел «Ключ и вебхук») или через PUT /webhook. Секрет подписи выдаётся только в кабинете. Каждое событие приходит POST с JSON-телом и заголовками X-Event, X-Delivery-Id, X-Webhook-Timestamp (unix-время отправки), X-Signature-V2 и X-Signature.
Проверяйте X-Signature-V2: это sha256= и hex от HMAC-SHA256 на вашем секрете по строке <X-Webhook-Timestamp>.<сырое тело>. Сравнивайте constant-time до разбора JSON и отбивайте доставку, если метка расходится с вашими часами больше чем на 5 минут: так перехваченную доставку не переиграть позже. X-Signature (HMAC только от тела) остаётся для тех, кто проверяет её сейчас; её подделать тоже нельзя, но от повтора она не защищает.
В теле всегда есть event (имя события), event_id (уникален для события, по нему дедупьте), occurred_at, schema_version (версия формата тела, сейчас 1) и source, откуда пришло событие: api (ваш вызов), platform (бот или мини-приложение платформы), network (событие сети VPN), test (тестовая отправка). События подписки несут объект subscription в том же виде, что GET /subscriptions/{id}, но без traffic_used_bytes. deposit.credited несёт deposit и balance_after_minor.
События могут прийти не по порядку: повтор старой доставки придёт после свежей. Чтобы не откатить подписку к устаревшему состоянию, храните у себя occurred_at последнего применённого события по каждой подписке и пропускайте более старые.
Догнать пропущенное
Если ваш сервер лежал, ждать повторов не обязательно. GET /events?since=<момент> отдаёт события, которые готовились вашему вебхуку, по порядку и вместе со статусом доставки, тело в payload ровно такое же, как у вебхука. Листайте по next_cursor, пока он не null. События хранятся 30 дней; если вебхук не задан, их нет.
Журнал доставок с ответами вашего сервера есть и в API: GET /webhook/deliveries (фильтры status и event). Повторить одну доставку прямо сейчас: POST /webhook/deliveries/{delivery_id}/redeliver, ответ скажет, что ответил ваш сервер.
События
subscription.createdsubscription.renewedsubscription.upgradedsubscription.traffic_purchasedsubscription.addon_purchasedsubscription.frozensubscription.unfrozensubscription.expires_in_72hsubscription.expires_in_48hsubscription.expires_in_24hsubscription.expiredsubscription.traffic_thresholddeposit.creditedТело события
Версия формата тела. Растёт только при несовместимой смене полей; новые поля добавляются без неё.
{
"event": "subscription.expires_in_24h",
"event_id": "01a06321-0000-7000-8000-000000000002",
"occurred_at": "2026-10-01T12:00:00Z",
"source": "network",
"schema_version": 1,
"subscription": {
"subscription_id": "01a06315-1270-705a-8d4c-58fea607bf2c",
"client_id": "01a06310-8f2b-7c11-9a3e-2b5c7d9e1f00",
"external_user_id": "user-42",
"telegram_id": null,
"bot_id": null,
"source": "api",
"plan_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plan_name": "Standard 30d",
"is_trial": false,
"product": "term",
"is_lifetime": false,
"status": "active",
"subscription_url": "https://api.relayhub.surf/sub/wnT9NRf0Zk6McRRnp2nggc3K",
"days": 30,
"days_left": 1,
"devices": 2,
"traffic_limit_bytes": null,
"auto_renew": false,
"renewal_count": 0,
"renewal_progress": 0,
"created_at": "2026-09-02T12:00:00Z",
"end_date": "2026-10-02T12:00:00Z",
"frozen_at": null,
"price_minor": 450,
"price_usd": "4.50"
}
}Как проверить подпись
import crypto from 'node:crypto'
// raw: сырые байты тела (Buffer), а не распарсенный JSON
function verify(raw, headers, secret) {
const ts = headers['x-webhook-timestamp'] ?? ''
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const signed = Buffer.concat([Buffer.from(ts + '.'), raw])
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(signed).digest('hex')
const a = Buffer.from(expected), b = Buffer.from(headers['x-signature-v2'] ?? '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}