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 notificapush, 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_digesty 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
inactivoy notifica por email al cliente.
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