Все статьи
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([])         # без auth для вебхуков
@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 без понятного сообщения в логах.