Out of the box, Django will reject every webhook POST with a 403. The culprit is the CSRF middleware doing its job — protecting against cross-site request forgery — except the provider is not a browser and doesn't carry a session cookie or CSRF token. The fix is one decorator and one line of awareness about request body access.
Skip CSRF for webhook views
The Django way:
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 # raw bytes, untouched
signature = request.headers.get('Stripe-Signature', '')
# Verify signature (use stripe SDK in real code)
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)
The @csrf_exempt decorator removes the middleware check for this view only — your other forms still get the protection. request.body returns the raw bytes as they arrived, which is exactly what signature verification needs.
If you're using Django REST Framework
DRF wraps requests in its own Request object and runs parsers based on the content-type. By the time your view code reads request.data, the JSON parser has already consumed the body. Two ways out:
Option A: keep it as a plain Django view
Webhook handlers don't need DRF's parsers, browsable API, or auth machinery. A plain @csrf_exempt view next to your DRF endpoints is the cleanest pattern.
Option B: use DRF with raw access
If you really want DRF, read request.body (the underlying Django request) instead of request.data:
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny
@api_view(['POST'])
@authentication_classes([]) # no auth for webhooks
@permission_classes([AllowAny]) # provider already authenticates via signature
def stripe_webhook(request):
payload = request._request.body # underlying Django request, raw bytes
signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
# ...verify and handle
The request._request indirection is awkward but it's the official escape hatch when you need to bypass DRF's parsing.
Set up the tunnel
- Run your Django dev server:
python manage.py runserver 8000. - Add the tunnel's hostname to
ALLOWED_HOSTSinsettings.py. Easiest is['*']during local dev, or a specific tunnel domain if you prefer. npx portpreview 8000in a separate terminal.- Paste the tunnel URL plus your webhook path into the provider's dashboard.
- Trigger a test event and watch it land in your view.
Forgetting ALLOWED_HOSTS produces a generic Django 400 with no obvious cause. If your first webhook delivery returns 400 and you can't see your view logs, that's why.
Common pitfalls
Reading the body twice
Like every framework, Django gives you the body as a stream that can only be read once. If middleware or a logging wrapper has already touched request.body, your view code sees an empty bytes object. Print len(request.body) as a sanity check.
Time zones and timestamp tolerance
If you're comparing the signed timestamp to datetime.now(), make sure you're using timezone.now() or comparing in UTC. Naive datetime comparisons fail intermittently when daylight saving changes happen.
Async views
Django 4+ supports async def views. Webhook handlers can be async, but be aware that signature verification SDKs (Stripe Python, etc.) are sync. Run them with asgiref.sync_to_async or keep the whole view synchronous.
Production deployment notes
Once you're past local testing, the same view works in production. Gunicorn, uWSGI, Daphne — none of them touch request.body before your view sees it. Your reverse proxy might (nginx with proxy_buffering on, for example), but that's about buffering, not body modification.
For the underlying signature theory, see the signature verification guide. For Stripe-specific patterns the Stripe local testing guide covers the dashboard side. Join the PortPreview waitlist for tunnels that play nicely with Python dev servers.