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
- Lancez le serveur de dev :
python manage.py runserver 8000. - Ajoutez l'hostname tunnel à
ALLOWED_HOSTSdanssettings.py. En dev,['*']suffit. - Exécutez
npx portpreview 8000. - Collez l'URL tunnel + chemin webhook dans le dashboard du provider.
- 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.