WebHooks en DRF: emision firmada y consumo seguro

Avanzado
Django
Django
Actualizado: 19/04/2026

Que es un webhook

Una API REST normal es pull: el cliente pregunta GET /api/pedidos/?estado=pagado cada cierto tiempo. Si el evento es raro, el polling es ineficiente y la latencia es alta.

Un webhook es push: cuando ocurre un evento (pedido pagado, build completado, ticket cerrado), tu servidor hace un POST HTTP al endpoint que el cliente registro previamente.

Es como suscribirse a un canal: el receptor registra una URL https://app.cliente.com/hooks/pagos/, y tu API la invoca cada vez que pasa algo relevante.

Ejemplos en produccion: Stripe notifica payment.succeeded, GitHub notifica push, Slack notifica nuevos mensajes, Twilio notifica SMS entrantes. Todos siguen el mismo patron.

Esquema canonico de un evento

{
  "id":          "evt_01J3K8...",          // unico, idempotencia
  "type":        "pedido.pagado",          // taxonomia jerarquica
  "occurred_at": "2026-04-18T10:34:21Z",   // ISO 8601 UTC
  "api_version": "2026-04-01",             // versionado del payload
  "data": {
    "pedido_id":  "ord_42",
    "importe":    "59.90",
    "moneda":     "EUR"
  }
}

Y va acompanado de cabeceras de seguridad:

POST /hooks/pagos HTTP/1.1
Content-Type: application/json
X-Webhook-Id: evt_01J3K8...
X-Webhook-Timestamp: 1713432861
X-Webhook-Signature: t=1713432861,v1=ab83f9...

Modelado en Django

# webhooks/models.py
from django.db import models

class Endpoint(models.Model):
    cliente = models.ForeignKey("auth.User", on_delete=models.CASCADE)
    url     = models.URLField()
    secret  = models.CharField(max_length=64)  # generado al crear
    eventos = models.JSONField(default=list)   # ["pedido.pagado", "pedido.enviado"]
    activo  = models.BooleanField(default=True)

class EventoEnviado(models.Model):
    endpoint     = models.ForeignKey(Endpoint, on_delete=models.CASCADE)
    evento_id    = models.CharField(max_length=64, unique=True)
    tipo         = models.CharField(max_length=64)
    payload      = models.JSONField()
    intentos     = models.PositiveIntegerField(default=0)
    estado       = models.CharField(max_length=16, default="pendiente")
    proxima_at   = models.DateTimeField(null=True, blank=True)

Firma HMAC del payload

Nunca envies eventos sin firma. Cualquiera podria hacer POST a la URL del cliente. Usamos HMAC SHA256 con un secret compartido entre tu API y el cliente.

# webhooks/sign.py
import hmac, hashlib, time, json

def firmar(secret: str, payload: dict) -> tuple[str, int, str]:
    body_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
    timestamp  = int(time.time())
    msg        = f"{timestamp}.".encode() + body_bytes
    sig        = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return body_bytes.decode(), timestamp, sig

Patron Stripe: el mensaje firmado incluye timestamp + body unidos por un punto. Asi un atacante no puede tomar la firma de hoy y reusarla manana (replay attack).

Emisor con Celery + reintentos

Bloquear la peticion HTTP del cliente (que provoco el evento) esperando a que el webhook llegue al consumidor seria suicida. Encolamos el envio.

# webhooks/tasks.py
import requests
from celery import shared_task
from datetime import timedelta
from django.utils import timezone
from webhooks.models import EventoEnviado
from webhooks.sign import firmar

DEMORAS = [10, 60, 300, 1800, 7200, 21600]  # 10s, 1m, 5m, 30m, 2h, 6h

@shared_task(bind=True, max_retries=len(DEMORAS))
def enviar_webhook(self, evento_id: str):
    ev = EventoEnviado.objects.select_related("endpoint").get(evento_id=evento_id)
    if not ev.endpoint.activo:
        return

    body, ts, sig = firmar(ev.endpoint.secret, ev.payload)
    headers = {
        "Content-Type":         "application/json",
        "X-Webhook-Id":         ev.evento_id,
        "X-Webhook-Timestamp":  str(ts),
        "X-Webhook-Signature":  f"t={ts},v1={sig}",
    }

    try:
        r = requests.post(ev.endpoint.url, data=body, headers=headers, timeout=10)
        if 200 <= r.status_code < 300:
            ev.estado = "ok"
            ev.save(update_fields=["estado", "intentos"])
            return
        raise requests.HTTPError(f"status {r.status_code}")
    except Exception as exc:
        ev.intentos += 1
        ev.save(update_fields=["intentos"])
        if ev.intentos > len(DEMORAS):
            ev.estado = "ko"
            ev.save(update_fields=["estado"])
            return
        raise self.retry(exc=exc, countdown=DEMORAS[ev.intentos - 1])

Backoff exponencial es estandar de la industria: 10 s, 1 m, 5 m, 30 m, 2 h, 6 h. Tras la ultima reintento marcamos ko.

Disparar el webhook desde una vista DRF

# pedidos/views.py
from rest_framework.viewsets import ModelViewSet
from webhooks.models import EventoEnviado, Endpoint
from webhooks.tasks import enviar_webhook
from uuid import uuid4
from django.utils import timezone

class PedidoViewSet(ModelViewSet):
    queryset = Pedido.objects.all()
    serializer_class = PedidoSerializer

    def perform_update(self, serializer):
        pedido_anterior = self.get_object()
        pedido = serializer.save()
        if pedido.estado == "pagado" and pedido_anterior.estado != "pagado":
            self._emitir("pedido.pagado", pedido)

    def _emitir(self, tipo, pedido):
        for ep in Endpoint.objects.filter(activo=True, eventos__contains=[tipo]):
            ev = EventoEnviado.objects.create(
                endpoint   = ep,
                evento_id  = f"evt_{uuid4().hex}",
                tipo       = tipo,
                payload = {
                    "id":         f"evt_{uuid4().hex}",
                    "type":       tipo,
                    "occurred_at": timezone.now().isoformat(),
                    "api_version": "2026-04-01",
                    "data": {
                        "pedido_id": str(pedido.id),
                        "importe":   str(pedido.total),
                    },
                },
            )
            enviar_webhook.delay(ev.evento_id)

Consumir webhooks: validar firma sin timing attack

Si tu DRF tambien recibe webhooks (por ejemplo de Stripe), validalos de forma segura.

# webhooks/views.py
import hmac, hashlib, time, json
from django.conf import settings
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status

class StripeWebhookView(APIView):
    authentication_classes = []
    permission_classes     = []

    def post(self, request):
        sig_header = request.headers.get("Stripe-Signature", "")
        body       = request.body
        secret     = settings.STRIPE_WEBHOOK_SECRET

        try:
            ts, v1 = self._parse(sig_header)
        except ValueError:
            return Response({"error": "bad signature header"}, status=400)

        # 1. Ventana de tiempo: rechazar si > 5 min
        if abs(int(time.time()) - ts) > 300:
            return Response({"error": "stale"}, status=400)

        # 2. Recalcular firma y comparar en tiempo constante
        msg      = f"{ts}.".encode() + body
        expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(expected, v1):
            return Response({"error": "invalid signature"}, status=401)

        # 3. Idempotencia: registrar evento_id para no procesar 2 veces
        evento = json.loads(body)
        if EventoRecibido.objects.filter(evento_id=evento["id"]).exists():
            return Response({"ok": True, "duplicado": True})
        EventoRecibido.objects.create(evento_id=evento["id"], payload=evento)

        # 4. Procesar (idealmente encolando para no bloquear)
        procesar_evento_stripe.delay(evento["id"])
        return Response({"ok": True})

    def _parse(self, header):
        partes = dict(p.split("=", 1) for p in header.split(","))
        return int(partes["t"]), partes["v1"]

Tres puntos criticos:

  • hmac.compare_digest y NO ==. La comparacion estandar para por el primer byte que difiere, lo que filtra informacion temporal y permite timing attacks.
  • Ventana de timestamp (5 minutos) para evitar replay.
  • Idempotencia con evento_id: el cliente puede recibir el mismo evento dos veces (reintento). Procesarlo dos veces seria desastroso (cobrar dos veces).

Diagrama del ciclo completo

sequenceDiagram
    participant U as Usuario
    participant API as DRF (emisor)
    participant Q as Celery
    participant CL as Cliente B2B (consumidor)
    U->>API: POST /api/pedidos/42/pagar/
    API->>API: pedido.estado = pagado
    API->>Q: enviar_webhook(evento_id)
    API-->>U: 200 OK
    Q->>CL: POST /hooks/pagos (sig)
    alt 200 OK
        CL-->>Q: 200
        Q->>API: estado=ok
    else error
        CL-->>Q: 5xx
        Q->>Q: retry backoff 10s, 1m, 5m...
    end

Buenas practicas finales

  • Permite re-entregar manualmente un evento desde tu panel admin para que el cliente recupere eventos perdidos.
  • Documenta los eventos en tu OpenAPI / pagina de developers (lista de event.type, payload de cada uno).
  • Endpoint de prueba: ofrece a los clientes un boton "Enviar evento de prueba" antes de produccion.
  • Health check: si tras N fallos consecutivos un endpoint no responde, marcalo inactivo y notifica por email al cliente.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Django es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Django

Explora más contenido relacionado con Django y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Entender el patron webhook y casos de uso (pagos, integraciones, notificaciones a terceros). Disenar el schema de eventos con event_id, event_type, occurred_at, data. Firmar payloads con HMAC SHA256 y enviarlos asincronamente con Celery. Implementar reintentos con backoff exponencial. Consumir webhooks externos validando firmas con hmac.compare_digest. Defender contra replay attacks usando timestamp.

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje