Партнёр пишет: «Мы не получили ваш вебхук по заказу 4412». Вы открываете логи — отправка была, ответ 200, всё в порядке. Партнёр открывает свои — у него пусто.
Дальше начинается переписка на три дня, в которой обе стороны уверены в своей правоте. И обе, скорее всего, правы: событие ушло, ответ вернулся, но принял его балансировщик, а до приложения оно не дошло.
Отдавать вебхуки сложнее, чем принимать. Когда вы принимаете, за доставку отвечает кто-то другой, и вам достаточно быстро ответить и не потерять то, что пришло. Когда отдаёте, ненадёжная сторона теперь вы, и все вопросы будут к вам.
Разберём, как спроектировать отдачу так, чтобы такие переписки не начинались. Про обратную задачу, приём чужих вебхуков, есть отдельная статья. Здесь речь только про исходящие.
Что кладут в событие
Начинается всё с формы payload, и здесь закладывают проблемы на годы вперёд.
Минимальный набор полей, который потом не придётся ломать:
{
"id": "evt_01J8X2QK4M",
"type": "order.paid",
"created_at": "2026-08-30T14:12:03Z",
"api_version": "2026-08-01",
"data": {
"order_id": "4412",
"amount": 15000,
"currency": "RUB"
}
}
Зачем каждое поле:
id задаёт уникальный идентификатор доставки. Без него получатель физически не сможет отличить повтор от нового события, а повторы будут обязательно.
type хранит тип события в виде объект.действие. Плоские имена вроде orderPaid через год превращаются в кашу, когда типов станет сорок.
created_at фиксирует момент, когда событие произошло, а не когда вы его отправили. Разница важна, если доставка ушла в повторы на два часа.
api_version называет версию формата. Позволяет менять структуру data, не ломая тех, кто уже подключён.
Отдельное правило: не кладите в событие полное состояние объекта. Отдавайте идентификатор и минимум полей, а за подробностями получатель сходит в ваш API. Иначе каждое изменение модели данных становится ломающим изменением для всех партнёров сразу.
Подпись: как её делают правильно
Адрес вебхука открыт всему интернету. Без подписи любой желающий отправит вашему партнёру «оплата прошла», и партнёр её примет.
Подпись считается по сырому телу запроса, и вместе с ним обязательно подписывается метка времени. Без метки перехваченный запрос можно повторить через месяц, и он останется валидным.
import crypto from "node:crypto";
function signPayload(payload: string, secret: string) {
const timestamp = Math.floor(Date.now() / 1000);
// Метка времени входит в подписываемую строку — иначе запрос
// можно перехватить и воспроизвести когда угодно
const signature = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${payload}`)
.digest("hex");
return { timestamp, header: `t=${timestamp},v1=${signature}` };
}
const body = JSON.stringify(event);
const { header } = signPayload(body, endpoint.secret);
await fetch(endpoint.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Signature": header,
"X-Event-Id": event.id,
},
body, // ровно та строка, которую подписали
signal: AbortSignal.timeout(10000),
});
Схема с префиксом v1= в заголовке нужна, чтобы однажды сменить алгоритм, не ломая существующих получателей: вы начнёте слать v1 и v2 одновременно, дадите партнёрам время и уберёте старую версию.
Секрет у каждого получателя должен быть свой. Общий секрет на всех означает, что любой партнёр может подделать событие для любого другого.
Что считать успешной доставкой
Только код ответа из диапазона 2xx. Ничего больше.
Три ошибки, которые здесь допускают:
Считать успехом любой ответ без исключения. Получатель вернул 500, соединение состоялось, ошибки не было — и доставка помечается выполненной. Событие потеряно навсегда.
Считать успехом редирект. Ответ 302 означает, что тело запроса до приложения не дошло. Редиректы при доставке вебхуков лучше не разрешать вовсе.
Ждать ответа слишком долго. Таймаут нужен и на вашей стороне: 10 секунд более чем достаточно. Получатель, которому нужно больше, обязан ставить работу в очередь у себя.
Повторы: сколько раз и с какими паузами
Разовая попытка не годится: у получателей бывают деплои, перезапуски и просто плохие пять минут.
Рабочая схема выглядит так: экспоненциальная задержка со случайной добавкой, суммарно в пределах суток.
// 1 мин, 5 мин, 30 мин, 2 ч, 6 ч, 12 ч, затем эндпоинт помечается проблемным
const RETRY_DELAYS_MIN = [1, 5, 30, 120, 360, 720];
function nextAttemptAt(attempt: number): Date | null {
const minutes = RETRY_DELAYS_MIN[attempt];
if (minutes === undefined) return null; // попытки исчерпаны
// случайная добавка, чтобы повторы всех событий не совпали в одну секунду
const jitter = Math.random() * minutes * 0.1;
return new Date(Date.now() + (minutes + jitter) * 60_000);
}
Повторять имеет смысл сетевые ошибки, таймауты и 5xx. Ответ 4xx означает, что получатель отверг запрос осознанно, и через час он отвергнет его точно так же. Исключение составляет 429: его нужно уважать вместе с заголовком Retry-After.
Про повторы обязательно предупредите в документации. Получатель должен знать, что одно и то же событие придёт ему несколько раз, и обязан обрабатывать дубли по id. Гарантировать доставку ровно один раз в распределённой системе невозможно — можно гарантировать доставку хотя бы один раз и дать получателю средство отличить повтор.
Очередь и изоляция получателей
Отправлять вебхук синхронно, прямо в обработчике бизнес-операции, нельзя. Оплата не должна ждать, пока чужой сервер ответит, и уж тем более не должна падать, если он лежит.
Отсюда очередь: бизнес-операция кладёт задачу на доставку и завершается, доставкой занимаются отдельные обработчики.
Второй пункт менее очевиден: получатели должны быть изолированы друг от друга. Если один партнёр отвечает по десять секунд, а обработчиков в пуле пять, он займёт их все, и остальные партнёры перестанут получать события. Лечится отдельной очередью на получателя либо ограничением на число одновременных доставок к одному адресу.
Мёртвые эндпоинты
Партнёр выключил тестовый стенд полгода назад и забыл. Ваша система продолжает исправно долбиться в несуществующий адрес, тратя ресурсы и накапливая очередь.
Нужна политика отключения: после нескольких суток непрерывных неудач эндпоинт переводится в неактивное состояние, а партнёру уходит письмо. Включается обратно вручную — это защищает и вас, и его.
Одновременно стоит различать «эндпоинт временно недоступен» и «эндпоинт отвечает 410». Код 410 в ответ на вебхук традиционно означает «этого получателя больше нет», и его можно отключать сразу.
Безопасность: адрес указывает партнёр
Момент, который упускают почти всегда. Партнёр сам вводит URL, куда слать события. А значит, он может ввести адрес внутри вашей инфраструктуры.
Попросив http://localhost:6379 или адрес служебного метаданного облака, злоумышленник превращает вашу систему доставки в инструмент для запросов во внутреннюю сеть от вашего же имени. Ваш сервер сходит туда с полными правами и вернёт результат в журнал доставок, который партнёр может посмотреть.
Минимальная защита:
- принимать только
https и только стандартный порт
- резолвить имя хоста и отклонять приватные диапазоны адресов, локальную петлю и служебные адреса облака
- проверять адрес заново перед каждой отправкой, а не однократно при сохранении: имя может начать резолвиться в другой адрес позже
- запретить переходы по редиректам
Последний пункт закрывает обход, при котором внешне безобидный адрес отвечает редиректом на внутренний.
Что нужно дать партнёру
Хорошая интеграция отличается от плохой в первую очередь тем, может ли партнёр разобраться сам, без письма в поддержку.
Минимум, который экономит обеим сторонам недели переписки:
- Журнал доставок: что отправляли, когда, каким был ответ и его тело
- Ручной повтор: кнопка «отправить снова» для конкретного события
- Тестовое событие: способ проверить эндпоинт, не совершая реальную операцию
- Пример проверки подписи: готовый код на паре языков, потому что подпись ошибаются чаще всего остального
История из начала статьи с журналом доставок закрылась бы за пять минут: было бы видно, что ответ 200 пришёл за 3 миллисекунды, а такой быстрый ответ приложение отдать не могло.
Чек-лист
Заключение
Отдача вебхуков выглядит как «отправить POST», а на деле это маленькая система доставки со своей очередью, политикой повторов, изоляцией и наблюдаемостью. Пропустить любой из этих слоёв можно, и первое время всё будет работать: партнёров мало, они отвечают быстро, событий немного.
Проблемы приходят вместе с ростом, и приходят сразу все. Дешевле заложить id события, подпись с меткой времени и очередь на старте, чем объяснять десятку партнёров, почему им придётся менять свой код.
Читайте также