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