Todos los artículos
DjangoPythonwebhook debugginglocal testing

Webhooks en Django: pasar CSRF y llegar al handler

De fábrica, Django rechaza cada POST de webhook con un 403. El culpable es el middleware CSRF haciendo su trabajo — protegiendo contra cross-site request forgery — salvo que el proveedor no es un navegador y no lleva cookie de sesión ni token CSRF. La solución son un decorador y una línea de conciencia sobre el acceso al cuerpo de la petición.

Saltar CSRF en las vistas de webhook

La forma de 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            # bytes crudos, intactos
    signature = request.headers.get('Stripe-Signature', '')

    # Verifica la firma (usa el SDK de stripe en código real)
    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)

El decorador @csrf_exempt elimina la comprobación del middleware solo para esta vista — tus otros formularios siguen protegidos. request.body devuelve los bytes crudos tal como llegaron, que es justo lo que necesita la verificación de firma.

Si usas Django REST Framework

DRF envuelve las peticiones en su propio objeto Request y ejecuta parsers según el content-type. Para cuando tu vista lee request.data, el parser JSON ya consumió el cuerpo. Dos salidas:

Opción A: déjalo como vista de Django plana

Los handlers de webhook no necesitan los parsers de DRF, la API navegable ni la maquinaria de auth. Una vista @csrf_exempt plana junto a tus endpoints DRF es el patrón más limpio.

Opción B: usa DRF con acceso crudo

Si de verdad quieres DRF, lee request.body (la petición Django subyacente) en vez de request.data:

from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import AllowAny

@api_view(['POST'])
@authentication_classes([])         # sin auth para webhooks
@permission_classes([AllowAny])     # el proveedor ya autentica vía firma
def stripe_webhook(request):
    payload = request._request.body  # petición Django subyacente, bytes crudos
    signature = request.META.get('HTTP_STRIPE_SIGNATURE', '')
    # ...verifica y maneja

La indirección request._request es incómoda, pero es la salida oficial cuando necesitas saltarte el parsing de DRF.

Configura el túnel

  1. Ejecuta tu servidor de desarrollo de Django: python manage.py runserver 8000.
  2. Añade el hostname del túnel a ALLOWED_HOSTS en settings.py. Lo más fácil es ['*'] durante el dev local, o un dominio de túnel específico si lo prefieres.
  3. npx portpreview 8000 en otra terminal.
  4. Pega la URL del túnel más tu ruta de webhook en el dashboard del proveedor.
  5. Dispara un evento de prueba y míralo llegar a tu vista.

Olvidar ALLOWED_HOSTS produce un 400 genérico de Django sin causa obvia. Si tu primera entrega de webhook devuelve 400 y no ves los logs de tu vista, ese es el motivo.

Trampas comunes

Leer el cuerpo dos veces

Como todo framework, Django te da el cuerpo como un stream que solo se puede leer una vez. Si un middleware o un wrapper de logging ya tocó request.body, tu vista ve un objeto bytes vacío. Imprime len(request.body) como comprobación.

Zonas horarias y tolerancia de timestamp

Si comparas el timestamp firmado con datetime.now(), asegúrate de usar timezone.now() o comparar en UTC. Las comparaciones de datetime naive fallan de forma intermitente cuando hay cambios de horario de verano.

Vistas async

Django 4+ soporta vistas async def. Los handlers de webhook pueden ser async, pero ten en cuenta que los SDK de verificación de firma (Stripe Python, etc.) son síncronos. Córrelos con asgiref.sync_to_async o mantén toda la vista síncrona.

Notas de despliegue en producción

Una vez pasado el testing local, la misma vista funciona en producción. Gunicorn, uWSGI, Daphne — ninguno toca request.body antes de que tu vista lo vea. Tu reverse proxy podría (nginx con proxy_buffering activado, por ejemplo), pero eso es sobre búfer, no modificación del cuerpo.

Para la teoría de firmas subyacente, mira la guía de verificación de firmas. Para patrones específicos de Stripe, la guía de pruebas de Stripe en local cubre el lado del dashboard. Únete a la lista de espera de PortPreview para túneles que se llevan bien con servidores de desarrollo de Python.

Preguntas frecuentes

¿Por qué Django devuelve 403 en cada POST de webhook?
El middleware CSRF de Django rechaza los POSTs sin un token CSRF válido. Los proveedores de webhook no envían uno. Añade @csrf_exempt a tu vista de webhook (solo esa vista) para saltar la comprobación y verifica la firma en su lugar.
¿Las vistas de webhook deben ser Django plano o DRF?
Django plano es más simple para webhooks. Los parsers de DRF consumen el cuerpo antes de que tu vista lo vea, lo que rompe la verificación de firma. Si mantienes DRF, lee request._request.body para obtener los bytes crudos.
¿Qué ajuste de ALLOWED_HOSTS necesito para túneles en Django?
Añade tu dominio de túnel a ALLOWED_HOSTS en settings.py. Durante el desarrollo local, ['*'] funciona; para configuraciones más estrictas lista el hostname específico del túnel. Sin esto, Django devuelve un 400 sin un mensaje de log claro.