All articles
DjangoPythonwebhook debugginglocal testing

Django Webhooks: Past CSRF and Into the Handler

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

  1. Run your Django dev server: python manage.py runserver 8000.
  2. Add the tunnel's hostname to ALLOWED_HOSTS in settings.py. Easiest is ['*'] during local dev, or a specific tunnel domain if you prefer.
  3. npx portpreview 8000 in a separate terminal.
  4. Paste the tunnel URL plus your webhook path into the provider's dashboard.
  5. 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.

Frequently asked questions

Why does Django return 403 on every webhook POST?
Django's CSRF middleware rejects POSTs without a valid CSRF token. Webhook providers don't send one. Add @csrf_exempt to your webhook view (only that view) to bypass the check, then verify the signature instead.
Should webhook views be plain Django or DRF?
Plain Django is simpler for webhooks. DRF's parsers consume the body before your view sees it, which breaks signature verification. If you keep DRF, read request._request.body to get the raw bytes.
What's the ALLOWED_HOSTS setting for tunnels in Django?
Add your tunnel domain to ALLOWED_HOSTS in settings.py. During local development, ['*'] works; for stricter setups list the specific tunnel hostname. Without this, Django returns a 400 with no clear log message.