Надёжность вебхуков: перестаньте пропускать события Stripe, GitHub и Slack
Надёжность вебхуков предотвращает пропуск или дублирование событий Stripe, GitHub и Slack с помощью подписей, идемпотентности, ретраев и обработки ошибок (dead‑letter).

Почему обработчики вебхуков падают в реальной жизни
Вебхук — это callback: одна система присылает вашему серверу HTTP‑запрос, когда что‑то происходит, например платёж, push или новое сообщение.
На бумаге это звучит просто. В продакшене надёжность ломается, потому что сети ненадёжны, а провайдеры защищают себя с помощью ретраев. То же событие может прийти дважды, прийти с опозданием или вообще выглядеть так, будто не приходило.
Команды обычно сталкиваются с несколькими повторяющимися режимами ошибок:
- Пропущенные события: ваш endpoint завис, упал или был недоступен на короткое время.
- Дубликаты: провайдер ретраит, и вы обработали одно и то же событие дважды.
- Доставка вне порядка: событие B приходит раньше A, хотя A случилось первым.
- Частичная обработка: вы записали данные в базу, затем упали до ответа, и вас ретраят.
Большинство провайдеров гарантируют «как минимум один раз» (at least once), а не «ровно один раз». Они будут пытаться доставить, но не могут гарантировать идеальное время, порядок или единоразовую доставку.
Поэтому цель не в том, чтобы вебхуки вели себя идеально. Цель — сделать так, чтобы результаты были корректны, даже если запросы приходят дважды, с опозданием или вне порядка. Остальное руководство фокусируется на четырёх защита́х, которые покрывают большинство реальных ошибок: идемпотентность, проверка подписи, разумные ретраи и путь dead‑letter, чтобы ошибки становились видимыми, а не молчали.
Что Stripe, GitHub и Slack сделают с вашим endpoint
Провайдеры вебхуков вежливы, но не терпеливы. Они пришлют событие, подождут короткое время и, если ваш endpoint не ответит так, как они ожидают, попытаются снова. Это нормальное поведение.
Можно ожидать со временем следующее:
- Таймауты: endpoint отвечает слишком долго, и провайдер считает попытку неудачной.
- Ретраи: они повторно отправляют то же событие, иногда несколько раз.
- Всплески: тихий день превращается в 200 событий за минуту.
- Временные сбои: сервер возвращает 500, деплой перезапускает воркер, или DNS даёт сбой.
- Задержки доставки: события приходят минутами позже, чем вы ожидаете.
Дублирующая доставка удивляет многие команды. Даже если ваш код сделал всё правильно, провайдер может этого не знать. Если ваш хендлер таймаутнулся, вернул не‑2xx или закрыл соединение раньше времени, то то же событие может вернуться. Если вы считаете каждую доставку новой, вы можете списать деньги дважды, сделать двойной апгрейд, отправить повторные письма или создать дубли записей.
Порядок тоже не гарантирован. Вы можете увидеть «subscription.updated» раньше «subscription.created» или редактирование сообщения Slack раньше его создания, в зависимости от ретраев и путей в сети. Если логика предполагает чистую последовательность, вы можете перезаписать более новую запись старой.
Ситуация усугубляется, когда хендлер зависит от медленной работы вниз по стеку: запись в базу, отправка письма или вызов другого API. Реалистичная ошибка выглядит так: ваш код ждёт от почтового провайдера 8 секунд, отправитель вебхуков таймаутит через 5 секунд, ретраит, и теперь два запроса одновременно пытаются обновить одну и ту же запись.
Хороший хендлер относится к вебхукам как к ненадёжным доставкам: принимать быстро, проверять, дедупить и обрабатывать контролируемо.
Идемпотентность: единственная мера, которая предотвращает двойную обработку
Идемпотентность означает: если одно и то же событие вебхука попадает на ваш сервер два раза (или десять раз), система в итоге окажется в том же состоянии, что и при одном обработанном событии. Это важно, потому что ретраи — нормальное явление.
На практике идемпотентность — это дедуп с памятью. Когда приходит событие, вы проверяете, обрабатывали ли вы его уже. Если да — возвращаете успех и ничего не делаете. Если нет — обрабатываете и фиксируете, что сделали.
Что нужно для дедупа
Вам не нужно много, но нужно что‑то стабильное:
- ID события от провайдера (лучше всего, когда есть)
- Имя провайдера (Stripe vs GitHub vs Slack)
- Когда вы его впервые увидели (полезно для очистки и отладки)
- Статус обработки (получено, обработано, ошибка)
Храните это в надёжном месте. Таблица в базе — самый безопасный вариант по умолчанию. Кэш с TTL может подойти для низкорисковых событий, но он может забыть данные при рестартах или вытеснении. Для операций с деньгами или изменением доступа считайте запись дедупа частью ваших данных.
Как долго хранить ключи? Дольше, чем окно ретраев провайдера и дольше, чем ваши собственные отложенные попытки. Многие команды держат от 7 до 30 дней, затем удаляют старые записи.
Побочные эффекты, которые нужно защищать
Идемпотентность защищает ваши самые рискованные действия: двойное списание, повторная отправка писем, двойной апгрейд роли, повторный возврат или создание дублирующих тикетов. Если вы сделаете только одно улучшение этой недели — сделайте это.
Проверка подписи без подвохов
Проверка подписи мешает случайному трафику из интернета выдавать себя за Stripe, GitHub или Slack. Без неё любой может вызвать ваш URL вебхука и триггерить действия вроде «пометить счёт оплаченным» или «пригласить пользователя в рабочее пространство». Поддельные события могут выглядеть достаточно правдоподобно, чтобы пройти сквозь простые JSON‑проверки.
Обычно вы проверяете одно и то же: сырое тело запроса (точные байты), временную метку (чтобы блокировать реплеи), общий секрет и ожидаемый алгоритм (часто HMAC). Если хоть один из входов отличается, подпись не совпадёт.
Подвох, который чаще всего ломает реальные интеграции: парсить JSON до проверки подписи. Многие фреймворки парсят и повторно сериализуют тело, что меняет пробелы или порядок ключей. Ваш код затем сверяет подпись с другой строкой, чем та, что подписал провайдер, и вы отклоняете настоящие события.
Другие распространённые ошибки:
- Использование неправильного секрета (тестовый vs продакшен, или секрет другого endpoint).
- Игнорирование допусков по времени и отклонение валидных событий при дрейфе часов сервера.
- Проверка неверного заголовка (некоторые провайдеры присылают несколько версий подписи).
- Возврат 200 при ошибке валидации подписи, что затрудняет отладку.
Безопасная обработка ошибок проста: если проверка подписи не прошла — быстро отклоняйте и не запускайте бизнес‑логику. Возвращайте понятную клиентскую ошибку (обычно 400, 401 или 403 в зависимости от ожиданий провайдера). Логируйте только то, что помогает диагностике: имя провайдера, ID события (если есть), короткую причину вроде «плохая подпись» или «метка времени устарела» и ваш внутренний request ID. Избегайте логирования сырых тел или полных заголовков — в них могут быть секреты.
Простая архитектура вебхуков, которая остаётся стабильной под нагрузкой
Самый надёжный паттерн — скучный: делайте минимум работы в HTTP‑запросе, затем передавайте реальную работу фоновой задаче.
Безопасный быстрый путь запроса
Когда Stripe, GitHub или Slack зовут ваш endpoint, держите путь запроса коротким и предсказуемым:
- Проверить подпись и базовые заголовки (быстро отклонить, если неверно)
- Записать событие и уникальный ключ события
- Поставить задачу в очередь (или записать строку «inbox»)
- Немедленно вернуть 2xx
Быстрый возврат 2xx важен, потому что отправители вебхуков ретраятят при таймаутах и 5xx. Если вы делаете медленную работу (фан‑аут в базу, API‑вызовы, отправку почты) до ответа, вы увеличиваете число ретраев, дублирующих доставок и «thundering herd» при инцидентах.
Разделение приёма и обработки
Думайте о двух компонентах:
- Endpoint приёма: проверки безопасности, минимальная валидация, постановка в очередь, 2xx
- Воркер: идемпотентная бизнес‑логика, ретраи и обновления состояния
Это разделение держит endpoint стабильным под нагрузкой, потому что воркер можно масштабировать и он может ретраить без блокировки новых событий. Если Slack пришлёт всплеск событий во время импорта пользователей, endpoint останется быстрым, а очередь поглотит пик.
Для логирования сохраняйте то, что нужно для отладки, не раскрывая секреты или персональные данные: тип события, отправитель (Stripe/GitHub/Slack), delivery ID, результат проверки подписи, статус обработки и метки времени. Не сбрасывайте полные заголовки или тела в логи; храните полезные полезадные полезные полезадные полезадные полезадные payloads только в защищённом хранилище событий, если это действительно нужно.
Пошаговый шаблон обработчика вебхуков, который можно скопировать
Большинство багов с вебхуками происходят потому, что хендлер пытается сделать всё в рамках HTTP‑запроса. Относитесь к входящему запросу как к шагу «квитанция», затем передавайте реальную работу воркеру.
Хендлер запроса (быстрый и строгий)
Этот паттерн работает в любом стеке:
- Валидируйте запрос и получите сырое тело. Проверьте метод, ожидаемый путь и content‑type. Сохраните сырые байты до любого парсинга JSON, чтобы проверки подписи не ломались.
- Проверяйте подпись как можно раньше. Отклоняйте с понятным 4xx при неверной подписи. Не «угадывайте», что payload означал.
- Извлеките ID события и соберите идемпотентный ключ. Отдавайте предпочтение event ID от провайдера. Если его нет, сформируйте ключ из стабильных полей (источник + метка времени + действие + ID объекта).
- Запишите запись идемпотентности до побочных эффектов. Выполните атомарный insert типа «event_id ещё не встречался». Если запись уже есть — верните 200 и остановитесь.
- Поставьте работу в очередь и верните 200 быстро. Поместите событие (или указатель на сохранённый payload) в очередь. Веб‑запрос не должен делать вызовы сторонних API, отправлять письма или выполнять тяжёлую работу.
Воркер (безопасные побочные эффекты)
Воркер загружает событие из очереди, выполняет бизнес‑логику и обновляет запись идемпотентности в прозрачное состояние: processing, succeeded или failed. Ретраи управляются здесь, с бэкоффом и ограничением числа попыток.
Пример: webhook оплаты Stripe пришёл дважды. Второй запрос видит тот же event ID, находит существующую запись идемпотентности и выходит, не делая апгрейда клиента снова.
Ретраи, которые помогают, а не вредят
Ретраи полезны, когда сбой временный. Они вредны, когда превращают реальную проблему в всплеск трафика или когда повторяют запрос, который никогда не должен пройти.
Ретрайте только тогда, когда есть шанс, что следующая попытка сработает: сетевые таймауты, сброс соединений и 5xx от зависимостей. Не ретрайте 4xx ошибки, которые означают «запрос неверен» (неверная подпись, плохой JSON, пропущенные поля). Также не ретрайте, если вы уже знаете, что событие дубликат и безопасно обработано идемпотентностью.
Простое правило:
- Ретрайте: таймауты, 429, 500–599, временные ошибки DNS/соединения
- Не ретрайте: 400–499 (кроме 429), неверная подпись, проваленная схема валидации
- Считайте успехом: уже обработанное событие (идемпотентный повтор)
- Останавливайтесь быстро: если зависимость упала для всех (используйте circuit breaker)
- Всегда: ограничивайте число попыток и общее время
Используйте экспоненциальный бэкофф с джиттером. Проще говоря: подождите немного, затем дольше при каждой попытке, и добавьте небольшой рандом, чтобы ретраи не приходили одновременно. Например: 1с, 2с, 4с, 8с плюс‑минус до 20% случайности.
Задайте и max attempts, и max total retry window. Практичная отправная точка — 5 попыток в течение 10–15 минут. Это предупредит «бесконечные» циклы, которые скрывают проблемы до тех пор, пока они не взорвутся.
Сделайте вызовы вниз по стеку безопасными: короткие таймауты для БД и API, и circuit breaker, чтобы временно перестать обращаться к упавшему сервису на минуту‑две.
Наконец, фиксируйте причину ретрая: таймаут, 5xx, 429, имя зависимости и сколько это заняло. Такие теги превращают «иногда мы пропускаем вебхуки» в исправимое наблюдаемое явление.
Обработка dead‑letter: как не терять события навсегда
Нужен план для событий, которые не обрабатываются даже после ретраев. Dead‑letter queue (DLQ) — место для хранение доставок вебхуков, которые постоянно падают, чтобы они не пропадали в логах и не застревали в бесконечных retry‑циклах.
Хорошая запись DLQ содержит достаточно контекста для отладки и повторного воспроизведения без догадок:
- Сырой payload (как текст) и распарсенный JSON
- Заголовки, нужные для проверки и трассировки (signature, event ID, timestamp)
- Сообщение об ошибке и стек трейса (или короткая причина падения)
- Счётчик попыток и метки времени каждой попытки
- Внутренний статус обработки (создан пользователь, обновлён план и т. п.)
Сделайте повтор безопасным. Повторы должны идти по тому же идемпотентному пути обработки, используя стабильный ключ события (обычно event ID провайдера). Таким образом, повторное проигрывание не создаст дубликатов.
Простой рабочий процесс позволяет не‑техническим командам действовать быстро без правок кода. Например, когда событие оплаты не прошло из‑за временной проблемы с базой, кто‑то может воспроизвести его, когда система снова здорова.
Держите рабочий процесс минимальным:
- Автоматически отправлять повторяющиеся ошибки в DLQ после N попыток
- Показывать короткое сообщение «что упало» и сводку payload
- Разрешать повтор с идемпотентностью
- Разрешать «пометить как проигнорированное» с обязательной заметкой
- Эскалировать в инженерную команду, если та же ошибка повторяется
Задайте время хранения и алерты. Храните элементы DLQ достаточно долго, чтобы покрыть выходные и отпуска (обычно 7–30 дней), и сигнализируйте владельцу при росте DLQ выше небольшого порога.
Пример: предотвращение двойного апгрейда из‑за webhook Stripe
Типичный поток Stripe: клиент платит, Stripe присылает событие payment_intent.succeeded, и ваше приложение повышает аккаунт.
Вот как это ломается. Ваш хендлер получил событие, затем пытается обновить базу и вызвать биллинг‑функцию. База замедляется, запрос таймаутит, и endpoint возвращает 500. Stripe думает, что доставка не удалась, и ретраит. Теперь то же событие приходит снова, и пользователь получает апгрейд дважды (или две учётные записи, два помеченных счёта, два приветственных письма).
Исправление многослойное:
Сначала проверьте подписку Stripe до всего прочего. Если подпись неверна — верните 400 и остановитесь.
Далее сделайте обработку идемпотентной, используя event.id от Stripe. Храните запись processed_events(event_id) с уникальным ограничением. Когда событие приходит:
- Если
event_idновый — принимайте его. - Если
event_idуже есть — верните 200 и ничего не делайте.
Затем разделите приём и работу: валидируйте + запишите + поставьте в очередь, а воркер выполнит апгрейд. Endpoint отвечает быстро, так что таймауты редки.
В конце добавьте путь dead‑letter. Если воркер падает из‑за ошибки базы, сохраните payload и причину аварии для безопасного воспроизведения. Повтор должен запускаться тем же кодом воркера, и идемпотентность гарантирует, что дубли не появятся.
После этих изменений пользователь увидит один апгрейд, меньше задержек и существенно меньше тикетов «я заплатил дважды» в саппорте.
Распространённые ошибки, которые создают тихие баги вебхуков
Большинство багов с вебхуками негромкие. Ваш endpoint возвращает 200, дашборды в порядке, а через недели вы замечаете пропущенные апгрейды, дублирующиеся письма или рассинхронизированные записи.
Одна классическая ошибка — случайно сломанная проверка подписи. Многие провайдеры подписывают сырое тело запроса, но некоторые фреймворки парсят JSON и меняют пробелы или порядок ключей. Если вы проверяете подпись по распарсенному телу, хорошие запросы могут выглядеть как подделка и быть отвергнуты. Исправление: проверяйте по сырым байтам, затем парсите.
Ещё один тихий провал происходит, когда вы возвращаете 200 слишком рано. Если вы подтвердили вебхук, а затем упали при обработке (запись в БД, постановка в очередь), провайдер не будет ретраить, потому что вы уже сказали, что всё хорошо. Подтверждайте успех только после того, как вы безопасно зафиксировали событие (или поставили в очередь).
Выполнение медленной работы в потоке запроса — ещё один убийца надёжности. Отправители вебхуков часто имеют короткие таймауты. Если вы делаете тяжёлую логику или сетевые вызовы до ответа, будут ретраи, дубликаты и пропущенные события.
Ошибки в дедупе тоже могут быть тонкими. Если вы дедупите по неправильному ключу — например по user ID или репозиторию — вы будете случайно отбрасывать реальные события. Дедуп должен базироваться на уникальном идентификаторе события (иногда плюс тип события), а не на субъекте события.
Наконец, будьте осторожны с логами. Сброс полных payload‑ов может раскрыть секреты, токены, имейлы или внутренние ID. Логируйте минимальный контекст (event ID, тип, метки времени) и редактируйте чувствительные поля.
Быстрый чек‑лист: безопасна ли ваша интеграция вебхуков сейчас?
Хендлер вебхуков «безопасен», когда остаётся корректным при дубликатах, ретраях, медленных базах и с периодическими плохими запросами.
Начните с базовых мер, которые предотвращают мошенничество и двойную обработку:
- Проверяйте подпись по сырым байтам тела (до парсинга JSON и любых трансформаций).
- Создавайте и сохраняйте запись идемпотентности до побочных эффектов. Сначала сохраните event ID (или вычисленный ключ), затем выполняйте работу.
- Возвращайте быстрый 2xx сразу после того, как запрос проверен и надёжно поставлен в очередь.
- Установите ясные таймауты везде. Для хендлера, вызовов в БД и исходящих API.
- Ретраи с бэкоффом и лимитом. Ретраи должны замедляться со временем и останавливаться после лимита.
Затем проверьте возможность восстановления, если что‑то всё же упало:
- Есть ли хранение dead‑letter с payload, нужными заголовками, причиной ошибки и счётчиком попыток?
- Реально ли воспроизвести событие? Вы можете повторно запустить DLQ‑элемент безопасно, и идемпотентность предотвратит дубли.
- Есть базовый мониторинг: числа полученных, обработанных, ретраенных и отправленных в DLQ событий, и алерт при росте DLQ.
Простой интуитивный вопрос: если сервер рестартнулся в середине запроса, потеряете ли вы событие или обработаете его дважды? Если не уверены — начните с этого.
Следующие шаги, если ваши вебхуки уже хрупкие
Если у вас уже есть пропущенные события или странные дубликаты, относитесь к этому как к небольшому проекту по ремонту, а не к быстрому патчу. Возьмите одну интеграцию (Stripe, GitHub или Slack) и исправьте её полностью, прежде чем браться за остальные.
Практический порядок действий:
- Сначала добавьте проверку подписи и сделайте ошибки заметными в логах.
- Сделайте обработку идемпотентной (сохраните event ID и игнорируйте повторы).
- Разделите «receive» и «process» (подтверждайте быстро, выполняйте работу фоново).
- Добавьте безопасные ретраи с бэкоффом для временных ошибок.
- Добавьте обработку dead‑letter, чтобы упавшие события сохранялись для проверки.
Потом напишите небольшой план тестов, который можно запускать при изменениях:
- Дублирование доставки: отправьте одно и то же событие дважды и убедитесь, что эффект применяется только один раз.
- Неверная подпись: подтвердите, что запрос отклоняется и ничего не обрабатывается.
- События вне порядка: проверьте, что система остаётся консистентной.
- Медленные зависимости: симулируйте таймаут, чтобы проверить безопасные ретраи.
Если вы унаследовали код вебхуков, сгенерированный инструментом ИИ и он кажется хрупким (трудно понять, неожиданные побочные эффекты, секреты в странных местах), целенаправленный рефакторинг часто быстрее, чем преследование симптомов. FixMyMess (fixmymess.ai) помогает командам превратить сломанные AI‑прототипы в продакшен‑код: диагностировать логические ошибки, укрепить безопасность и перестроить шаткие потоки вебхуков в безопасный паттерн ingest‑and‑worker.
Часто задаваемые вопросы
Почему одно и то же событие вебхука приходит несколько раз?
Относитесь к дубликатам как к норме, а не к редкому случаю. Большинство провайдеров доставляют вебхуки по крайней мере один раз, поэтому таймаут или краткий 500 может привести к повторной отправке события, даже если ваш код уже отработал.
Когда мой endpoint вебхука должен возвращать 200?
Возвращайте 2xx только после того, как вы проверили подпись и безопасно зафиксировали событие (или поставили в очередь) таким образом, чтобы можно было восстановиться. Если вы вернёте 200, а затем запись в базу или постановка в очередь упадёт, провайдер подумает, что всё прошло удачно, и не будет повторять отправку — это приводит к скрытой потере данных.
Как предотвратить двойное списание или двойной апгрейд из‑за ретраев?
Используйте идемпотентность на основе стабильного уникального ключа, желательно event ID от провайдера. Сохраните этот ключ в долговременном хранилище с уникальным ограничением — при повторном поступлении того же ключа просто завершайте обработку и возвращайте успех, чтобы прекратить ретраи.
Как обрабатывать события вне порядка, чтобы не портить состояние?
Не полагайтесь на порядок доставки. Делайте изменения условными на основе версий, временных меток или текущего состояния, чтобы старое событие не перезаписало более новое. Проектируйте хендлеры так, чтобы каждое событие было безопасно применимо, даже если оно пришло позже.
Почему проверка подписи падает, хотя секрет правильный?
Проверяйте подпись против сырых байтов тела запроса, точно как они пришли, до любой парсинга JSON или повторной сериализации. Многие фреймворки меняют пробелы или порядок ключей при парсинге, и это достаточно, чтобы корректная подпись не совпала.
Стоит ли делать бизнес‑логику прямо в обработчике запроса?
Хорошая практика — проверить подпись, записать строку индекса/инбокс (idempotency record), поставить работу в очередь и вернуть ответ. Медленная логика, как отправка писем или вызовы сторонних API, должна выполняться в воркере, чтобы провайдер не успевал тайм‑аутиться и не ретраить.
Какие ошибки стоит ретраить, а какие — нет?
Ретрайте только когда вероятность успеха на следующей попытке высока: таймауты, сетевые ошибки, 429 и 5xx от зависимостей. Не ретрайте 4xx (кроме 429), неверные подписи или ошибки валидации. Всегда ограничивайте число попыток и общее окно повторов, чтобы ошибки становились видимыми, а не застревали в бесконечном цикле.
Что хранить для дедупа и как долго это держать?
Запишите ключ для дедупа (dedupe key), когда впервые увидели событие, и статус обработки, чтобы различать полученные, завершённые и упавшие задачи. Для всего, что связано с деньгами или правами доступа, храните такие записи надёжно и дольше, чем окно ретраев провайдера.
Что такое dead‑letter очередь и когда она нужна?
Очередь ошибок (dead‑letter) — это место, куда попадают события после исчерпания попыток, чтобы они не терялись. Храните достаточно контекста для отладки и повторного воспроизведения, и обеспечьте, чтобы повторный прогон шёл тем же идемпотентным путём, чтобы повторы не создавали дублирующих эффектов.
Мои вебхуки хрупки и сгенерированы ИИ — с чего начать починку?
Чаще всего не хватает одного из базовых слоёв безопасности: проверки подписи, идемпотентности, быстрого подтверждения с фоновой обработкой, контролируемых ретраев или видимости dead‑letter. Если код генерировал ИИ и его сложно понимать, лучше провести аудит, починить логику и перестроить поток на ingest‑and‑worker.