Приём вебхуков
Как принимать вебхуки Typizer и проверять подпись (Standard Webhooks) на Node, Python и PHP, учитывая повторы и дедупликацию.
Вебхук (webhook) — это HTTP-запрос, которым Typizer сам уведомляет ваш сервер о событии (прогон завершился, пакет обработан, баланс низкий), вместо того чтобы вы опрашивали API. Это быстрее и дешевле опроса — подробнее о выборе между ожиданием, опросом и вебхуком см. «Получить результат».
Любой, кто знает адрес вашего эндпоинта, может прислать на него поддельный запрос. Никогда не обрабатывайте тело вебхука, не проверив подпись — ниже показано, как это сделать на Node.js, Python и PHP.
Как подключить приём
- Создайте конечную точку —
POST /v1/webhook_endpointsсurl(публичныйhttps:-адрес вашего сервера) и спискомevents(какие из четырёх событий вас интересуют). В ответе приходитsecret— ключ для проверки подписи (см. ниже). Это не одноразовый показ: любой ключ или сессия со скоупомruns:writeможет прочитатьsecretснова черезGET /v1/webhook_endpoints/{id}или в спискеGET /v1/webhook_endpoints, так что сохранять его немедленно не обязательно — но обращайтесь с ним как с любым секретом (не публикуйте, не логируйте, не передавайте по открытым каналам). Единственное исключение из «можно прочитать снова» описано ниже, в разделе «Секрет конечной точки». - Либо укажите
webhook_urlпрямо в запросе на прогон (POST /v1/extract,POST /v1/typizers/{id}/runs) — для него конечная точка создаётся автоматически и получает только события этого прогона, не все события организации. У вас не будет ответа на создание, поэтому секрет такой точки читайте черезGET /v1/webhook_endpoints(сruns:write). - Проверьте приём тестовым событием —
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-объект одной формы для всех событий:
{
"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:
{
"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— обрабатывайте это как штатный случай, а не как ошибку.
Что дальше
- «События вебхуков» — полный список событий и формат тела.
- «Обработка ошибок и повторов» —
Idempotency-Keyна стороне вызовов к API. - «Получить результат» — ожидание, опрос и вебхук как три способа узнать о завершении прогона.