Вебхуки
Подписка с delivery: webhook присылает новые события на ваш https-адрес пачками до 100
штук. Каждая пачка подписана: проверяйте подпись, а не адрес отправителя.
Запрос
POST https://example.ru/hooks/tg
Content-Type: application/json
X-Webhook-Subscription: 5b0c7e1a-…
X-Webhook-Delivery: 7f1a…
X-Webhook-Timestamp: 1790000000
X-Webhook-Signature: sha256=<hex>[, sha256=<hex старым секретом>]
{"events": [{"id": "18446", "kind": "post", "chat": {…}, "message": {…}, "received_at": "…"}]}
| заголовок | смысл |
|---|---|
X-Webhook-Subscription | subscription_id подписки |
X-Webhook-Delivery | id попытки: входит в подпись и виден в журнале. Для отсева дублей не годится — после неудачи пачка собирается заново и id может смениться |
X-Webhook-Timestamp | время отправки, секунды Unix |
X-Webhook-Signature | одна или две подписи через запятую (две — сутки после смены секрета) |
Проверка подписи
Подпись — HMAC-SHA256(секрет, timestamp + "." + delivery_id + "." + тело) в hex. Пачка подлинная,
если:
- хотя бы одна подпись из заголовка совпала (сравнение — за постоянное время);
timestampотличается от вашего времени не больше чем на 5 минут.
Дубли отсеивайте по id события внутри events[]: после неудачи пачка собирается заново,
в неё могут войти и новые события, так что повтор может прийти с другим delivery_id и другим составом.
Событие, которое вы уже обработали, пропустите.
Тело берите как пришло, байтами — до разбора JSON: после разбора и обратной сборки подпись не сойдётся.
Секрет (whs_…) приходит один раз в ответе на создание подписки или на
rotate_webhook_secret: true.
import hashlib
import hmac
import time
def verify_webhook(secret: str, headers, body: bytes) -> bool:
"""headers — заголовки запроса (без учёта регистра), body — тело как пришло, байтами."""
ts = headers.get("X-Webhook-Timestamp", "")
delivery = headers.get("X-Webhook-Delivery", "")
signature = headers.get("X-Webhook-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
want = hmac.new(secret.encode(), f"{ts}.{delivery}.".encode() + body, hashlib.sha256).hexdigest()
return any(part.strip().startswith("sha256=") and hmac.compare_digest(part.strip()[7:], want)
for part in signature.split(","))
const crypto = require("crypto");
// headers — заголовки запроса (в Node имена в нижнем регистре), rawBody — Buffer с телом как пришло
function verifyWebhook(secret, headers, rawBody) {
const ts = headers["x-webhook-timestamp"] || "";
const delivery = headers["x-webhook-delivery"] || "";
const signature = headers["x-webhook-signature"] || "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = crypto.createHmac("sha256", secret)
.update(`${ts}.${delivery}.`).update(rawBody).digest("hex");
return signature.split(",").map((s) => s.trim()).some((s) =>
s.startsWith("sha256=") && s.length === want.length + 7 &&
crypto.timingSafeEqual(Buffer.from(s.slice(7)), Buffer.from(want)));
}
// Express: app.post("/hooks/tg", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyWebhook(process.env.WEBHOOK_SECRET, req.headers, req.body)) return res.sendStatus(401);
// const { events } = JSON.parse(req.body);
// res.sendStatus(200);
// });
<?php
// $rawBody = file_get_contents('php://input'); — тело как пришло, до json_decode
function verify_webhook(string $secret, string $rawBody): bool {
$ts = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$delivery = $_SERVER['HTTP_X_WEBHOOK_DELIVERY'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) {
return false;
}
$want = hash_hmac('sha256', $ts . '.' . $delivery . '.' . $rawBody, $secret);
foreach (explode(',', $signature) as $part) {
$part = trim($part);
if (str_starts_with($part, 'sha256=') && hash_equals($want, substr($part, 7))) {
return true;
}
}
return false;
}
Ответ и повторы
- Ответ
2xx— пачка принята, следующая придёт с новыми событиями. Отвечайте быстро (таймаут — 10 с), тяжёлую обработку делайте после ответа. - Любой другой ответ, таймаут или редирект — неудача: события придут снова с растущей паузой (от 5 секунд до 10 минут), возможно вместе с новыми. События не теряются, пока им не больше 7 дней.
- После 50 неудач подряд вебхук выключается, события копятся. Включить —
PATCH /v1/tg/subscriptions/{sub_id}с{"active": true}. - Пока адрес не готов, события можно забирать страницами:
GET /v1/tg/subscriptions/{sub_id}/events.
Какой адрес подойдёт
- Только
https, порт 443, без логина и пароля в адресе. - Имя должно резолвиться в публичные адреса: внутренние сети,
localhost,*.localи похожие не принимаются —400 caller_error, code=webhook_url_forbidden. - Редиректы не проходятся: адрес должен отвечать сам.
Смена секрета
PATCH /v1/tg/subscriptions/{sub_id} с {"rotate_webhook_secret": true} вернёт новый
секрет. Сутки после смены пачки подписываются обоими секретами — успейте обновить секрет у себя, повторы уже
отправленных пачек при этом проходят проверку.
Вебхуки событий организации
Отдельно от подписок организация может получать уведомления о своих делах: готовность задач, расход кредитов,
счета, совпадения мониторингов, сроки ключей. Адреса ведутся путями /v1/webhooks (право
webhooks, см. справочник) или в кабинете, в разделе
«Вебхуки». Адрес получает события, случившиеся после его создания.
| событие | когда |
|---|---|
task.done | Задача готова |
task.partial | Задача готова частично |
task.failed | Задача не получилась |
quota.threshold | Израсходовано 50, 80 или 100 % кредитов месяца |
invoice.issued | Выставлен счёт |
monitor.hit | Новые совпадения мониторинга (не чаще раза в минуту) |
key.expiring | Скоро истекает срок ключа |
webhook.test | Тестовое событие (служебное, приходит всегда) |
webhook.disabled | Адрес выключен после неудач (служебное, приходит всегда) |
POST https://example.ru/hooks/events
Content-Type: application/json
X-Webhook-Id: evt_5f1c…
X-Webhook-Event: task.done
X-Webhook-Timestamp: 1790000000
X-Webhook-Signature: sha256=<hex>[, sha256=<hex старым секретом>]
{"id": "evt_5f1c…", "type": "task.done", "created_at": "2026-09-23T10:00:00+00:00", "data": {"task": {…}}}
- Подпись — по тому же правилу, что выше:
HMAC-SHA256(секрет, timestamp + "." + X-Webhook-Id + "." + тело); повтор события несёт тот жеX-Webhook-Id— по нему отсеивайте дубли. - Ответ
2xxза 10 с — доставлено. Иначе повтор с паузой от 30 секунд до часа, до 10 попыток; потом доставку можно повторить руками:POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry. - После 20 неудач подряд адрес выключается, на остальные адреса организации приходит
webhook.disabled. Включить —PATCH /v1/webhooks/{webhook_id}с{"enabled": true}. - Требования к адресу — те же, что у подписок. Журнал доставок хранится 30 дней.