Усі статті
DjangoPythonwebhook debugginglocal testing

Вебхуки Django: пройти CSRF і потрапити в обробник

З коробки Django відхиляє кожен POST-вебхук кодом 403. Винуватець — CSRF-middleware, що робить свою справу, захищаючи від міжсайтової підробки запитів, тільки от провайдер не браузер і не несе ні сесійної cookie, ні CSRF-токена. Рішення — один декоратор і один рядок усвідомлення про доступ до тіла запиту.

Пропустити CSRF для вебхук-вʼю

По-Django:

from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse, HttpResponseBadRequest
import hmac, hashlib

@csrf_exempt
def stripe_webhook(request):
    if request.method != 'POST':
        return HttpResponseBadRequest('POST only')

    payload = request.body            # сирі байти, недоторкані
    signature = request.headers.get('Stripe-Signature', '')

    # Перевірка підпису (у реальному коді використовуйте SDK Stripe)
    if not verify(payload, signature, settings.STRIPE_WEBHOOK_SECRET):
        return HttpResponse(status=401)

    event = json.loads(payload)
    handle_stripe_event(event)
    return HttpResponse(status=200)

Декоратор @csrf_exempt прибирає перевірку middleware лише для цієї вʼю — інші форми лишаються під захистом. request.body повертає сирі байти, як вони прийшли, — саме те, що потрібно перевірці підпису.

Якщо ви використовуєте Django REST Framework

DRF загортає запити у власний обʼєкт Request і запускає парсери за content-type. Поки код вʼю читає request.data, JSON-парсер уже спожив тіло. Два виходи:

Варіант A: лишити як звичайну Django-вʼю

Обробникам вебхуків не потрібні парсери DRF, browsable API чи механіка авторизації. Звичайна @csrf_exempt-вʼю поруч із вашими DRF-ендпоінтами — найчистіший патерн.

Варіант B: DRF із сирим доступом

Якщо вам справді потрібен DRF, читайте request.body (підлеглий запит Django) замість request.data:

from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny

@api_view(['POST'])
@authentication_classes([])         # без авторизації для вебхуків
@permission_classes([AllowAny])     # провайдер уже автентифікується підписом
def stripe_webhook(request):
    payload = request._request.body  # підлеглий запит Django, сирі байти
    signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
    # ...перевірити й обробити

Непрямість request._request незграбна, але це офіційний вихід, коли треба обійти парсинг DRF.

Налаштуйте тунель

  1. Запустіть dev-сервер Django: python manage.py runserver 8000.
  2. Додайте хостнейм тунелю до ALLOWED_HOSTS у settings.py. Найпростіше ['*'] під час локальної розробки, або конкретний домен тунелю, якщо так зручніше.
  3. npx portpreview 8000 в окремому терміналі.
  4. Вставте URL тунелю плюс шлях вебхука в дашборд провайдера.
  5. Запустіть тестову подію і дивіться, як вона надходить у вашу вʼю.

Забутий ALLOWED_HOSTS дає узагальнений Django-400 без очевидної причини. Якщо перша доставка вебхука повертає 400, а логів вашої вʼю не видно — ось чому.

Поширені пастки

Подвійне читання тіла

Як і будь-який фреймворк, Django віддає тіло як потік, який можна прочитати лише раз. Якщо middleware чи логувальна обгортка вже торкнулись request.body, код вʼю побачить порожній обʼєкт bytes. Для перевірки виведіть len(request.body).

Часові пояси й допуск за часом

Якщо порівнюєте підписану мітку часу з datetime.now(), використовуйте timezone.now() або порівнюйте в UTC. Порівняння наївних datetime періодично ламаються під час переходу на літній час.

Async-вʼю

Django 4+ підтримує вʼю async def. Обробники вебхуків можуть бути async, але памʼятайте, що SDK перевірки підпису (Stripe Python тощо) синхронні. Запускайте їх через asgiref.sync_to_async або тримайте всю вʼю синхронною.

Нотатки про продакшен-деплой

Коли локальне тестування позаду, та сама вʼю працює в продакшені. Gunicorn, uWSGI, Daphne — жоден не торкається request.body до того, як його побачить вʼю. Ваш reverse proxy може (наприклад, nginx з увімкненим proxy_buffering), але це про буферизацію, а не зміну тіла.

Щодо базової теорії підписів див. посібник з перевірки підписів. Для специфіки Stripe бік дашборда покриває посібник з локального тестування Stripe. Приєднайтеся до списку очікування PortPreview заради тунелів, що добре ладнають із dev-серверами Python.

Поширені запитання

Чому Django повертає 403 на кожен POST-вебхук?
CSRF-middleware Django відхиляє POST без дійсного CSRF-токена. Провайдери вебхуків його не надсилають. Додайте @csrf_exempt до вашої вебхук-вʼю (лише до неї), щоб обійти перевірку, і натомість перевіряйте підпис.
Вебхук-вʼю мають бути на чистому Django чи DRF?
Чистий Django простіший для вебхуків. Парсери DRF споживають тіло до того, як його побачить вʼю, що ламає перевірку підпису. Якщо лишаєте DRF, читайте request._request.body, щоб отримати сирі байти.
Яке налаштування ALLOWED_HOSTS потрібне для тунелів у Django?
Додайте домен тунелю до ALLOWED_HOSTS у settings.py. Під час локальної розробки працює ['*']; для суворіших конфігурацій вкажіть конкретний хостнейм тунелю. Без цього Django повертає 400 без зрозумілого повідомлення в логах.