Надёжность вебхуков при дубликатах, задержках и некорректных payload'ах
Надёжность вебхуков начинается с чётких подписей, безопасных повторов, защиты от replay и стабильных схем событий, которые успокаивают интеграции.

Почему вебхуки падают в обычных системах
Большинство проблем с вебхуками начинаются не с бага. Они начинаются с обычного сетевого поведения, стандартных таймаутов и с того, что две системы делают слегка разные допущения.
Отправитель может доставить одно и то же событие дважды, потому что не получил чистого "200 OK" в ответ. Получатель мог обработать первый запрос нормально, но медленный ответ, разрыв соединения или таймаут прокси заставят отправителя думать, что попытка не удалась. В результате у клиента появляются два счёта, два письма или два изменения статуса.
Порядок событий ломается чаще, чем ожидают. Очереди замедляются, работники перезапускаются, и одно событие может обрабатываться дольше другого. Поэтому account.updated может прийти раньше account.created, или возврат может появиться раньше платежного события, которое его объясняет.
С payload'ами возникает другой тип проблем. Одно отсутствующее поле, null там, где ожидалась строка, или испорченный JSON могут остановить парсер. Даже небольшие изменения вредят, когда получатель предполагает, что каждое событие всегда будет точно соответствовать одной и той же форме.
Таймауты превращают мелкие сбои в шумные ошибки. Если один внешний API-запрос зависает на 20 секунд, обработчик вебхука может не успеть выйти в срок. Отправитель повторяет попытку, затем ещё раз, и одна медленная попытка превращается в шторм повторов, который продолжает бить по одному и тому же endpoint.
В реальных системах эти проблемы накладываются друг на друга. Дубликат приходит с опозданием, версия payload'а изменилась, а получатель уже перегружен предыдущими повторами. Сама по себе ни одна вещь не была критичной, но их сочетание создаёт часы ручной чистки.
Именно поэтому хорошие интеграции относятся к каждой доставке как к недоверенной, неупорядоченной и временной. Примите это с самого начала, и остальная часть дизайна станет проще.
Постройте поток приёма шаг за шагом
Большинство багов с вебхуками зарождаются в первые миллисекунды. Получатель должен делать как можно меньше до тех пор, пока не убедится, что запрос реальный, свежий и не дубликат.
Чистый поток также упрощает объяснение ошибок, когда клиент спрашивает, почему событие не применилось.
- Прочитайте сырой тело запроса ровно так, как оно пришло. Делайте это до любого JSON-парсера, помощника фреймворка или middleware, которые могут поменять пробелы, кодировку или порядок полей.
- Проверьте подпись вебхука по этим сырым байтам. Если подпись не совпадает, остановитесь и верните ошибку авторизации.
- Проверьте метку времени в подписанных данных. Если запрос слишком старый — отклоните. Это блокирует попытки replay и ловит доставки, которые слишком долго лежали в очереди.
- Сохраните
event_idв хранилище для дедупации до того, как будете триггерить побочные эффекты. Если придёт другая копия того же события, вы сможете обнаружить её рано и избежать второго списания, письма или изменения статуса. - Только после этих проверок парсите JSON, валидируйте нужные поля и передавайте работу в приложение или очередь.
Коды статусов важнее, чем многие команды ожидают. Возвращайте 2xx только когда вы надёжно приняли событие. Если отправитель не правильно подписал запрос — используйте 401 или 403. Если payload битый или отсутствуют обязательные поля — используйте 400. Если база данных или очередь недоступны — верните 500 или 503, чтобы отправитель понял, что повтор имеет смысл.
Одна деталь экономит много боли: признайте быстро, а тяжёлую работу делайте в фоне. Сохранив событие, пометив ID как увиденный, верните 202 или 204 и пусть воркер обновляет заказы или шлёт письма позже.
Этот поток преднамерённо простой. Прямые получатели переживают дубликаты, задержки и некорректные payload'ы лучше, чем хитрые.
Проектируйте события, которые остаются понятными со временем
Чёткий дизайн событий экономит поддержку больше, чем большинство механизмов повторов. Если клиент получает событие order.updated, ему всё равно придётся гадать, что именно изменилось. Лучше дать событию название одного бизнес-действия, например order.created, order.paid или invoice.sent.
Каждое событие должно нести несколько полей, которые остаются неизменными:
event_id— уникальный идентификатор событияtype— имя событияversion— версия схемыoccurred_at— когда произошло бизнес-действие
Это даёт получателю достаточно контекста, чтобы сохранять, сортировать и отлаживать события, не читая каждое поле в payload.
Типы полей должны оставаться предсказуемыми. Если customer_id — строка в одном событии, держите её строкой везде. Если amount — целое число в центах, не переключайтесь потом на десятичное. Небольшие изменения типов ломают парсеры обычно в самый неподходящий момент.
Когда схему нужно расширить — добавляйте поля. Не переименовывайте старые, если вы не планируете долго поддерживать оба варианта. Получатели часто зафиксированы на форме, с которой они интегрировались впервые. Тихие переименования превращают обычный деплой в тикет поддержки.
Держите payload'ы фокусированными на том, что нужно получателю. Платёжному вебхуку обычно нужен payment ID, order ID, статус, сумма, валюта и метки времени. Ему редко нужны внутренние заметки, метки UI или несвязанные вложенные объекты.
Это не выглядит эффектно, но важно. Чёткие имена событий и стабильные схемы делают обработку дубликатов, повторы и защиту от replay проще, потому что получатель может доверять тому, что означает каждое событие.
Подписывайте запросы одинаково каждый раз
Много боли с вебхуками начинается ещё до того, как ваше приложение прочитает одно поле. Если отправитель подписывает одно, а получатель проверяет другое, валидные запросы падают, а плохие проходят. У обеих сторон должно быть одно правило: подписывать точные сырые байты тела запроса, которые ушли по проводу.
Не парсите JSON и не пересобирайте его для проверки. Парсеры могут поменять порядок полей, убрать пробелы, изменить формат числа или нормализовать Unicode. Payload может выглядеть одинаково для человека и всё равно дать другую подпись. Читайте сырой body, сформируйте строку для подписи, проверьте её, и только потом парсите JSON.
Метка времени должна быть частью подписываемой строки. Это даёт простой способ отвергнуть старые запросы и упрощает защиту от повторов. Частая практика — подписывать timestamp + "." + raw_body с HMAC-SHA256.
X-Webhook-Signature: t=1712812010,v1=4a8c...,kid=2025-01
Этот формат заголовка важнее, чем многие думают. Выберите один формат и держитесь его. Документируйте имя заголовка, поле с меткой времени, версию подписи, алгоритм хеширования, кодировку и точную строку для подписи. Если приёмник должен гадать, является ли v1 hex или base64, интеграция уже сложнее, чем надо.
Ротация секретов тоже нуждается в плане с первого дня. Давайте каждому секрету короткий идентификатор, например kid. Позвольте приёмникам принимать текущий и предыдущий секрет в короткий период перекрытия, затем отправляйте новые запросы с новым секретом и удаляйте старый в фиксированную дату. Это перекрытие избегает сломанных интеграций во время деплоев и из-за дрейфа часов между системами.
Если у вас несколько окружений — держите секреты раздельно для теста и продакшена. Повторное использование одного секрета везде — это кратчайший путь к проблемам позже.
Хорошие подписи вебхуков скучны в лучшем смысле: они проверяют одни и те же байты каждый раз, отказывают для старых или изменённых payload'ов и продолжают работать при смене секретов.
Сделайте дубликатные доставки безвредными
Дубликаты — нормальное явление. Сети таймаутятся, отправители повторяют, и некоторые провайдеры шлют одно и то же событие больше одного раза. Если ваш получатель отправляет два письма, создаёт два аккаунта или запускает тот же workflow дважды, отправитель вызвал повтор, но ваше приложение всё равно должно это корректно обработать.
Давайте каждому событию стабильный event_id и используйте его для дедупации. Не пытайтесь угадывать по email, метке времени или хешу payload'а. Они могут совпасть случайно, а некоторые поля payload'а меняются между повторами.
Сохраняйте каждый обработанный event_id в долговременном хранилище до выполнения побочных эффектов. Таблица базы данных с уникальным индексом по event_id обычно достаточна. Сохраняйте, когда вы впервые увидели событие, обработка началась или закончилась и какой результат вы вернули.
Не держите это только в памяти или в кэше с коротким сроком жизни. Перезапуск его сотрёт, и следующая попытка покажется новой.
Когда вставка успешна — обрабатывайте событие. Если вставка падает из-за существующего ID, посмотрите сохранённый статус. Если работа уже завершена, снова верните 2xx и остановитесь. Повторы должны оставаться незаметными.
Поставьте каждое внешнее действие за той же идемпотентной проверкой. Это касается писем, вызовов биллинга, обновлений CRM и очередей сообщений. Если ваш API-слой блокирует дубликаты, но фоновой джоб игнорирует event_id, вы всё равно можете создать дубликаты побочных эффектов.
Правило простое: запишите ID, выполните работу один раз и относитесь к каждому повтору как к тому же событию, а не к новому запросу.
Повторные попытки без хаоса
Повторы помогают только если следующая попытка реально имеет шанс сработать. Если приёмник таймаутнулся, вернул 503 или кратковременно превысил лимит — отправляйте снова. Если payload неверен, подпись плохая или отсутствует обязательное поле — остановитесь и отметьте как постоянную ошибку.
Это разделение делает систему управляемой. Слепые повторы превращают мелкие баги в длинные цепочки поддержки и засыпают реальные ошибки страницами дубликатов в логах.
Простое правило работает хорошо. Повторяйте при таймаутах, разрывах соединения, 429 и 5xx. Не повторяйте при ошибках валидации схемы, плохих подписях, неизвестных типах событий или отсутствующих обязательных полях. Относитесь к ошибкам авторизации осторожно — чаще всего они требуют ручного исправления, а не десяти дополнительных попыток.
Увеличивайте интервалы между повторами. Короткая первая пауза — ок, но поздние задержки должны расти достаточно быстро, чтобы дать другой стороне время восстановиться. Например: повтор через 30 секунд, затем через 2 минуты, затем через 10 минут, затем через 1 час. Добавьте небольшой случайный джиттер, чтобы не бить по одному и тому же endpoint одновременно.
Задайте окно повторов и придерживайтесь его. Для многих систем 24 часа — достаточно. Некоторые платежные или заказные потоки могут оправдать более долгие окна, но бесконечные повторы — плохая привычка. Событие, пришедшее через три дня, может навредить больше, чем помочь, если клиент уже исправил проблему вручную.
Когда окно закрывается, отметьте доставку как проваленную, сохраните причину и сделайте простой интерфейс для инспекции или ручной повторной отправки. Это даёт поддержке конкретные данные для работы.
Простой подход обычно выигрывает: повторяйте временные ошибки, быстро делайте бэкоф и прекращайте, когда ошибка явно постоянна.
Блокируйте реплеи и устаревшие события
Валидная подпись — недостаточно. Если кто-то перехватил реальный вебхук и отправил его снова через час, подпись всё ещё может совпасть, если вы не проверяете, когда отправитель создал запрос.
Поместите метку времени внутрь подписываемого payload'а или в подписанный заголовок. При приходе запроса сначала проверьте подпись, затем сравните метку времени с часами сервера. Большинство команд разрешают небольшое окно, часто 5 минут, чтобы покрыть сетевые задержки и незначительный дрейф часов.
Если запрос выходит далеко за пределы этого окна — отклоняйте. Так же поступайте, если метка времени сильно вперёд. Оба случая обычно означают реплей, сломанные часы или прокси, изменивший запрос в пути.
Короткоживущая запись недавних доставок закрывает пробел, который не покрывают проверки времени. Храните event_id или хеш подписи и метки времени на короткий период и проверяйте эту запись прежде чем обработать тело события. Если видите то же значение снова — трактуйте это как дубликат, а не как новую операцию. Истекайте старые записи автоматически, чтобы хранилище не разрасталось.
Вам не нужна большая таблица в базе для этого. Быстрый кэш с коротким временем хранения часто работает лучше — защита от повторов нужна только для недавней истории.
Логи важнее, чем многие думают. При отклонении запроса пишите причину простым языком: timestamp too old, timestamp too far ahead или event ID already seen. Поддержка тогда объяснит отклонение за минуты, а не будет копаться в дампах сырого запроса часами.
Обычные повторы всё равно будут. Это нормально. Защита от реплеев должна блокировать устаревшие или подозрительные запросы, а идемпотентная обработка должна делать легитимные дубликаты безвредными.
Простой пример события заказа
Магазин отправляет order.shipped вебхук в приложение склада после покупки этикетки. Payload включает event_id, order_id, label_id, occurred_at и необязательное note. Пять секунд спустя то же событие приходит снова, потому что отправитель не получил достаточно быстрый 200 ответ. Это нормально.
Приложение склада должно проверить подпись, распарсить payload и проверить, не обрабатывало ли оно уже этот event_id. Если обрабатывало — вернуть успех и больше ничего не делать. Первая доставка печатает этикетку и сохраняет запись вроде event_id=9f2... processed. Вторая видит эту запись и останавливается. Никакой второй этикетки, никакой двойной отправки.
Позднее order.cancelled событие сложнее. Возможно, клиент отменил в 10:12, но событие отгрузки дошло раньше, а отмена пришла в 10:20. Обработчик не должен гадать; нужен бизнес-правило.
Если заказ ещё не упакован — остановите выполнение и пометьте как отменённый. Если этикетка уже напечатана или посылка ушла — не отменяйте автоматом. Создайте задачу в поддержку или обзор возврата средств.
Это правило важнее любого хитрого кода. Без него два человека будут решать одну и ту же ситуацию по-разному.
Необязательные поля должны оставаться необязательными. Если note отсутствует, обработчик всё равно должен обработать отправку. Он может сохранить пустую заметку и двигаться дальше. Отклоняйте событие только когда для безопасного решения требуется поле, например order_id, event_id или occurred_at.
Здесь дизайн схемы окупается. Держите тип события стабильным, делайте event_id уникальным, включайте явную метку времени и отмечайте лишь несколько полей как обязательные. Это сохраняет надёжность вебхуков, даже когда доставки приходят дважды, с опозданием или без несущественных полей.
Ошибки, которые тратят часы
Большинство багов с вебхуками начинаются как маленькие упрощения. Они вырастают в длинные потоки поддержки, обычно когда клиент говорит: "ваша проверка подписи падает только в продакшене."
Одна распространённая ошибка — проверять изменённое тело запроса вместо сырых байтов, которые вы действительно получили. Если ваш фреймворк обрезает пробелы, меняет порядок полей JSON или нормализует переводы строк до проверки подписи, вы будете отклонять валидные события. Сначала проверяйте сырой body, затем парсите.
Ещё один путь потерять время — смешивать тестовые секреты с живым трафиком. Стейджинг-эндпоинт, который иногда получает продакшен-запросы, будет падать случайно. Держите отдельные эндпоинты и секреты, и делайте логи понятными, чтобы видно было, какое окружение обработало запрос.
Коды ответов при ошибках наносят больше ущерба, чем думают. Если payload битый или отсутствует обязательное поле — не возвращайте 500, если не хотите бесконечных повторов одного и того же повреждённого события. Используйте 4xx для постоянных проблем, записывайте причину и прекращайте цикл.
Изменения схемы также тихо ломают клиентский код. Переименование customer_id в user_id без новой версии может вывести интеграцию из строя, которая вчера работала нормально. Если нужно менять имена полей — добавьте версию, храните старые поля какое-то время и дайте клиентам время обновиться.
Логи часто делают надёжность хуже, а не лучше. Если вы прячетесь оригинальный event_id, никто не сможет отследить дубликаты, повторы или жалобы поддержки между системами. Помещайте event_id, delivery ID, результат подписи и окружение в каждую запись лога, связанную с запросом.
Это мелочи, но именно они решают, будет ли интеграция спокойной или хаотичной.
Быстрая проверка перед запуском
Вебхук может выглядеть нормально в тестовом инструменте и всё же провалиться в первую неделю под реальной нагрузкой. Прежде чем отправлять в продакшен, прогоните одно событие через всю систему и убедитесь, что человек может проследить его без домыслов.
Выберите один event_id и проследите его от лога отправителя до лога получателя. Вы должны видеть, когда вы его создали, когда отправили, сколько раз повторяли и что с ним сделал получатель. Если команда не может ответить "что случилось с event 8f3..." за минуту–две, поддержке будет сложно позже.
Используйте одно реальное тестовое событие и проверьте несколько вещей. Отправьте то же событие дважды и подтвердите, что получатель применяет его только один раз. Целенаправленно верните постоянную ошибку, например неверный account ID, и подтвердите, что отправитель перестаёт повторять. Добавьте новое необязательное поле в payload и подтвердите, что старый клиент всё ещё работает. Отклоните один запрос с плохой подписью или сломанным JSON и убедитесь, что поддержка видит точную причину. Логи должны показывать и event_id, и ID попытки доставки.
Тест на дубликаты важнее, чем многие думают. Повторы, таймауты и ручные реплеи — обычные вещи. Если получатель создаёт два заказа, два письма или два возврата, баг быстро становится дорогим.
Тест версионирования тоже важен. Необязательные поля должны быть просты для игнорирования. Если лишнее поле ломает старого клиента — схема слишком хрупкая.
Наконец, посмотрите сообщение об ошибке, которое увидит ваша поддержка. "400 bad request" недостаточно. "Rejected: timestamp too old" или "Rejected: missing customer_id" экономит реальное время, особенно когда клиент настаивает, что он отправил правильный payload.
Следующие шаги после первой рабочей версии
Вебхук, который один раз сработал — это только проект. Реальная надёжность проявляется, когда другая команда отправит то же событие дважды, с опозданием или с JSON, который ломает ваш парсер в 2 утра.
Напишите короткий контракт для каждого принимаемого типа события. Держите его простым и конкретным. Назовите событие, перечислите обязательные поля, покажите один реальный пример payload'а, определите event_id, опишите, как вы подписываете запросы и как долго метка времени остаётся валидной. Скажите, что делает ваша система, когда поле отсутствует или payload устарел.
Короткий контракт экономит больше времени, чем длинная спецификация. Большинству команд не нужно много теории — нужен один ясный референс, с которым они сверятся по логам и тестам.
Нарочно тестируйте «уродливые» случаи
Не останавливайтесь на счастливом пути. Ломайте получатель так, как клиенты сломают его позже. Отправьте одно и то же событие три раза и подтвердите, что вы сохраняете один результат. Задержите событие, чтобы оно пришло после более свежего состояния. Уберите обязательное поле или поменяйте тип поля. Повредите тело, чтобы проверки подписи падали. Реплейте старый подписанный запрос вне вашего допустимого окна.
Прогоняйте эти тесты в стейджинге, потом держите их в CI, если можете. Простая тренировка реплея стоит попытки до онбординга. Захватите один подписанный запрос, отправьте его снова внутри окна, отправьте снова после закрытия окна и подтвердите, что логи дают очевидный результат.
Если вашей команде нужно десять минут, чтобы объяснить, почему событие было принято, проигнорировано или отклонено, клиентам будет ещё сложнее. Улучшайте логи, сообщения об ошибках и контракт событий, пока ответ не станет очевидным.
Если хотите второе мнение по потоку — Oleg Sotnikov на oleg.is работает со стартапами и малыми командами как fractional CTO и консультант. Короткий обзор подписей, повторов и обработки событий может выявить слабые места до того, как они превратятся в запросы в поддержку.
Часто задаваемые вопросы
Почему вебхуки приходят дважды?
Потому что отправитель часто повторяет доставку, если не получает чистый ответ 2xx достаточно быстро. Ваше приложение могло успешно обработать первый запрос, но таймаут или разрыв соединения заставляет отправителя попытаться снова.
Что должен делать мой endpoint до парсинга JSON?
Сначала прочитайте сырой (raw) body и проверьте подпись по этим точным байтам. После этого проверьте метку времени и event_id, затем распарсите JSON и передайте работу в приложение или очередь.
Стоит ли отвечать до завершения основной работы?
Да, если вы сначала сохранили событие в надёжном хранилище. Быстро возвращайте 202 или 204, а затем фоновый воркер отправляет письма, обновляет заказы или вызывает внешние API.
Как проверять подписи вебхуков без случайных сбоев?
Подписывайте и проверяйте точные сырые байты запроса, а не пересобранный JSON. Простой шаблон — timestamp + "." + raw_body с HMAC-SHA256, плюс короткое окно времени для свежести.
Какие коды статуса возвращать при ошибках вебхуков?
Используйте 400 для битого JSON или отсутствующих обязательных полей, 401 или 403 для проблем с аутентификацией или подписью. Возвращайте 500, 503 или иногда 429 только когда повторная попытка имеет смысл.
Как остановить дублирующиеся письма, списания или изменения статуса?
Давайте каждому событию стабильный event_id и сохраняйте его в надёжном хранилище до выполнения побочных эффектов. Если тот же event_id приходит снова, верните успех и пропустите вторую операцию.
Стоит ли перезапускать каждую неудачную доставку?
Нет. Повторяйте при таймаутах, разрывах соединения, 429 и 5xx, но прекращайте при плохих подписях, битых payload'ах, неизвестных типах событий или отсутствии обязательных полей.
Как заблокировать replay-атаки на вебхуки?
Проверьте подписанную метку времени и отвергайте запросы, которые выходят за небольшое окно (например, пять минут). Храните короткую запись недавних event_id, чтобы старые, но валидные запросы не запускали работу заново.
Что делает схему вебхуков стабильной со временем?
Добавляйте поля вместо переименования старых. Держите имена событий конкретными, например order.paid, и включайте стабильные поля: event_id, type, version и occurred_at.
Что логировать, чтобы ошибки с вебхуками было легко отлаживать?
Логируйте event_id, ID попытки доставки, результат проверки подписи, окружение, код ответа и простую причину отклонения. Чёткие сообщения вроде timestamp too old или missing customer_id экономят много времени поддержки.