Вебхук, возвращающий 401 или 403, попадает в небольшое число категорий. Чаще всего это даже не проблема подписи — это middleware или дефолт фреймворка отклоняет запрос до того, как запустится ваш код. Вот диагностический чек-лист, который мы используем, в том порядке, в каком мы его применяем.
Сначала отделите 401 от 403
Они означают разное, и исправление разное.
401 Unauthorized: сервер получил запрос, посмотрел на учётные данные (или подпись) и не принял их. Ваш обработчик, вероятно, отработал, вычислил ожидаемую подпись и отклонил.
403 Forbidden: сервер получил запрос и отказался обрабатывать его по другой причине. Часто запрос вообще не дошёл до вашего обработчика — 403 отправил middleware или дефолт фреймворка.
Откройте захват запросов вашего туннеля и посмотрите на ответ. Если видите тело ответа вашего обработчика («invalid signature»), это 401 из вашего кода. Если ответ общий, а логи обработчика пусты, фреймворк отклонил запрос до запуска обработчика.
Пять самых вероятных причин
1. CSRF-middleware (Django, Rails, Laravel)
Дефолтная CSRF-защита отклоняет POST без токена сессии. Провайдеры вебхуков его не присылают. Симптомы: 403, общее тело ответа, нет логов обработчика.
Исправление: исключите маршрут вебхука из CSRF-защиты. В Django @csrf_exempt. В Rails skip_before_action :verify_authenticity_token, only: [:webhook]. В Laravel добавьте путь в VerifyCsrfToken::$except. Руководство по вебхукам Django разбирает версию для Django от начала до конца.
2. Auth-middleware применён слишком широко
Вы добавили auth-middleware глобально (JWT, проверка сессии, требование API-ключа), и маршрут вебхука его унаследовал. Провайдер не присылает ваш auth-заголовок, поэтому middleware отдаёт 401 до запуска обработчика.
Исправление: исключите путь вебхука из вашего auth-middleware. Аутентификация вебхука — это подпись, а не схема, защищающая остальной API.
3. Проверка подписи не проходит
Обработчик отработал, вычислил ожидаемую подпись, и она не совпала. Пять подпричин, примерно по убыванию частоты:
- Тело разобрано middleware до проверки (сырого тела больше нет).
- Неверная кодировка (hex vs base64). См. руководство по проверке подписей.
- Неверный секрет (test vs live, dashboard vs CLI, env-файл vs runtime).
- Слишком старый timestamp (подпись валидна, но устарела — вероятно, вы тестируете старой повторно отправленной полезной нагрузкой).
- Замыкающие пробелы в секрете, загруженном из env-файла.
4. Зарегистрирован неверный URL туннеля
Вы перезапустили туннель, URL сменился, но в дашборде провайдера всё ещё старый. Симптом похож на 401, потому что запрос до вас не дошёл, — но на деле другой запрос попадает на другой сервер (часто предыдущую сессию туннеля, которая теперь отказывает или возвращает 401).
Исправление: убедитесь, что URL в дашборде провайдера совпадает с текущей сессией туннеля. Нужен стабильный URL — посмотрите именованные туннели или зарезервированный поддомен.
5. Ограничения CORS, content-type или метода
Для вебхуков реже, но возможно. Если маршрут принимает только application/json, а провайдер шлёт application/x-www-form-urlencoded (например, Twilio), некоторые фреймворки дают 415 — а неправильно настроенный может дать 403. Или маршрут зарегистрирован на GET, а провайдер делает POST.
90-секундный диагностический поток
Вот порядок, по которому мы идём:
- Прочитайте тело ответа. Если в нём слова вашего обработчика, запрос дошёл до обработчика. Переходите к отладке подписи. Если это общая страница ошибки фреймворка, запрос не дошёл. Переходите к отладке middleware.
- Проверьте логи обработчика. Срабатывают ли какие-либо лог-выражения вашего обработчика? Подтверждает, дошёл ли запрос до вас.
- Проверьте зарегистрированный URL. Откройте дашборд провайдера. Убедитесь, что URL совпадает с текущим туннелем. Убедитесь, что он указывает на правильный путь.
- Сравните входящие заголовки. Захват туннеля показывает точные заголовки, которые прислал провайдер. Сравните с тем, что читает ваш код. Заголовки регистронезависимы, но способ доступа к ним разнится по фреймворкам —
request.headers.get('Stripe-Signature')в одних,request.META['HTTP_STRIPE_SIGNATURE']в других. - Убедитесь, что тело сырое в момент подписи. Выведите длину тела прямо перед вычислением подписи. Если ноль или подозрительно мало — middleware его съел.
- Перепроверьте секрет. Сравните env-переменную в рантайме с дашбордом.
console.log(process.env.WEBHOOK_SECRET.length)— длина совпадает с тем, что показывает дашборд?
По нашему опыту, шаги 1 и 5 ловят 80% случаев в первые две минуты.
Захватите неудавшийся запрос
Самый полезный инструмент здесь — туннель с захватом и повтором запросов. Не нужно ждать повтора от провайдера — вы воспроизводите захваченный запрос против локального обработчика, пока отлаживаете. Каждая попытка мгновенна.
Если вы используете туннель, который не захватывает запросы, вы отлаживаете с одной связанной рукой. Переход на тот, который умеет (или запуск tcpdump, или nginx перед dev-сервером), окупается в первый же раз, когда вы экономите час.
Как 401 выглядит у каждого провайдера
- Stripe: 401 из вашего кода значит, что проверка подписи не прошла. Дашборд Stripe показывает доставку как неудавшуюся и включает ваше тело ответа.
- GitHub: если обработчик возвращает 401, GitHub помечает доставку как неудавшуюся и повторяет. Страница последних доставок показывает ответ.
- Shopify: 401 от обработчика вебхука нормален для безопасности, но Shopify будет повторять. После 19 подряд неудач за 48 часов Shopify отключает подписку.
Более широкий контекст отладки вебхуков см. в как отлаживать вебхуки локально. Запишитесь в лист ожидания PortPreview ради туннеля со встроенным захватом.