Вебхук, що повертає 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 (підпис дійсний, але застарілий — імовірно, ви тестуєте старим повторно надісланим payload).
- Кінцеві пробіли в секреті, завантаженому з 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 заради тунелю з вбудованим захопленням.