Необходимые cookie-файлы нужны для работы сайта. Аналитические cookie (Яндекс Метрика) — только с вашего согласия. Политика cookie-файлов

Тема оформления
Инструкции

Приём вебхуков

Как принимать вебхуки Typizer и проверять подпись (Standard Webhooks) на Node, Python и PHP, учитывая повторы и дедупликацию.

Вебхук (webhook) — это HTTP-запрос, которым Typizer сам уведомляет ваш сервер о событии (прогон завершился, пакет обработан, баланс низкий), вместо того чтобы вы опрашивали API. Это быстрее и дешевле опроса — подробнее о выборе между ожиданием, опросом и вебхуком см. «Получить результат».

Внимание:

Любой, кто знает адрес вашего эндпоинта, может прислать на него поддельный запрос. Никогда не обрабатывайте тело вебхука, не проверив подпись — ниже показано, как это сделать на Node.js, Python и PHP.

Как подключить приём

  1. Создайте конечную точку — POST /v1/webhook_endpoints с url (публичный https:-адрес вашего сервера) и списком events (какие из четырёх событий вас интересуют). В ответе приходит secret — ключ для проверки подписи (см. ниже). Это не одноразовый показ: любой ключ или сессия со скоупом runs:write может прочитать secret снова через GET /v1/webhook_endpoints/{id} или в списке GET /v1/webhook_endpoints, так что сохранять его немедленно не обязательно — но обращайтесь с ним как с любым секретом (не публикуйте, не логируйте, не передавайте по открытым каналам). Единственное исключение из «можно прочитать снова» описано ниже, в разделе «Секрет конечной точки».
  2. Либо укажите webhook_url прямо в запросе на прогон (POST /v1/extract, POST /v1/typizers/{id}/runs) — для него конечная точка создаётся автоматически и получает только события этого прогона, не все события организации. У вас не будет ответа на создание, поэтому секрет такой точки читайте через GET /v1/webhook_endpoints (с runs:write).
  3. Проверьте приём тестовым событием — POST /v1/webhook_endpoints/{id}/test присылает на указанный URL пример run.succeeded (с тестовым прогоном run_test), подписанный точно так же, как настоящая доставка.

Заголовки и подпись

Каждая доставка подписана по соглашению Standard Webhooks и несёт три заголовка:

  • webhook-id — идентификатор события (evt_…); одинаковый во всех попытках одной и той же доставки — по нему и делается дедупликация на стороне получателя (повторная попытка — не новое событие);
  • webhook-timestamp — время отправки, целые секунды Unix;
  • webhook-signature — может содержать несколько подписей через пробел, каждая в формате v1,<base64> (Standard Webhooks разрешает несколько, не объясняя зачем — проверяйте каждую: действительной считается любая совпавшая).

Подпись — HMAC-SHA256 от строки {webhook-id}.{webhook-timestamp}.{тело запроса} (тело — ровно те байты, что были отправлены, без повторной сериализации на вашей стороне) на ключе — секрете конечной точки. Секрет имеет вид whsec_ + base64; HMAC-ключ — результат base64-декодирования части после whsec_.

Кроме самой подписи, проверяющая сторона обязана отклонять запрос, если webhook-timestamp отличается от текущего времени больше чем на 5 минут (300 секунд) — в любую сторону. Это защита от повторного использования перехваченного тела запроса (replay): без проверки времени когда-то действительная подпись оставалась бы действительной вечно.

Секрет конечной точки

Секрет виден в поле secret всюду, где ключ или сессия несёт скоуп runs:write: в ответе на создание конечной точки, в GET /v1/webhook_endpoints/{id} и в списке GET /v1/webhook_endpoints — не только сразу после создания. Без этого скоупа поле всегда null. Отдельный случай — повтор запроса на создание с тем же Idempotency-Key: такой ответ всегда несёт secret: null, даже у принципала с runs:write (секрет не повторяется в ответах-повторах). Если конечная точка была создана автоматически для webhook_url прогона, прочитайте её секрет позже через GET /v1/webhook_endpoints (с runs:write) — отдельного уведомления с ним не присылается. На сервере секрет хранится зашифрованным.

Отдельного маршрута для смены (ротации) секрета нет. Чтобы заменить секрет, создайте новую конечную точку и удалите старую — повторно использовать тот же URL можно только после удаления предыдущей записи с ним (один URL — одна конечная точка организации, активная или отключённая).

Повторы и отключение

Неудачная попытка повторяется ещё 6 раз — итого до 7 попыток (с 0 по 6) на одну доставку — с увеличивающейся задержкой: 5 секунд, 5 минут, 30 минут, 2 часа, 5 часов, 10 часов. Если и последняя попытка не удалась, доставка помечается failed и дальше не повторяется автоматически; её можно повторить вручную — каждый такой повтор добавляет одну попытку сверх автоматической серии и сам не планирует следующую. Если и он не удался, доставка снова failed, и вы можете повторить её вручную ещё раз, и так далее, пока не добьётесь успеха или не откажетесь от доставки.

Конечная точка, у которой 72 часа подряд не было ни одной успешной доставки, отключается автоматически (status: "disabled") — владельцам и администраторам организации приходит письмо. Отключённую точку можно включить обратно вручную; пока она отключена, она ничего не получает.

Если в настройках организации срок хранения результата — «не хранить» (retention: "none"), то к моменту повторной отправки output и fields в сохранённом теле события уже могли быть обнулены — ручной повтор в этом случае придёт с "output": null и "fields": null под тем же webhook-id, что и первая попытка.

События

Четыре типа событий, в этом же порядке в списке events конечной точки:

  • run.succeeded, run.failed — прогон завершился, успешно или с ошибкой;
  • batch.completed — пакетная обработка завершена;
  • balance.low — баланс организации опустился ниже порога.

Тело запроса — JSON-объект одной формы для всех событий:

JSON
{
  "id": "evt_01J…",
  "type": "run.succeeded",
  "created_at": "2026-10-31T12:00:00.000Z",
  "data": { "object": { "…": "…" } }
}

data.object для run.succeeded/run.failed — тот же объект Run, что отдаёт REST API, с одним отличием: поле file в нём всегда null — имя файла по вебхуку не передаётся, даже когда в самом API оно видно. Для batch.completed — объект Batch. Форма data.object для balance.low этой страницей не документируется.

Проверка подписи

Используйте только встроенные криптографические функции платформы — node:crypto, hmac/ hashlib, hash_hmac — никаких сторонних пакетов для самой проверки не нужно. Общее для всех языков:

  • сравнивайте подписи функцией постоянного времени (timingSafeEqual, hmac.compare_digest, hash_equals), а не ===/== — иначе время сравнения может выдать, сколько первых байт совпало;
  • считайте HMAC от тела запроса как оно пришло побайтово — большинство веб-фреймворков уже распарсили JSON к моменту, когда код разработчика получает запрос; нужно читать «сырое» тело (raw body) до разбора, иначе смена порядка ключей или пробела сломает проверку;
  • отклоняйте запрос, если заголовков не хватает или webhook-timestamp вне допуска — не пытайтесь угадать или подставить значение по умолчанию.

Ниже — готовая реализация для каждого языка и один тестовый вектор для всех трёх: валидный набор secret/заголовков/тела, сгенерированный функцией signWebhook из @typizer/core и проверенный функцией verifyWebhook оттуда же (а не сторонней реализацией Standard Webhooks), так что он отражает реальный формат подписи. Секрет в нём тестовый, ни для чего, кроме самопроверки, не годится — можете подставить его в свою реализацию и убедиться, что она тоже принимает валидную подпись и отклоняет изменённое тело или просроченную метку времени.

test/fixtures/webhook-vector.json:

JSON
{
  "secret": "whsec_J8G0iNL6JM6usEvy6InDrrH8E1crlPG4a9TjsmmHN/A=",
  "headers": {
    "webhook-id": "evt_01m69rmab2eccrpd5h5gdw4527",
    "webhook-timestamp": "1793448000",
    "webhook-signature": "v1,exEsUFj9Svu5IyCWc04F4OyD3c4x1t+Dds5Vd9pY4Nc="
  },
  "body": "{\"id\":\"evt_01m69rmab2eccrpd5h5gdw4527\",\"type\":\"run.succeeded\",\"created_at\":\"2026-10-31T12:00:00.000Z\",\"data\":{\"object\":{\"id\":\"run_test\",\"object\":\"run\",\"status\":\"succeeded\",\"output\":{\"example\":true}}}}",
  "ts": 1793448000
}
import { createHmac, timingSafeEqual } from 'node:crypto';

// Допуск по времени — 5 минут (300 секунд), как у Standard Webhooks. Не настраивается: это защита
// от повторного использования перехваченного тела запроса (replay), а не параметр интеграции.
const TOLERANCE_SEC = 300;

export interface TypizerWebhookHeaders {
  'webhook-id': string;
  'webhook-timestamp': string;
  'webhook-signature': string;
}

/**
 * Проверяет подпись вебхука Typizer (Standard Webhooks: HMAC-SHA256 от
 * `{webhook-id}.{webhook-timestamp}.{тело}` на ключе из секрета конечной точки).
 *
 * - `rawBody` — тело запроса ровно в тех байтах, в которых оно пришло (не `JSON.stringify`
 *   пересобранного объекта — любая перестановка ключей или пробел ломают подпись).
 * - `nowSec` — текущее время в секундах Unix; по умолчанию реальное время, параметр нужен в
 *   основном для тестов (чтобы не привязываться к системным часам).
 *
 * Никогда не бросает исключение: отсутствующий или нечитаемый заголовок, неверная подпись и
 * протухший `webhook-timestamp` — всё даёт `false`.
 */
export function verifyWebhook(
  secret: string,
  headers: TypizerWebhookHeaders,
  rawBody: string,
  nowSec: number = Math.floor(Date.now() / 1000),
): boolean {
  const msgId = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!msgId || !timestamp || !signatureHeader) return false;
  if (!/^\d{1,15}$/.test(timestamp)) return false;

  const timestampSec = Number(timestamp);
  if (Math.abs(nowSec - timestampSec) > TOLERANCE_SEC) return false;

  const encoded = secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret;
  if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return false;
  const key = Buffer.from(encoded, 'base64');
  if (key.length === 0) return false;

  const expected = createHmac('sha256', key)
    .update(`${msgId}.${timestampSec}.${rawBody}`, 'utf8')
    .digest();

  // Проверяем каждую подпись из заголовка (их может быть несколько, через пробел) и сравниваем
  // каждую в постоянное время — порядок перебора не должен выдать, какая из них совпала.
  let ok = false;
  for (const entry of signatureHeader.split(' ')) {
    const comma = entry.indexOf(',');
    if (comma < 0 || entry.slice(0, comma) !== 'v1') continue;
    const given = Buffer.from(entry.slice(comma + 1), 'base64');
    if (given.length === expected.length && timingSafeEqual(given, expected)) ok = true;
  }
  return ok;
}

В Node.js: если заголовки у вас — объект Headers из Fetch API (так получает их обработчик маршрута в Next.js App Router, Hono или на Cloudflare Workers), приведите его к обычному объекту перед вызовом: verifyWebhook(secret, Object.fromEntries(req.headers), rawBody).

Как обрабатывать событие

  • Отвечайте 2xx как можно быстрее (в пределах нескольких секунд) и переносите саму обработку (запись в БД, пересчёт) в фоновую задачу — любой код ответа, кроме 2xx, или таймаут считаются неудачной попыткой и приводят к повтору;
  • используйте webhook-id для дедупликации: сохраните уже обработанные идентификаторы (хотя бы на несколько дней) и игнорируйте повтор с тем же webhook-id, если событие уже применено;
  • не полагайтесь на порядок доставки между разными событиями — например, run.succeeded одного прогона и batch.completed его пакета могут прийти в любом порядке.

Частые ошибки

  • Конечная точка — на том же URL, что указан как webhook_url у прогона, и она отключена. Новый прогон с таким webhook_url вернёт 409 conflict (param: "webhook_url") — включите конечную точку в PATCH /v1/webhook_endpoints/{id} (status: "active") или укажите другой URL.
  • Эндпоинт отключился сам. Если 72 часа подряд ни одна доставка не прошла успешно, Typizer отключает конечную точку и присылает письмо владельцам и администраторам организации — проверьте журнал GET /v1/webhook_endpoints/{id}/deliveries, исправьте сервер и включите точку обратно.
  • Тело не совпадает с ожидаемым после ручного повтора. Если срок хранения результата у организации — «не хранить», повтор доставки старого события придёт с output и fields, равными null — обрабатывайте это как штатный случай, а не как ошибку.

Что дальше