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

Когда кто-то говорит «вебхуки не работают», обычно имеют в виду одну из трёх проблем: события не дошли, события пришли дважды или события пришли в запутанном порядке. С их точки зрения система что-то «пропустила». С вашей точки зрения провайдер отправил событие, но ваш endpoint не принял его, не обработал или не записал так, как вы ожидали.
Вебхуки живут в публичном интернете. Запросы задерживаются, повторяются и иногда доставляются не по порядку. Большинство провайдеров активно повторяют отправку при таймаутах или ответах не 2xx. Это превращает небольшую проблему (медленная база, деплой, кратковременный простой) в дубликаты и гонки.
Плохие логи делают поведение случайным. Если вы не можете доказать подлинность запроса, вы не можете безопасно на нём действовать. Если вы не можете связать жалобу клиента с конкретной попыткой доставки, вы начинаете гадать.
Большинство реальных ошибок укладывается в несколько категорий:
Практическая цель проста: принимать реальные события ровно один раз, отклонять подделки и оставлять понятный след, чтобы вы могли отладить жалобу клиента за считанные минуты.
Вебхук — это просто HTTP-запрос, который провайдер отправляет на ваш открытый endpoint. Вы не делаете pull как у API. Отправитель пушит событие, а ваша задача — принять его, быстро ответить и безопасно обработать.
Типичная доставка включает тело запроса (обычно JSON) и заголовки, которые помогают валидировать и отслеживать полученное. Многие провайдеры добавляют метку времени, тип события (например, invoice.paid) и уникальный ID события, который можно хранить для детекции дубликатов.
То, что удивляет команды: доставка почти никогда не «ровно один раз». Большинство провайдеров стремятся к «по крайней мере один раз», то есть одно и то же событие может прийти несколько раз, иногда с разницей в минуты или часы.
Повторы происходят по скучным причинам: ваш сервер медленный или таймаутит, вы возвращаете 500, их сеть не увидела ваш 200, или ваш endpoint недоступен во время деплоя или всплеска трафика.
Таймаут особенно коварен. Ваш сервер может получить запрос и даже завершить его обработку, но ответ не дойдёт до отправителя вовремя. С точки зрения провайдера это провал, и они повторяют отправку. Без защиты вы обработаете одно и то же событие дважды.
Хорошая модель для мышления: рассматривайте HTTP-запрос как «попытку доставки», а не как «событие». Событие идентифицируется по его ID. Обработка должна основываться на этом ID, а не на том, сколько раз провайдер звонил вам.
Подпись вебхука — это способ отправителя доказать, что запрос действительно от него и не был изменён по пути. Без подписи любой, кто угадал URL вашего webhook, может отправить фейковые события вроде «платёж выполнен» или «пользователь апгрейднулся». Ещё хуже, реальное событие может быть изменено по пути (сумма, ID клиента, тип события) и всё ещё выглядеть легитимно для вашего приложения.
Самый распространённый шаблон — HMAC с общим секретом. Обе стороны знают секрет. Отправитель берёт точную полезную нагрузку вебхука (обычно сырое тело запроса), считает HMAC с этим секретом и отправляет подпись вместе с телом. Ваша задача — пересчитать HMAC по тем же байтам и проверить, совпадают ли подписи.
Данные подписи обычно кладут в HTTP-заголовок. Некоторые провайдеры также включают там метку времени, чтобы вы могли добавить защиту от воспроизведения. Реже подпись вкладывают прямо в JSON, что рискованнее, потому что парсеры или повторная сериализация могут изменить формат и сломать проверку.
При сравнении подписей не используйте обычное сравнение строк. Простое сравнение может выдавать тайминговую утечку, которая позволяет атакующему подобрать подпись за много попыток. Используйте функцию сравнения с постоянным временем из вашей языковой или крипто-библиотеки и отклоняйте при любом несовпадении.
Если клиент жалуется «ваша система приняла событие, которого мы не отправляли», начните с проверки подписи. Если верификация подписи не прошла, скорее всего у вас несовпадение секрета или вы хэшируете неправильные байты (например, парсите JSON вместо использования сырого тела). Если проверка прошла, вы можете доверять идентичности отправителя и переходить к дедупу, порядку и повторам.
Надёжная обработка вебхуков начинается с одного простого правила: проверяйте то, что вы получили, а не то, что вы бы хотели получить.
Считайте сырые байты тела запроса ровно такими, какими они пришли. Не парсите и не пересериализуйте JSON перед проверкой подписи. Малейшие различия (пробелы, порядок ключей, unicode) меняют байты и могут сделать валидную подпись неверной.
Затем воспроизведите точно ту строку, которую провайдер ожидает, чтобы посчитать подпись. Многие системы подписывают строку вроде timestamp + "." + raw_body. Метка времени — не украшение. Она нужна, чтобы можно было отклонять старые запросы.
Вычислите HMAC с использованием общего секрета и нужного хэша (часто SHA-256). Держите секрет в безопасном хранилище и относитесь к нему как к паролю.
Наконец, сравните ваше вычисленное значение с заголовком подписи с помощью сравнения с постоянным временем. Если не совпало — верните 4xx и остановитесь. Не «принимайте всё равно».
Короткий чеклист реализации:
Клиент жалуется «вебхуки перестали работать» после добавления middleware для парсинга JSON. Вы видите несоответствия подписи, чаще на больших полезных нагрузках. Исправление обычно в том, чтобы проверять подпись по сырому телу до любого парсинга и логировать, на каком шаге произошёл сбой (например, "заголовок подписи отсутствует" vs "timestamp вне допустимого окна"). Эта деталь часто сокращает время отладки с часов до минут.
Провайдеры повторяют отправку, потому что доставка не гарантирована. Ваш сервер мог быть недоступен минуту, сетевой узел мог потерять запрос или обработчик мог таймаутить. Провайдер предполагает «возможно, сработало» и отправляет то же событие снова.
Идемпотентный ключ — это квитанционный номер, который вы используете, чтобы распознать событие, которое уже обработали. Это не средство безопасности и не замена проверке подписи. Это также не решит условия гонки, если вы не сохраняете и не проверяете ключ безопасно при конкуренции.
Выбор ключа зависит от того, что даёт провайдер. Отдавайте предпочтение значению, которое стабильно при повторах:
При получении вебхука сначала запишите ключ в хранилище с правилом уникальности, чтобы только один запрос «выиграл». Затем обработайте событие. Если вы видите тот же ключ снова, верните успех, не выполняя работу второй раз.
Держите сохранённую «квитанцию» компактной но полезной: ключ, статус обработки (received/processed/failed), метки времени (first seen/last seen) и минимальное резюме (тип события и связанный объект ID). Многие команды хранят ключи 7–30 дней, чтобы покрыть поздние повторы и большинство клиентских обращений.
Защита от повторной воспроизводимости останавливает простую но неприятную проблему: кто‑то перехватил реальный вебхук (с валидной подписью) и отправляет его снова позже. Если ваш обработчик считает каждую доставку новой, такое воспроизведение может привести к двойным возвратам, дублированным приглашениям пользователей или повторным изменениям статусов.
Обычный подход — подписывать не только payload, но и timestamp. Ваш вебхук включает заголовки вроде X-Signature и X-Timestamp. При получении проверяйте подпись и убеждайтесь, что timestamp свеж в пределах небольшого окна.
Дрейф часов обычно вызывает ложные отклонения. Ваши сервера и сервера отправителя могут расходиться на минуту или две, а сеть может задерживать доставку. Оставьте небольшой буфер и логируйте причину отклонения.
Практические правила, которые хорошо работают:
abs(now - timestamp) <= window (например, 5 минут плюс небольшой запас).Если метки времени отсутствуют, вы не сможете сделать истинную защиту от воспроизведения по времени. В этом случае сильнее опирайтесь на идемпотентность (храните и отклоняйте дубликаты event ID) и подумайте о требовании timestamp в следующей версии webhook API.
Ротация секретов тоже важна. Если вы меняете секреты подписи, держите несколько активных секретов с небольшим перекрытием. Сначала проверяйте по новейшему секрету, затем по старым. Это избегает поломки у клиентов во время выката. Если ваша команда быстро деплоит (например, генерируя код с Koder.ai и используя снапшоты и откат), окно перекрытия поможет, потому что старые версии могут быть ещё живы короткое время.
Повторы — это норма. Предположите, что каждая доставка может быть дублирована, задержана или прийти не по порядку. Ваш обработчик должен вести себя одинаково, увидев событие один раз или пять раз.
Сократите путь запроса. Делайте только необходимое, чтобы принять событие, затем переносите тяжёлую работу в фоновую задачу.
Простой паттерн, который держит себя в продакшене:
Возвращайте 2xx только после того, как вы проверили подпись и записали событие (или поставили в очередь). Если вы отвечаете 200 до сохранения чего‑либо, вы можете потерять события при краше. Если вы выполняете тяжёлую работу до ответа, таймауты вызовут повторы и вы можете продублировать побочные эффекты.
Медленные внешние системы — главная причина, почему повторы становятся болезненными. Если ваш почтовый провайдер, CRM или база данных медленны, пусть очередь поглотит задержку. Воркер может повторять с экспоненциальной задержкой, и вы можете сигнализировать о зависших задачах без блокировки отправителя.
События, пришедшие не по порядку, тоже случаются. Например, subscription.updated может прийти до subscription.created. Стройте устойство, проверяя текущее состояние перед применением изменений, позволяя upsert‑операции и рассматривая «не найдено» как причину для отложенной повторной обработки (когда это имеет смысл), а не как окончательный провал.
Многие «случайные» проблемы с вебхуками самопровоцируемы. Они выглядят как сетевые флюктуации, но повторяются по шаблону, обычно после деплоя, ротации секретов или небольшой правки парсинга.
Самая распространённая ошибка с подписью — хэширование неправильных байтов. Если вы сначала парсите JSON, сервер может его переформатировать (пробелы, порядок ключей, формат чисел). Тогда вы проверяете подпись против другого тела, чем то, что подписал отправитель, и верификация падает, хотя полезная нагрузка настоящая. Всегда проверяйте по сырым байтам тела запроса ровно как пришли.
Следующая большая проблема — секреты. Команды тестируют в staging, но по ошибке проверяют продакшен‑секрет, или не удаляют старый секрет после ротации. Когда клиент жалуется, что «только в одном окружении всё ломается», сначала думайте о неправильном секрете или конфигурации.
Пару ошибок, ведущих к долгим расследованиям:
Пример: клиент говорит «order.paid не дошёл». Вы видите, что после рефактора middleware для парсинга запросов начались ошибки подписи. Middleware читает и заново кодирует JSON, поэтому проверка подписи теперь использует изменённое тело. Исправление простое, но его можно найти только если знать, где искать.
Когда клиент пишет «ваш вебхук не сработал», относитесь к этому как к проблеме трассировки, а не угадыванию. Зафиксируйте одну точную попытку доставки от провайдера и пройдите её сквозь систему.
Начните с идентификатора доставки провайдера, request ID или event ID для провалившейся попытки. С этим одним значением вы должны быстро найти соответствующую запись в логах.
Далее проверьте три вещи в порядке:
Потом подтвердите, что вы вернули провайдеру. Медленный 200 может быть так же плох, как 500, если провайдер таймаутит и повторяет. Посмотрите код статуса, задержку и было ли подтверждение до тяжёлой работы.
Если нужно воспроизвести — делайте это безопасно: храните редактированный сырой пример запроса (ключевые заголовки плюс raw body) и воспроизводите в тестовой среде с тем же секретом и кодом верификации.
Когда интеграция с вебхуками начинает «случайно» падать, скорость важнее совершенства. Этот рукописный план ловит обычные причины.
Возьмите сначала один конкретный пример: имя провайдера, тип события, примерное время (с часовым поясом) и любой event ID, который может назвать клиент.
Дальше проверьте:
Если провайдер говорит «мы повторили 20 раз», сначала проверьте распространённые шаблоны: неверный секрет (падение подписи), дрейф часов (окно воспроизведения), лимиты размера полезной нагрузки (413), таймауты (нет ответа) и всплески 5xx от зависимостей.
Клиент пишет: «Мы пропустили событие invoice.paid вчера. Наша система не обновилась.» Вот быстрый путь трассировки.
Сначала подтвердите, пытался ли провайдер доставить событие. Получите event ID, timestamp, URL назначения и точный код ответа вашего endpoint. Если были повторы, отметьте первую причину неудачи и удалось ли позже.
Далее проверьте, что ваш код увидел на границе: правильный конфиг signing secret для этого endpoint, пересчитайте верификацию подписи по сырому телу запроса и проверьте timestamp относительно вашего допустимого окна.
Будьте осторожны с окнами воспроизведения при повторах. Если окно 5 минут, а провайдер повторил через 30 минут, вы можете отклонить легитимный повтор. Если это ваша политика, документируйте и делайте её осознанно. Если нет — расширьте окно или поменяйте логику так, чтобы идемпотентность оставалась основной защитой от дубликатов.
Если подпись и timestamp выглядят хорошо, пройдите event ID по системе и ответьте: обработали ли вы его, дедупировали или отбросили?
Типичные исходы:
В ответе клиенту держите формулировки чёткими и конкретными: «Мы получили попытки доставки в 10:03 и 10:33 UTC. Первая истекла по таймауту через 10s; повтор был отклонён потому что timestamp вышел за пределы нашего 5‑минутного окна. Мы расширили окно и добавили более быстрое подтверждение. Пожалуйста, при необходимости пришлите событие ID X заново.»
Самый быстрый способ остановить пожары с вебхуками — заставить каждую интеграцию следовать одному и тому же чеклисту. Выпишите контракт, на который вы и отправитель соглашаетесь: обязательные заголовки, точный метод подписания, какой timestamp используется и какие ID вы считаете уникальными.
Затем стандартизируйте, что вы записываете для каждой попытки доставки. Маленького лога‑квитанции обычно достаточно: received_at, event_id, delivery_id, signature_valid, idempotency_result (new/duplicate), handler_version и статус ответа.
Рабочий процесс, который остаётся полезным по мере роста:
Если вы строите приложения на Koder.ai (koder.ai), Planning Mode удобен для определения контракта вебхука в начале (заголовки, подпись, ID, поведение при повторах), а затем генерации согласованного endpoint и записи квитанций в проектах. Эта согласованность делает отладку быстрой, а не героической.
Потому что доставка вебхуков обычно гарантирует по крайней мере один раз (at-least-once), а не «ровно один раз». Провайдеры повторяют отправку при таймаутах, ответах 5xx и иногда когда они не увидели ваш 2xx вовремя, поэтому вы можете получить дубликаты, задержки и события в неправильном порядке даже при рабочей системе.
Правило по умолчанию: сначала проверьте подпись, затем сохраните/убедитесь в уникальности события, затем ответьте 2xx, а тяжёлую работу выполняйте асинхронно.
Если вы делаете тяжёлую работу до ответа, вы столкнётесь с таймаутами и повторами; если отвечаете до записи, можете потерять события при краше.
Используйте сырые байты тела запроса (raw request body) точно как пришли. Не парсите JSON и не сериализуйте заново перед проверкой — пробелы, порядок ключей и формат чисел могут изменить байты и сломать подпись.
Также убедитесь, что вы воспроизводите подписываемую строку провайдера точно (часто это timestamp + "." + raw_body).
Верните 4xx (обычно 400 или 401) и не обрабатывайте нагрузку.
Залогируйте минимальную причину (нет заголовка подписи, несоответствие, окно времени просрочено), но не логируйте секреты или полные чувствительные тела.
Идемпотентный ключ — это стабильный уникальный идентификатор, который вы сохраняете, чтобы повторы не применяли побочные эффекты повторно.
Лучшие варианты:
Применяйте уникальное ограничение, чтобы при конкуренции только один запрос «побеждал».
Запишите идемпотентный ключ до побочных эффектов с правилом уникальности. Затем:
Если вставка не удаётся потому что ключ уже есть, верните 2xx и пропустите бизнес-логику.
Подписывайте не только полезную нагрузку, но и метку времени. Заголовки вроде X-Signature и X-Timestamp позволяют проверять подпись и одновременно убеждаться, что запрос свежий.
Чтобы не отвергать легитимные повторы:
Не полагайтесь на порядок доставки. Сделайте обработчики устойчивыми:
Сохраняйте event ID и тип, чтобы понимать, что произошло, даже при странном порядке.
Логируйте маленькую «квитанцию» для каждой попытки доставки, чтобы можно было проследить событие end-to-end:
Держите логи доступными по поиску по event ID — поддержка сможет быстро отвечать клиентам.
Попросите одну конкретную идентифицирующую вещь: event ID или delivery ID и примерное время.
Проверяйте в таком порядке:
Если вы используете единый шаблон обработчика (verify → record/dedupe → queue → respond), расследование идёт очень быстро.