Надёжность вебхуков на своём сайте: повторы, идемпотентность, проверка подписи и мониторинг
1. Почему вебхуки изначально ненадёжны
Вебхук — это когда чужая система сама отправляет событие POST на ваш callback-адрес. Он работает поверх HTTP, а HTTP не поддерживает постоянное соединение и не гарантирует доставку: моргнула сеть, перезапустился ваш сервис, истёк таймаут обработки — и доставка потеряна. Хуже того, вы не знаете, получил ли получатель: вы отправили, но 2xx не пришёл — то ли сеть потеряла, то ли получатель обработал, но ответ потерялся?
Контринтуитивный вывод здесь такой:Система вебхуков по умолчанию повторяет отправку, поэтому ваш приёмник должен быть рассчитан на то, что доставка будет повторяться, а не на то, что событие придёт один раз. Стандартный подход зрелых платформ вроде Stripe и Shopify: если приёмник не вернул 2xx, отправка повторяется с экспоненциальной задержкой, максимум 5–8 раз в течение примерно 24 часов.
Если не сделать идемпотентность, одно и то же событие оплаты зачислится несколько раз. О том, как связать лиды с системой, читайте Заявки с сайта в CRM, там тоже придётся иметь дело с повторными событиями.
Уберите одно из четырёх — и надёжность упадёт на ступень. В таблице ниже разобрано, что делает каждый элемент и что будет, если его пропустить.
| Элемент | Что даёт | Стандартная практика | Что будет, если пропустить |
|---|---|---|---|
| Проверка подписи | Убедиться, что запрос действительно от отправителя | Проверка заголовка HMAC-SHA256 | Поддельные события создадут фальшивые заказы |
| Быстрое подтверждение | Чтобы отправитель не повторял по таймауту | Сначала 2xx, потом асинхронная обработка | Медленная обработка вызовет повторную доставку |
| Идемпотентная дедупликация | Повторное событие обрабатывается один раз | ID события пишется в таблицу для дедупликации | Один заказ зачислится несколько раз |
| Повторы с задержкой | Временный сбой в итоге доставляется | Экспоненциальная задержка с джиттером | Случайный сбой приведёт к потере заказа |
| Мониторинг недоставленных | Неудачные события не теряются молча | После лимита — очередь недоставленных и алерт | Заказы теряются, и никто не знает |
2. Проверка подписи: убедиться, что запрос не подделан
URL вебхука доступен из интернета, и любой может отправить на него POST-запрос. Если не проверять подпись, злоумышленник просто сформирует фальшивое событие «платёж прошёл» — и ваш бэкенд вполне может выдать ему доступ или оформить заказ. Это не теоретический риск, а реальная атака, которой уже пользуются.
Стандартный подход — HMAC-SHA256: отправитель подписывает тело запроса общим секретным ключом и кладёт подпись в заголовок (например, X-Signature). Вы, получив запрос, считаете подпись тем же ключом по исходному телу и сравниваете — обрабатываете только при совпадении.
Обязательны две детали. Первая: сравнивайте функцией с постоянным временем, а не обычным ==, чтобы защититься от атак по времени. Вторая: проверяйте метку времени в заголовке — если расхождение больше 5 минут, сразу отклоняйте, иначе старый запрос могут записать и воспроизвести. При утечке ключа должна быть возможность немедленной ротации.
| Что проверяем | Как сделать | Что делать, если не пройдено |
|---|---|---|
| Совпадает ли подпись | Пересчёт и сравнение HMAC-SHA256 | Сразу отклонить с 401 |
| Свежая ли метка времени | Расхождение с серверным временем ≤ 5 минут | Отклонить, защита от повтора |
| Не истёк ли ключ | Ротация со сосуществованием старого и нового ключей | Повторить со старым ключом |
| Не изменено ли тело запроса | Считать подпись по исходным байтам | Не декодировать JSON перед подписью |
Три. Быстрое подтверждение и асинхронная обработка: не делайте тяжёлую работу в запросе
Это самая частая ошибка. Получив вебхук, многие в том же синхронном запросе делают кучу тяжёлых дел: лезут в базу, шлют письма, дёргают сторонние API, пишут логи — и всё это занимает 5 секунд. Отправитель ждёт 3 секунды, не получает 2xx, решает, что таймаут, и начинает повторять — а у вас ещё обрабатывается первый запрос, и вот приходит второй.
Правильная схема — из трёх шагов: получив запрос, сначала проверьте подпись (это миллисекунды), сразу сохраните событие как есть и верните 2xx, а тяжёлую обработку вынесите в фоновую задачу. Тогда отправитель гарантированно получит подтверждение за 3 секунды и не станет повторять; тяжёлая работа может идти медленнее — пользователь уже получил ответ. Неудачные события остаются в очереди, и асинхронная задача сама их повторяет.
Пример из практики: Приходит событие об успешном платеже. Приёмник делает только три вещи: проверяет подпись, записывает ID события и исходный payload в таблицу events и сразу возвращает 200. А настоящие «списать товар, отправить письмо, уведомить ERP» выполняет фоновый worker, который забирает из таблицы events необработанные события и не спеша их разбирает. Даже если письмо отправляется 10 секунд, на повторные запросы отправителя это не влияет. Как обеспечить надёжность доставки форм, можно посмотреть в Надёжность формы запроса.
| Этап | Действие | Цель по времени | Действия при сбое |
|---|---|---|---|
| Синхронный приём | Проверка подписи + запись в базу + ответ 2xx | Меньше 300 миллисекунд | При неверной подписи сразу отклонить |
| Асинхронная обработка | Запросы к базе, уведомления, вызовы внешних сервисов | От секунд до минут | При ошибке остаётся в очереди на повтор |
| Повтор | Перезапуск с интервалом отката | Экспоненциальный откат | После превышения лимита — в очередь недоставленных |
| Ручное доведение | Разобрать очередь недоставленных и запустить вручную | Ежедневная проверка | Записать причину доведения |
Четыре. Идемпотентность и повторы: одно и то же событие приходит десять раз, а обрабатывается один
Идемпотентность означает: выполнить операцию один раз или десять раз — результат одинаков. Webhook обязан быть идемпотентным по своей природе, потому что вы не можете запретить отправителю повторять запрос. Реализуется это просто: каждое событие приходит с глобально уникальным ID, вы перед обработкой проверяете таблицу events, и если этот ID уже встречался — сразу возвращаете успех и пропускаете обработку.
Стратегия повторов — экспоненциальная задержка с джиттером: первая неудача — ждём 1 минуту, вторая — 2 минуты, третья — 4 минуты, и так с удвоением, максимум 5–8 попыток, общий интервал около 24 часов. Джиттер нужен, чтобы все неудачные события не ринулись на повтор в одну и ту же секунду и не обрушили ваш сервис ещё раз (это называется штормом повторов). События, исчерпавшие лимит попыток, не теряются, а попадают в очередь недоставленных — до вмешательства человека.
| Номер попытки | Интервал задержки (примерно) | Суммарное время | Пояснение |
|---|---|---|---|
| 1-я попытка | 1 минута | 1 минута | Временный сбой сети |
| 2-я попытка | 2 минуты | 3 минуты | Кратковременная перезагрузка сервиса |
| 3-я попытка | 4 минуты | 7 минут | Временная недоступность получателя |
| 4-я попытка | 8 минут | 15 минут | Более длительный сбой |
| 5–8-я попытки | 15–30 минут | Около 24 часов | Снова неудача — в очередь недоставленных |
Пять: мониторинг и недоставленные — не дайте заказам пропадать молча
Самый коварный сбой Webhook — «тихая потеря заказа»: отправитель считает, что всё доставлено, у вас нет ни одной записи, а через несколько недель клиент жалуется: «Я заплатил, почему нет доставки?» — и только тогда вы обнаруживаете, что событие вообще не пришло. Поэтому мониторинг должен следить за тремя вещами: доля успешных доставок, накопление очереди повторов, размер очереди недоставленных.
Пороги — конкретные числа: если в очереди повторов больше 100 записей или в течение 30 минут нет ни одной успешной доставки — немедленно шлите алерт в привычный вам канал. Очередь недоставленных проверяйте раз в день, при накоплении — восстанавливайте заказы вручную. Если доля успешных доставок падает ниже 99% — разбирайтесь: в здоровом состоянии успешность Webhook должна быть близка к 99,9%, а падение ниже 99% говорит о постоянной проблеме. Как настроить общий мониторинг и алерты, читайте в Мониторинг сайта и оповещения.
| Метрика | Здоровое значение | Порог алерта | Действие |
|---|---|---|---|
| Доля успешных доставок | ≥ 99,9 % | 1 час подряд < 99% | Проверьте логи приёмника |
| Накопление очереди повторов | близко к 0 | Больше 100 записей | Проверьте, не завис ли получатель |
| Очередь недоставленных | 0 | Любое накопление | Восстановите вручную и найдите причину |
| Нет успешных доставок подряд | События идут постоянно | 30 минут — ноль | Проверьте, не заблокирован ли адрес колбэка |
| Число неудачных проверок подписи | близко к 0 | Рост в 10 раз | Возможно, кто-то сканирует интерфейс |
FAQ: частые вопросы
Почему мой Webhook иногда получает повторные события о заказе?
Webhook нужно обрабатывать сразу или сначала вернуть ответ?
А можно не проверять подпись, всё равно же HTTPS?
Как задать интервалы между повторами и почему нельзя повторять каждую минуту?
Что делать с событиями, которые не прошли и после множества повторов?
Как понять, что Webhook тихо теряет события?
В заключение
Главное, что нужно понимать про надёжность Webhook: это не односторонний push, а двусторонний протокол по принципу «нет подтверждения — повторяем». Проектируя приёмник, исходите из того, что доставка будет повторяться, события будут подделывать и они будут теряться, — это норма, а не редкий случай. Из четырёх элементов идемпотентность с дедупликацией и быстрое подтверждение чаще всего упускают, и именно они дают больше всего аварий. Сделайте сначала их правильно — и число инцидентов упадёт вдвое.
Три шага к надёжности: первый — на приёмной стороне только проверка подписи, запись в базу и ответ 2xx за 3 секунды, вся бизнес-логика асинхронно; второй — таблица дедупликации по ID события, повторные попытки с экспоненциальной задержкой и джиттером; третий — ежедневный обход очереди недоставленных, алерты на долю успешных доставок и объём накоплений. После этих трёх шагов ваш колбэк-канал превращается из «заказы теряются, как повезёт» в «потеряли — в тот же день заметили и восстановили».
Запомните одну фразу: надёжность Webhook зависит не от того, как стабильно отправляет другая сторона, а от того, как тупо принимаете вы — проверяй подпись, отвечай быстро, убирай дубли. Лучше обработать дважды, чем потерять заказ.
