Вебхуки: события и проверка подписи
> Кратко. ТГИД отправляет события (новый дефект, наряд, отключение, тревога датчика) POST-запросом на адрес вашей системы и подписывает каждое по спецификации Standard Webhooks. Здесь — состав событий, заголовки и код проверки подписи на Python и JavaScript. Подписки ведёт администратор в окне Вебхуки.
События
Фоновая задача API раз в WEBHOOK_POLL_S секунд (по умолчанию 10) переносит новые записи истории нарядов,
отключений, журнала дефектов (в том числе из десктопа) и тревог телеметрии в таблицу tgid_api_event. Из неё
идут и вебхуки, и лента API v2 /api/v2/events. Журнал событий хранится 30 дней.
| Тип | Когда |
|---|---|
defect.created |
новая запись журнала дефектов — из веба, с обхода или из десктопа |
work_order.created |
новая заявка или наряд (в том числе через API v2) |
work_order.status_changed |
назначен, в работе, выполнен, закрыт, отменён, возвращён |
outage.created |
запланировано отключение |
outage.status_changed |
объявлено, идёт, завершено, отменено |
sensor.deviation, sensor.silent, sensor.recovered |
датчик расходится с моделью, молчит, вернулся в норму |
balance.night_growth, balance.imbalance, balance.recovered |
баланс расхода зоны: рост ночного расхода, небаланс, норма |
ping |
только кнопка «Проверить» в окне подписок |
Список типов с подписями — GET /api/v2 (поле event_types) или GET /api/v1/webhooks/event-types
(администратор).
Тело события:
{
"id": "evt_almaty_57",
"type": "sensor.silent",
"occurred_at": "2026-10-30T14:24:35Z",
"city": "almaty",
"data": {"sensor_id": 23, "code": "ALM-N710-P1", "status": "silent", "prev_status": "normal",
"value": 10.0626, "model": 10.2458, "unit": "kgf_cm2", "…": "…"}
}
В data — только номера, состояния и координаты: без названий и описаний нарядов и дефектов, логинов
и имён потребителей (туда call-центр может вписать телефон жителя). Подробности система дочитывает через API v2
своим ключом. id — ключ события: повтор доставки приходит с тем же id, получатель отбрасывает уже
обработанные. Порядок доставки не гарантирован — сортируйте по occurred_at и номеру в id.
Запрос
POST <адрес подписки>, тело — JSON в UTF-8 без пробелов между элементами, заголовки:
| Заголовок | Значение |
|---|---|
content-type |
application/json |
user-agent |
TGID-Webhooks/1 |
webhook-id |
id события (evt_…) |
webhook-timestamp |
время отправки, секунды Unix |
webhook-signature |
v1, + base64(HMAC-SHA256(ключ, «webhook-id».«webhook-timestamp».«тело»)) |
Ключ HMAC — байты из base64 после префикса whsec_ в секрете подписки. Секрет показывается один раз при
создании подписки и при «Новый секрет»; старый перестаёт действовать сразу.
Ответ получателя. 2xx — доставлено. Иначе (или нет ответа за 10 с) — повтор через 1 мин, 5 мин, 30 мин, 2 ч, 5 ч, 10 ч, 10 ч, затем «не доставлено». Подписка, которая не принимает ничего 3 дня, выключается сама. Перенаправления (3xx) не выполняются. Отвечайте сразу, долгую обработку делайте после ответа.
Проверка подписи
Правила:
- проверять тело как оно пришло — байты до разбора JSON; пересобранный JSON даст другую подпись;
- отклонять запросы, у которых
webhook-timestampотличается от текущего времени больше чем на 5 минут (защита от повтора перехваченного запроса); - сравнивать подпись функцией постоянного времени;
- в заголовке может быть несколько подписей через пробел — достаточно совпадения одной.
Python:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes, tolerance_s: int = 300) -> bool:
msg_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > tolerance_s:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
return any(hmac.compare_digest(sig.removeprefix("v1,"), expected)
for sig in headers["webhook-signature"].split() if sig.startswith("v1,"))
JavaScript (Node.js):
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, headers, body, toleranceS = 300) { // body — Buffer как пришёл
const id = headers['webhook-id']
const ts = headers['webhook-timestamp']
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceS) return false
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = createHmac('sha256', key).update(`${id}.${ts}.`).update(body).digest()
return headers['webhook-signature'].split(' ').some((sig) => {
if (!sig.startsWith('v1,')) return false
const got = Buffer.from(sig.slice(3), 'base64')
return got.length === expected.length && timingSafeEqual(got, expected)
})
}
В Express тело как пришло даёт express.raw({ type: 'application/json' }), во Flask — request.get_data(),
в FastAPI — await request.body(). Готовые библиотеки — пакет standardwebhooks для Python, Node.js, C#, Go,
PHP и Java.
Как проверено. Событие evt_almaty_57 из ленты копии Алматы подписано функцией самого API
(database/webhooks.py: body_bytes, sign) с новым секретом new_secret(). Обе функции выше вернули
«верно» для этого тела и «неверно», когда в теле silent заменили на normal. Пример подписи из спецификации
Standard Webhooks (секрет whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw, msg_p5jXN8AQM9LWM0D4loKWxJek, время
1614265330, тело {"test": 2432232314} → v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=) проверяют тест API
tests/test_webhooks.py и e2e e2e/functions/developer-api.spec.ts.
Требования к адресу
- только
https://;http://— если на сервереWEBHOOK_ALLOW_HTTP=1(1С или ERP во внутренней сети); - адреса внутренних сетей и localhost — только при
WEBHOOK_ALLOW_PRIVATE=1; link-local (169.254.x), CGNAT 100.64.0.0/10, multicast и прочие не публичные адреса — никогда; - адрес проверяется при каждой доставке, запрос идёт на проверенный IP, сертификат — по имени хоста;
- прокси из окружения службы (
HTTP_PROXY) не используется.
Свой приёмник для проверки
Минимальный приёмник на Python (stdlib) — отвечает 204 на верную подпись, 401 на неверную:
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = open("secret.txt").read().strip() # whsec_… из окна подписки
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers["content-length"]))
ok = verify(SECRET, {k.lower(): v for k, v in self.headers.items()}, body)
self.send_response(204 if ok else 401)
self.end_headers()
HTTPServer(("0.0.0.0", 8443), Hook).serve_forever() # за https-прокси
С функцией verify выше приёмник ответил 204 на подписанное событие и 401 на изменённое тело и на то же
событие через час (старше 5 минут). На стенде без внешней сети приёмник на 127.0.0.1 работает только с WEBHOOK_ALLOW_PRIVATE=1
и WEBHOOK_ALLOW_HTTP=1.
Для разработчика
- API:
database/api_events.py(сбор событий, лента,RETENTION_DAYS),database/webhooks.py(подписки,sign, доставка,RETRY_DELAYS_S,SEND_TIMEOUT_S),routers/webhooks.py(администратор), миграцияsql/migrations/20261030_webhooks.sql. Описание —docs/webhooks-api-v2.md. - Веб:
components/WebhooksDialog.vue,utils/webhooks.ts(код проверки для Node.js в окне),tests/utils/webhooks.spec.ts. - Сбор событий под advisory-замком базы, доставки —
FOR UPDATE SKIP LOCKED: несколько процессов API не мешают друг другу.WEBHOOK_POLL_S=0выключает и события, и вебхуки (так на стендах).
