Tous les articles
DjangoPythonwebhook debugginglocal testing

Webhooks Django : passer CSRF et arriver au handler

Par défaut, Django rejette tous les POSTs webhook avec un 403. Le coupable est le middleware CSRF qui fait son job — sauf que le provider n'est pas un navigateur et n'a ni session ni token CSRF. La solution tient en un décorateur et une ligne d'attention sur l'accès au corps de la requête.

Désactiver CSRF sur les vues webhook

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', '')

    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)

Le décorateur retire le check CSRF uniquement pour cette vue. request.body renvoie les octets bruts tels qu'arrivés.

Avec Django REST Framework

DRF enveloppe la requête et lance des parsers selon le content-type. Au moment où votre vue lit request.data, le parser JSON a déjà consommé le corps. Deux options :

Option A : vue Django classique

Les webhooks n'ont pas besoin des parsers DRF, de la browsable API, ni de l'auth. Une vue @csrf_exempt à côté de vos endpoints DRF est plus propre.

Option B : DRF avec accès brut

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
    signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')

L'indirection request._request est moche mais c'est l'échappatoire officielle pour contourner le parsing DRF.

Setup tunnel

  1. Lancez le serveur de dev : python manage.py runserver 8000.
  2. Ajoutez l'hostname tunnel à ALLOWED_HOSTS dans settings.py. En dev, ['*'] suffit.
  3. Exécutez npx portpreview 8000.
  4. Collez l'URL tunnel + chemin webhook dans le dashboard du provider.
  5. Déclenchez un événement test.

Oublier ALLOWED_HOSTS produit un 400 Django générique sans cause évidente.

Pièges

Lecture multiple du corps

Le corps est un stream lisible une seule fois. Un middleware ou logger qui a touché request.body avant la vue laisse un bytes vide. print(len(request.body)) comme test.

Fuseaux horaires et tolérance timestamp

Comparer un timestamp signé à datetime.now() : utilisez timezone.now() ou comparez en UTC. Les datetimes naïfs cassent au changement d'heure.

Vues async

Django 4+ supporte async def. Les SDKs de vérification (Stripe Python) restent synchrones. Utilisez asgiref.sync_to_async ou gardez la vue synchrone.

Production

La même vue tourne en prod. Gunicorn, uWSGI, Daphne ne touchent pas request.body. Votre reverse proxy pourrait (nginx avec proxy_buffering) mais c'est du buffering, pas de la modification.

Voir guide vérification de signature et test webhook Stripe en local. Rejoignez la waitlist PortPreview.

Questions fréquentes

Pourquoi Django renvoie 403 sur tous les POSTs webhook ?
Le middleware CSRF rejette les POSTs sans token CSRF valide. Les providers n'en envoient pas. Ajoutez @csrf_exempt à la vue webhook (uniquement celle-là) pour contourner le check, puis vérifiez la signature à la place.
Vue webhook en Django classique ou DRF ?
Django classique est plus simple. Les parsers DRF consomment le corps avant la vue, ce qui casse la vérification de signature. Si vous gardez DRF, lisez request._request.body pour les octets bruts.
Quel ALLOWED_HOSTS pour un tunnel en Django ?
Ajoutez le domaine tunnel à ALLOWED_HOSTS dans settings.py. En dev, ['*'] fonctionne ; pour plus strict listez l'hostname spécifique. Sans ça, Django renvoie 400 sans log clair.