Вебхуки: события и проверка подписи

> Кратко. ТГИД отправляет события (новый дефект, наряд, отключение, тревога датчика) 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 выключает и события, и вебхуки (так на стендах).
Хотите увидеть эту функцию на своих данных?Покажем ТГИД на демонстрации и ответим на вопросы инженеров.
Запросить демо