Stack: DRF + Celery (adelanto) · Proyecto: TicketFlow Estado: Publicada — cierre del módulo de APIs Prerrequisito: Lección 16 — gRPC/GraphQL/WS/SSE
Objetivos
- Recibir el webhook de la pasarela con seguridad: verificación de firma, replay y orden.
- Responder 2xx rápido y procesar después (el patrón que evita reintentos tormentosos).
- Diseñar tu webhook saliente para otros (idempotencia, reintentos, firmas) — la simetría del oficio.
1. El webhook entrante: confianza cero
La pasarela (Stripe/PayPal/simulada) llama a tu URL pública cuando el pago cambia de estado. Tu handler no puede creer el cuerpo: cualquiera puede POSTear {"status": "SUCCEEDED"}. Defensas, en orden:
- Firma HMAC: el proveedor firma el payload con un secreto compartido; tú recalculas con el raw body y comparas (constante-time). Sin coincidencia → 401/400 y log.
- Anti-replay: el payload lleva
timestamp; si tiene más de 5 minutos, se rechaza (un cuerpo robado no vale para siempre). Stripe firmatimestamp.payloady el timestamp es parte del HMAC. - Idempotencia por event_id: el proveedor reintenta (at-least-once, la 14): buscar antes de crear; duplicado → 200 silencioso.
- Orden no garantizado:
payment.succeededpuede llegar antes quepayment.pending(reintentos). Tu procesamiento debe ser tolerante: el estado del pago lo gobierna la máquina de estados (00b), no el orden de llegada.
@api_view(["POST"])
@authentication_classes([]) # el webhook no usa tu auth: usa su firma
@permission_classes([AllowAny])
def gateway_webhook(request):
signature = request.headers.get("X-Signature", "")
if not verify_hmac(request.body, signature, settings.GATEWAY_SECRET):
return Response(status=401)
payload = json.loads(request.body)
if abs(time.time() - payload["timestamp"]) > 300:
return Response(status=400)
process_gateway_event.delay(payload) # enqueue: la 29
return Response(status=200) # ACK inmediato2. Responder rápido, procesar después
¿Por qué delay() y no procesar ahí? Porque el proveedor reintenta con timeout (típicamente 5-30s) y con backoff exponencial si fallas. Si tu handler hace: verificar pago en BD, enviar email, generar entradas PDF... y tarda 40s, el proveedor te marca fallo y reintenta mientras tu primer procesamiento sigue vivo → doble procesamiento + tormenta. El patrón: ACK 2xx al recibir (validado + encolado), procesamiento en worker (Celery, Lección 29), idempotencia del worker por event_id.
Si aún no tienes Celery: procesar síncrono pero acotado (solo actualizar estado + crear entrada en BD, <1s) es aceptable para empezar; la cola es la forma adulta.
3. Llamar APIs de terceros: tú también eres cliente
Cuando TÚ llamas a la pasarela para crear el pago (antes del webhook):
- Timeout explícito (connect+read, p. ej. 3s/10s): el default de la librería es 60s+ = tu worker colgado.
- Reintentos con backoff solo para idempotentes: GET sí; POST de pago con la Idempotency-Key del proveedor (siempre que lo ofrezca) — así el reintento no cobra dos veces (14).
- Circuit breaker (adelanto 54): si la pasarela lleva 30 fallos, corta y falla rápido en vez de acumular timeouts.
- Nunca secretos en logs (la 05) y credenciales por entorno (27).
4. Tu webhook saliente (cuando otros te consumen)
TicketFlow avisará a los organizadores ("vendiste 50 entradas"): misma disciplina al revés — firma HMAC del cuerpo + timestamp, reintentos con backoff (5 intentos, 1m/5m/30m/2h/6h), idempotencia con event_id, y un panel de entregas fallidas (endpoint de salud de webhooks salientes: última entrega, código, reintento programado). Un webhook saliente sin reintentos es una promesa que no cumples.
5. Webhooks entrantes vs salientes: la asimetría que manda
Dos casas viven en TicketFlow y no se gobiernan igual:
| Entrantes (la pasarela nos llama) | Salientes (nosotros llamamos) | |
|---|---|---|
| Quién fija las reglas | El tercero (su formato, su firma) | Nosotros (nuestro formato, nuestra firma) |
| Verificación | Verificar SU HMAC (raw body + ventana) | Firmar NUESTRO HMAC y publicar la clave |
| Fiabilidad exigible | Sus reintentos (estándar: 24-72 h) | Nuestros reintentos (backoff 1m→6h, DLQ) |
| El fracaso típico | No verificar (el 402 que forjó pagos) | No deduplicar en el receptor ajeno (el doble evento) |
| Reconciliación | El GET del intent ante el UNKNOWN (32) | El log de intentos + el reporte de entrega |
El detalle que conecta con el 25/30: los salientes NO se envían directamente del servicio — se escriben al outbox y un poller los publica (el mismo mecanismo de los eventos de dominio): la garantía transaccional ("solo notificamos lo que de verdad pasó") es la misma para ambos usos. Y el receptor ajeno de nuestros webhooks merece la misma generosidad que exigimos: reintentos con backoff, firma verificable y documentación de la ventana — el webhook mal diseñado es el que obliga al ajeno a poller.
6. Seguridad del endpoint entrante: checklist de la fortaleza
El endpoint que recibe webhooks es público por definición (la pasarela debe alcanzarlo). El blindaje completo: (1) verificación HMAC SIEMPRE (raw body, clave por tercero, rotación dual de la 27); (2) ventana anti-replay (5 min) + guard de timestamp fuera de ventana; (3) dedup por event_id del tercero (la 30: at-least-once ES lo normal); (4) respuesta 200 inmediata + trabajo en cola (el ACK no es el trabajo: es el recibido); (5) rate limit por origen (23: la pasarela no necesita 1000 rps; un atacante sí); (6) el secret jamás en el log (23: el header Authorization con el HMAC se filtra en logs de proxy: filtrar la 45); (7) monitoreo de firma inválida (46: picos de firma inválida = alguien intentando forjar). Con las siete, el webhook entrante de TicketFlow es tan auditable como el resto del sistema.
- ¿Por qué se verifica la firma sobre el raw body y no sobre
request.dataya parseado? - ¿Qué doble función cumple el timestamp firmado (anti-replay) y por qué 5 minutos?
- ¿Por qué ACK antes de procesar y qué pasa exactamente si procesas lento sin cola?
- El webhook llegó fuera de orden (succeeded antes que pending): ¿qué componente de tu modelo absorbe el desorden?
- Tu webhook saliente falló 5 veces para un organizador. ¿Qué sigue? ¿Y si la URL del organizador ahora devuelve 410?
Continúa con los ejercicios. Las solutions.md solo tras intentarlo.