Прямой ответ
Вебхук — это «чужой сервис звонит вам по HTTP», и три правила приёма делают интеграцию надёжной. Проверяйте подпись: каждый серьёзный отправитель подписывает тело HMAC-ключом (X-Hub-Signature-256, Stripe-Signature и т.п.) — сравнение hmac.compare_digest (константное время — защита от timing-атак) без проверки подпись = любой, кто узнал URL, шлёт вам «события оплаты». Отвечайте 200 быстро (<2–5 секунд): обработчик сохраняет событие и ставит фоновую задачу (Celery) — тяжёлая работа внутри хук-обработчика приводит к таймаутам отправителя и ретраям, а ретраи — к дублям. Обрабатывайте идемпотентно: каждое событие имеет уникальный id — обработанное событие при повторной доставке пропускается (идемпотентность); дубли — не исключение, а норма жизни вебхуков (at-least-once доставка). К этому добавляются: приём только POST с валидацией схемы, логирование каждой доставки (сырое тело + подпись + результат — разбор «событие не дошло» без логов невозможен), и понимание: порядок событий не гарантирован (payment.succeeded может прийти раньше payment.created — состояние выводить из данных, не из последовательности).
Подпись и аутентификация: закрытие входа
Механика проверки (GitHub-стиль): отправитель считает HMAC-SHA256(secret, raw_body) и шлёт в заголовке; получатель повторяет расчёт на сыром теле (классический баг: проверка подписи от распарсенного-и-пересериализованного JSON — байты другие, подпись не сходится «иногда» — парсить тело нужно один раз и из тех же байт), сравнивает compare_digest. Stripe-вариация — подпись включает timestamp (защита от replay: событие старше 5 минут отклоняется) — паттерн стоит копировать и для своих вебхуков: t=...,v1=... с проверкой окна времени. Секрет ротации ради — два активных секрета (старый+новый проверяются, переключение после перехода отправителя). Дополнительные слои по ситуации: IP-allowlist отправителя (если публикует диапазоны — файрвол-правило; диапазоны меняются — следите за анонсами) и секрет в URL — слабый, но бесплатный слой от сканеров (URL утёк в логи прокси — уже не секрет; только как дополнение к подписи). Особый случай — webhook без подписи от отправителя (редкие легаси-сервисы): тогда свой challenge-response при настройке + строгая схема + подтверждение фактов обратным API-запросом («пришёл payment.succeeded — проверили статус заказа у отправителя API-ом» — webhook как триггер, не как источник истины).
Быстрый 200 и фоновая обработка
Почему скорость ответа критична: отправители ставят таймауты (5–30 с) и при таймауте/5xx ретраят с экспоненциальным backoff (Stripe — до трёх дней; GitHub — повторы с задержками) — медленный обработчик порождает лавину дублей и, в пределе, блокировку доставки («вы не отвечаете — отключаем вебхук»). Правильный контур: хук-эндпоинт делает три вещи — проверяет подпись, сохраняет сырое событие в таблицу/очередь (атомарно с фактом приёма), возвращает 200/202; обработка — воркер из очереди (гарантии доставки: at-least-once + идемпотентный обработчик). Что делать с ошибками обработки: событие сохранено — 200 уже отдан (честно: «доставку принял»), провал воркера — его ретраи и dead-letter, не ретраи отправителя; «вернуть 500, чтобы отправили ещё раз» — антипаттерн (доставка и обработка — разные контракты; смешивание даёт непредсказуемые повторы). Отдельно — дедупликация на входе: уникальный индекс по event_id в таблице приёма (повторная доставка вставляется конфликтом — обработка не запускается дважды даже при гонке параллельных запросов).
Порядок, версионирование, отладка
Порядок не гарантирован: параллельные доставки, ретраи, пауза-возобновление — invoice.paid может обогнать invoice.created; решение — выводить состояние из payload/обратного API-запроса, а не из последовательности («создан» после «оплачен» — обновить статус, не создать запись заново); «пропущенное» событие компенсируется периодической сверкой (polling-реконсиляция раз в час — страховка от потерянных доставок). Версии событий: отправители меняют схемы (v1 → v2) — обработчик tolerant reader (известные поля типизированы, неизвестные игнорируются — не падать на новом поле), подписка на явные версии в настройках вебхука. Отладка доставки: локальная разработка — туннель (ngrok/cloudflared — публичный URL на localhost; или локальный reverse-proxy); журнал доставок у отправителя (GitHub/Stripe показывают каждую попытку с ответом — «не дошло» часто значит «ваш сервер вернул 500 неделю назад»); свой журнал приёма — сырое тело, заголовки, результат подписи, время обработки; replay-инструменты (Stripe CLI trigger/resend, GitHub redeliver) — воспроизведение события без ожидания реального. Мониторинг: метрика «событий в час по типам» — обвал до нуля — сигнал «доставка встала» (раньше, чем жалоба клиента).
Практический сценарий: приём платежей без потерянных заказов
Интеграция: платёжный провайдер шлёт payment.succeeded/failed/refunded. Реализация: эндпоинт /webhooks/payments — проверка Stripe-подписи с timestamp-окном, сохранение события в webhook_events (unique по id), 200 за 30 мс; воркер: по payment.succeeded — найти заказ по metadata, перейти статусом «оплачен» (идемпотентно: повторный вызов при уже оплаченном — no-op с логом), отправить письмо; по неизвестному типу — сохранить и пропустить (новый тип события не роняет обработчик). Инцидент первой недели: клиент заплатил, заказ не открылся — разбор по журналу: событие доставлено, воркер упал на отправке письма (SMTP недоступен) — ретрай задачи упёрся в лимит и ушёл в dead-letter; фикс: письмо — отдельная задача (статус заказа не зависит от почты), dead-letter-очередь с алертом разобрана, заказы открыты. Вывод, зашитый в архитектуру: обработчик события делает минимум критичного, остальное — декомпозировано; каждое событие имеет полный трейс (получено → обработано → побочные действия) — любой спор «мы не получили оплату» решается за минуту по логу. Периодическая сверка (часовой polling списка платежей против заказов) — раз в квартал ловит одно потерянное событие — страховка работает.
FAQ
Что отвечать отправителю: 200 или 202? Оба принимаются (2xx = доставлено); 202 семантически точнее для «принял, обработаю позже» — но проверьте документацию отправителя (единичные сервисы ждут строго 200); важнее — быстро и 2xx, а не код из двух.
Нужно ли шифровать тело вебхука? TLS транспорта достаточно (подпись закрывает целостность/авторство); шифрование payload поверх — ниша с регуляторными требованиями; чувствительные данные в теле (карты целиком) — нарушение PCI в любом случае, провайдеры шлют токены/референсы.
Как тестировать обработчик без реальных событий? Три уровня: юниты на обработчик (фикстуры событий из журнала — реальные payload'ы обезличенные), интеграция — replay через CLI/tunнель в dev-контур, и fault-инъекция (SMTP лежит, БД медленная) — поведение ретраев/dead-letter проверяется до инцидента.
Почему отправитель отключил наш вебхук? Автоотключение при серии недоставок (таймауты/5xx подряд — GitHub/Stripe деактивируют после порога): профилактика — быстрый 200, алерт на рост ошибок приёма, мониторинг «последнее событие получено N минут назад» (тишина — тоже сигнал); восстановление — включение в панели + replay пропущенных.
Источники: документация GitHub webhooks, Stripe webhooks, практики Svix — ссылки в шапке.