Si tu app envia correos desde un worker y luego recibe webhooks de entrega, el punto fragil casi nunca es el envio. El problema real aparece cuando llegan eventos fuera de orden: processed, delivered, opened, a veces repetidos y a veces varios segundos despues. En FastAPI eso suele terminar en estados raros, soporte mirando logs a mano y alguien diciendo "seguro fue un retry". Me ha pasado mas de una vez, y casi siempre el bug no estaba en el provider sino en nuestro contrato interno.
La idea que mejor funciona es bastante simple: cada correo saliente necesita un delivery_id estable desde el momento en que se crea el job, y todos los webhooks deben escribirse contra ese mismo identificador. Suena obvio, pero cuando no existe, el backend termina correlacionando por email, por asunto o por timestamps aproximados. Eso es fragil, medio incomodo y aveces imposible de explicar.
Por que los webhooks de email se vuelven confusos
Un flujo comun se ve asi:
- FastAPI crea un registro de envio
- un worker manda el correo
- el provider devuelve un
message_id - mas tarde llegan webhooks de estado
El hueco aparece entre el paso 2 y 4. Si el worker reintenta, o si el provider reenvia eventos, puedes terminar actualizando el registro equivocado. Tambien pasa que staging usa cuentas efimeras para pruebas y soporte busca referencias en una bandeja externa con nombres como dummy e mail. Ese detalle parece menor, pero mezcla lenguaje humano, aliases temporales y eventos tecnicos en una sola bolsa.
Cuando el equipo ya procesa alertas o correos de incidentes, esta clase de orden tambien ayuda en otros contextos. El mismo principio que sirve para validar correos operativos despues de un cambio sirve aqui: si no puedes unir un evento con una accion concreta, depurar se vuelve lentisimo.
El contrato minimo que evita carreras
Yo intentaria mantener solo estas piezas:
-
delivery_idgenerado por tu sistema -
provider_message_idguardado cuando el envio sale event_typeevent_at-
payload_hasho alguna huella para deduplicar
Con eso ya puedes hacer dos cosas utiles:
- aplicar idempotencia al webhook
- actualizar el estado visible del envio sin adivinar
Si durante QA usas un servicio externo para aislar cuentas, un correo temporal gratis puede ser suficiente para separar escenarios. Pero ese link no arregla la trazabilidad por si solo. La parte importante es que el webhook llegue con metadata que apunte a delivery_id, no que el equipo tenga otra inbox donde mirar.
Tambien recomiendo exponer un endpoint interno como GET /email-deliveries/{delivery_id}. No hace falta que sea publico ni bonito. Solo debe responder rapido a preguntas concretas:
- ¿se envio?
- ¿que webhook fue el ultimo aceptado?
- ¿hubo un duplicado descartado?
- ¿que provider_message_id quedo asociado?
Ese tipo de respuesta corta le ahorra mucho tiempo a backend, QA y soporte. Es una de esas mejoras pequenas que nadie celebra en la demo, pero todos notan cuando algo falla.
Un ejemplo pequeno con FastAPI
from uuid import uuid4
from fastapi import APIRouter, Header, HTTPException
router = APIRouter()
@router.post("/email-deliveries")
async def create_delivery(to: str, template: str):
delivery_id = f"dlv_{uuid4().hex}"
await save_delivery(
delivery_id=delivery_id,
to=to,
template=template,
state="queued",
)
await enqueue_email_job(delivery_id)
return {"delivery_id": delivery_id, "state": "queued"}
@router.post("/webhooks/email")
async def email_webhook(payload: dict, x_signature: str = Header()):
if not verify_signature(payload, x_signature):
raise HTTPException(status_code=401, detail="invalid signature")
delivery_id = payload["metadata"]["delivery_id"]
event_id = payload["event_id"]
if await webhook_event_exists(event_id):
return {"ok": True, "deduplicated": True}
await store_webhook_event(event_id, delivery_id, payload)
await apply_delivery_event(delivery_id, payload["event_type"], payload)
return {"ok": True}
La parte que no conviene saltarse es store_webhook_event antes de mutar estado. Si haces el update primero y guardas la evidencia despues, cuando llegue un retry podrias no saber si es duplicado o una segunda entrega valida. Es un bug re comun, y luego cuesta bastante reproducirlo.
Como depurarlo sin revisar diez sistemas
Para mi, la mejor rutina operativa es esta:
- buscar
delivery_id - ver la linea de tiempo de eventos aceptados
- comparar
provider_message_id - revisar solo el payload del ultimo cambio de estado
Nada de abrir cinco dashboards a la vez. Nada de buscar por asunto del correo. Nada de "creo que este tempail mail era del test correcto". Si el contrato esta bien hecho, una sola consulta te dice si el worker nunca envio, si el provider atraso el evento o si tu API aplico el webhook dos veces.
Tambien ayuda guardar una transicion monotona de estados. Por ejemplo, permitir queued -> sent -> delivered, pero no dejar que un webhook viejo mueva delivered otra vez a processed. Este detalle importa porque algunos proveedores reintentan callbacks durante horas. Según Postmark, los sistemas de webhooks deben tratar duplicados como algo normal, no excepcional.
Si ya trabajaste con correos de rollback con contexto real, la idea es parecida: el correo en si importa, pero el valor operativo aparece cuando puedes explicar rapido que paso, cuando paso y por que ese mensaje pertenece a ese intento exacto. No es glamour, pero si te evita varias horas tontas por semana.
Q&A
¿Hace falta guardar todos los payloads?
No siempre. Yo guardaria el payload bruto por un tiempo corto y luego una version resumida. Para depuracion temprana sirve mucho, pero no hace falta retenerlo para siempre.
¿Puedo correlacionar solo con provider_message_id?
Puedes, pero quedas atado a cuando ese valor aparece. Con delivery_id propio, tus APIs ya tienen una referencia comun antes del envio.
¿Esto sirve solo para correos transaccionales?
No. Tambien sirve para invitaciones, recibos, magic links o avisos internos. Cualquier flujo async con webhooks gana claridad cuando deja de adivinar correlaciones.
Top comments (0)