DEV Community

Olivia Cheng
Olivia Cheng

Posted on

FastAPI: callbacks de email sin duplicados

Cuando un servicio de email avisa que un mensaje fue entregado, abierto o rechazado, el callback parece una operación sencilla: recibir JSON, actualizar una fila y devolver 200 OK. En realidad, los proveedores reintentan, las redes cortan conexiones y dos eventos pueden llegar casi al mismo tiempo.

En una API pequeña, esto puede terminar en estados imposibles: un mensaje marcado como entregado despues de haber sido rechazado, dos tareas de automatización para el mismo evento o un contador de notificaciones que crece cada vez que el proveedor repite la petición.

La solución que suelo aplicar en backend es tratar cada callback como un evento con identidad propia. FastAPI ayuda con la validación y el tipado, pero la parte importante es el contrato de idempotencia: recibir el mismo evento dos veces debe tener el mismo efecto que recibirlo una sola vez.

El problema de los callbacks duplicados

Supongamos que una aplicación envía un enlace de verificación. El proveedor acepta el mensaje y luego manda un callback delivered. Si nuestra API tarda demasiado en responder, el proveedor puede reenviar el mismo evento. También puede ocurrir que el worker procese el primer evento mientras el segundo ya está esperando en la cola.

Guardar solo status = "delivered" no alcanza. Hace falta conservar un identificador del evento, el identificador del mensaje y un estado que permita decidir si una transición es válida. Tambien conviene guardar una marca de tiempo y el cuerpo mínimo necesario para investigar, sin copiar datos privados que no aportan.

Un contrato práctico puede tener esta forma:

{
  "event_id": "evt_01J9ABC123",
  "message_id": "msg_7f31",
  "type": "email.delivered",
  "occurred_at": "2026-10-29T14:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

event_id identifica la notificación del proveedor. message_id identifica el email dentro de nuestro sistema. Si el proveedor no entrega un event_id estable, podemos calcular una huella de los campos confiables, aunque es mejor documentar esa decisión y no depender de todo el JSON.

Un contrato pequeño para el webhook

Antes de escribir el endpoint, definiria tres reglas:

  1. Un event_id nuevo se registra una sola vez.
  2. Un evento ya registrado devuelve éxito sin ejecutar otra vez el efecto.
  3. Una transición antigua no puede sobrescribir un estado más avanzado.

La segunda regla es importante. Devolver un error para cada repetición hace que el proveedor siga reintentando. Si el evento ya fue procesado, 200 OK significa “lo conozco y no necesito repetirlo”.

Para las transiciones, una tabla sencilla puede guardar received, processed, delivered y failed. No todos los proveedores usan los mismos nombres, por eso es mejor mapear sus estados a un vocabulario interno. Un callback opened no deberia cambiar un mensaje que ya está en failed si ese cambio no está permitido por el producto.

Implementación idempotente en FastAPI

El endpoint puede validar la forma del evento y dejar el trabajo pesado a una tarea. La restricción única en la base de datos es la última defensa contra carreras entre peticiones:

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class EmailEvent(BaseModel):
    event_id: str
    message_id: str
    type: str
    occurred_at: str

@app.post("/webhooks/email")
def receive_email_event(event: EmailEvent):
    inserted = event_store.insert_once(
        event_id=event.event_id,
        message_id=event.message_id,
        event_type=event.type,
        occurred_at=event.occurred_at,
    )

    if not inserted:
        return {"accepted": True, "duplicate": True}

    queue.publish("email-event", event.model_dump())
    return {"accepted": True, "duplicate": False}
Enter fullscreen mode Exit fullscreen mode

El ejemplo omite la implementación de event_store, pero su operación insert_once deberia apoyarse en un índice único para event_id. No confiaría solamente en un SELECT seguido de un INSERT: dos requests pueden pasar el SELECT antes de que cualquiera escriba.

El worker puede aplicar la transición dentro de una transacción. Si falla después de registrar el evento, el sistema necesita una estrategia de reintento basada en el estado, no un evento nuevo con otro identificador. Un correlation_id facilita seguir el recorrido desde el callback hasta la tarea que actualiza el mensaje.

Pruebas y observabilidad del flujo

Primero probaría la lógica con una secuencia pequeña:

  1. Enviar un callback nuevo y comprobar que se crea un registro.
  2. Enviar el mismo event_id dos veces y comprobar que solo se encola una tarea.
  3. Enviar un evento antiguo despues de uno más avanzado y comprobar que el estado no retrocede.
  4. Simular un fallo del worker y verificar que el reintento es seguro.

En una prueba de integración, una bandeja aislada permite revisar asunto, enlace y contenido sin usar una cuenta personal. Para ese tipo de verificación, un correo temporal gratis puede servir como entorno descartable; no lo usaría para datos reales ni para ocultar la identidad de usuarios.

Los logs deberían incluir event_id, message_id, tipo y resultado de la transición. No incluiría el cuerpo completo del email ni tokens. Un panel sencillo con eventos recibidos, duplicados, rechazados y tiempo de procesamiento responde preguntas mas utiles que un contador general de requests.

Si la interfaz muestra el resultado del envío, el backend y el frontend deben compartir estados claros. Vale la pena diseñar estados async accesibles sin saltos para que “procesando” no se confunda con “entregado”. Y cuando el usuario vuelve a abrir una pantalla, verificar emails sin perder el contexto ayuda a no lanzar una segunda acción por accidente.

En búsquedas y pruebas internas pueden aparecer expresiones como tempmail so o el typo tempail mail. Son términos de contenido, no contratos técnicos: no deberían terminar en nombres de eventos, tablas o respuestas de la API.

Checklist para llevarlo a producción

  • ¿Existe una restricción única para event_id?
  • ¿Una repetición devuelve éxito sin repetir efectos?
  • ¿Las transiciones inválidas quedan registradas?
  • ¿El worker puede reintentarse sin duplicar notificaciones?
  • ¿Los logs evitan tokens y contenido privado?
  • ¿Hay una forma de relacionar callback, mensaje y tarea?
  • ¿El endpoint valida el tamaño y la forma del payload?

Tambien revisaría qué ocurre cuando la base de datos está temporalmente fuera de servicio. En ese caso, devolver un error para que el proveedor reintente suele ser más seguro que confirmar un evento que nunca se guardó. El tiempo de respuesta debe ser corto; el procesamiento detallado puede ocurrir después.

Preguntas rápidas

¿Idempotencia significa ignorar todos los eventos repetidos?

No. Significa que una repetición no vuelve a ejecutar el mismo efecto. Aun puede ser útil actualizar un contador de intentos o registrar una métrica de duplicados, siempre que eso sea una operación separada y controlada.

¿Debo procesar el callback antes de responder?

Normalmente no. Valida, persiste o encola lo mínimo y responde. El worker puede hacer el cambio de estado con más tiempo y con reintentos. Una cola no arregla un contrato ambiguo, pero hace el flujo mas resistente cuando el proveedor entrega ráfagas.

¿Qué debo probar primero?

El caso feliz y la repetición inmediata. Quizas el bug más caro no sea un callback que falla, sino uno que parece funcionar dos veces y manda dos mensajes o activa dos procesos.

Un webhook de email confiable no necesita mucha magia. Necesita identidad, restricciones en la base de datos, transiciones explícitas y pruebas que imiten los reintentos reales. Con ese contrato, FastAPI deja de ser solo la puerta de entrada y se convierte en una base clara para automatizar sin sorpresas.

Top comments (0)