Sistema email multi-tenant con FastAPI y Resend
Cuando te piden armar un sistema de emails para que varios clientes envíen desde sus propios dominios con un solo backend, la lógica te dice: “uso las Audiences y Broadcasts de Resend y listo”. La documentación lo pinta limpito. No lo es. Después de laburar la implementación real, el desarrollador Srinivasa Rao documentó lo que nadie te cuenta: los Segmentos de Resend no tienen API de filtrado, las Audiencias y Contactos viven en universos separados de los Dominios sin relación nativa, y los Broadcasts no te dan el control por cliente que necesitás en una arquitectura multi-tenant. La solución que funciona es más simple de lo que parece — y más rebuscada de implementar de lo que te gustaría.
En criollo: un sistema email multi-tenant con FastAPI es un backend que permite a múltiples clientes (tenants) mandar correos desde sus dominios verificados usando una sola cuenta de Resend y una sola API key, pero almacenando contactos, segmentos e historial en base de datos propia en vez de delegarle eso al proveedor. El envío lo hacés por Resend; los datos los manejás vos. Simple en concepto, lleno de trampas en la práctica.
En 30 segundos
- Las Audiences y Broadcasts de Resend no sirven para multi-tenant: los Segmentos no tienen API de filtrado y no hay relación nativa entre Contactos y Dominios.
- El patrón que funciona: Resend solo como motor de envío: verificás dominios y despachás correos por Resend, pero contactos, segmentos y campañas van a tu base de datos.
- La verificación DNS de dominios puede demorar: implementar polling cada 30-60 segundos y exponer el estado al frontend te ahorra dolores de cabeza.
- Podés correr tests sin gastar un crédito: mockeando las llamadas a la API de Resend y simulando eventos de webhook, todo sin dominio verificado.
- El aislamiento entre tenants depende de tu diseño de base de datos: si filtrás mal por
tenant_id, un cliente ve los contactos de otro y se arma un despelote bárbaro.
¿Por qué no conviene usar Audiences y Broadcasts de Resend para multi-tenant?
La tentación es obvia: Resend te da Audiences para agrupar contactos, Segmentos para filtrarlos y Broadcasts para dispararles correos masivos. Si cada cliente es una Audience distinta, debería andar, ¿no? El problema aparece apenas querés hacer algo que no sea el camino feliz que documentan.
Primero, los Segmentos no tienen API de filtrado. Así como lo leés. Para crear un segmento necesitás hacerlo a mano desde el dashboard o con condiciones predefinidas que no podés manipular dinámicamente desde tu backend. En un escenario multi-tenant real — donde cada cliente quiere mandarle a suscriptores que hicieron clic en tal campaña o que tienen tal tag — te quedás corto en cinco minutos.
Segundo, Audiences y Contacts están completamente separados de Domains. No hay relación nativa entre ellos. Resend no te dice “este contacto pertenece a este dominio verificado”, porque su modelo de datos no se pensó para multi-tenancy. Podés tener contactos huérfanos dando vueltas sin que nada los ate al dominio de origen, y si mezclás contactos de distintos clientes en la misma Audience (cosa que pasa cuando crecés), rezar para que el unsubscribe funcione correctamente por dominio. Spoiler: no reza bien.
Tercero, los Broadcasts no te dan control por cliente. Disparás un broadcast y sale para toda la Audience. ¿Querés que el Cliente A mande un broadcast solo a su segmento premium y el Cliente B a sus nuevos suscriptores, todo desde el mismo backend? No es imposible, pero la API no está diseñada para eso — terminás haciendo malabares con condiciones y metadatos que el propio sistema no valida, y cada broadcast adicional es una oportunidad para meter la pata. Según la guía oficial de Resend para multi-tenants, la recomendación de la propia empresa es que manejes vos los contactos y solo uses Resend para el envío. Si el fabricante te dice “esto no es para eso”, conviene escuchar.
Arquitectura recomendada: Resend solo como motor de envío
Después de chocar contra las limitaciones, el patrón que queda es claro y tiene sentido arquitectónico: Resend verifica dominios y despacha correos; vos almacenás contactos, segmentos, campañas e historial en tu base de datos. La responsabilidad queda partida al medio — y eso es bueno, porque cada capa hace lo que mejor sabe hacer. Ya lo cubrimos antes en nuestra comparativa de pipelines CI/CD.
Así se ve en la práctica:
├── Cliente A → dominio: acme.com ─┐
├── Cliente B → dominio: betacorp.com ├──▶ Resend (solo motor de envío)
└── Cliente C → dominio: delta.io ──┘
│
└──▶ Tu Base de Datos (SQLite en dev, PostgreSQL en producción)El flujo es así: cuando el Cliente A quiere mandar una campaña, tu backend consulta tu base de datos para obtener los contactos del segmento que eligió, arma los payloads de email, y se los manda a Resend usando la API key única de tu cuenta. Resend despacha desde el dominio verificado del Cliente A, y los eventos de entrega (sent, bounced, opened, clicked) vuelven a tu backend por webhook. Vos actualizás el estado en tu base y cada cliente ve solo lo suyo.
¿Lo más lindo de este esquema? Que cambiás de proveedor de envío sin migrar datos de negocio. Si mañana Resend te queda chico o cambia de pricing, tus contactos, segmentos y campañas están en tu base, no en la de ellos.
¿Cómo verificar dominios de múltiples clientes con Resend?
Acá es donde la implementación se pone densa. Verificar un dominio en Resend implica que el cliente agregue registros DNS (TXT, MX, DKIM, SPF) en su proveedor de dominio — algo como donweb.com si está hosteando dominios en Argentina — y que vos esperes a que la verificación se complete. La verificación DNS no es instantánea y puede tomar un tiempo considerable. En la práctica suele resolverse en minutos, pero no podés dejar al cliente mirando una pantalla en blanco mientras tanto.
Lo que funciona es implementar un polling del estado de verificación cada 30 a 60 segundos desde tu backend y exponer ese estado al frontend. Algo así:
- Endpoint POST /tenants/{id}/domains: el cliente mete su dominio, vos lo registrás en Resend y devolvés los registros DNS que tiene que configurar.
- Endpoint GET /tenants/{id}/domains/{domain_id}/status: consulta el estado de verificación contra la API de Resend y devuelve
pending,verifiedofailed. - Job en background: cada 60 segundos, tu backend se fija si algún dominio pendiente ya se verificó y actualiza el estado en tu base. Simple, predecible, y no dejás al usuario apretando F5 como un desesperado.
Un detalle que los docs no te cuentan: si el cliente configura mal los registros DNS y la verificación falla, el mensaje de error de la API de Resend es minimalista. Conviene loguear todo y darle al cliente instrucciones claras sobre qué registro falló y cómo corregirlo.
Implementación en FastAPI: endpoints y lógica de negocio
Con FastAPI, la estructura de endpoints queda bastante natural. Cada cliente tiene su tenant_id, y ese identificador viaja en cada request (típicamente desde un token JWT o API key propia). A partir de ahí, todas las queries a tu base de datos filtran por tenant_id. Veamos los endpoints centrales que necesitás:
| Endpoint | Método | Qué hace |
|---|---|---|
| /tenants/{id}/domains | POST | Registra un dominio para verificación en Resend |
| /tenants/{id}/domains/{domain_id}/status | GET | Devuelve el estado de verificación DNS |
| /tenants/{id}/contacts | POST / GET | Crear y listar contactos del tenant |
| /tenants/{id}/segments | POST / GET | Crear segmentos con filtros (tags, fecha, estado) |
| /tenants/{id}/campaigns | POST | Crear campaña y disparar envío por Resend |
| /tenants/{id}/campaigns/{campaign_id} | GET | Consultar estado de campaña y métricas |
| /webhooks/resend | POST | Recibir eventos de entrega de Resend |

La lógica de negocio vive en servicios separados de los endpoints. Por ejemplo, cuando creás una campaña, el servicio arranca obteniendo los contactos del segmento desde tu base, formatea los emails con los datos del tenant (remitente, dominio verificado, plantilla), y los manda en lotes a la API de Resend. Guardás el resend_email_id en tu tabla de eventos para después matchear con los webhooks que lleguen. Lo explicamos a fondo en el análisis entre Jenkins y GitHub Actions.
En desarrollo usás SQLite con este modelo mínimo:
# Modelos básicos con SQLAlchemy
class Tenant(Base):
id: str # UUID
name: str
api_key: str # clave interna, no la de Resend
class Domain(Base):
id: str
tenant_id: str # FK a Tenant
domain: str
resend_domain_id: str # ID que devuelve Resend
status: str # pending, verified, failed
class Contact(Base):
id: str
tenant_id: str
email: str
tags: list # JSON
subscribed: bool
class Campaign(Base):
id: str
tenant_id: str
domain_id: str
segment_id: str
subject: str
body_html: str
status: str # draft, sending, sent
created_at: datetimeManejo de eventos de entrega con webhooks de Resend
Cuando Resend termina de procesar un envío (o falla en el intento), te pega a un webhook con eventos de tipo email.sent, email.bounced, email.opened, email.clicked. Tu trabajo es recibirlos, validarlos y actualizar el estado en tu base de datos local — todo sin mezclar datos entre tenants.
El endpoint de webhook en FastAPI es simple, pero ojo con la validación. Resend manda una firma HMAC en el header resend-signature que tenés que verificar para estar seguro de que el request viene realmente de ellos. Sin eso, cualquiera podría inyectarte eventos falsos y ensuciarte las métricas.
Acá viene lo bueno: como cada resend_email_id está atado a una campaña tuya, y cada campaña tiene tenant_id en tu base, el webhook handler busca la campaña por el ID de Resend, actualiza el estado del envío individual, y el tenant ve sus métricas actualizadas en tiempo real sin que hayas expuesto datos de otros clientes. Todo el aislamiento depende de las queries que hagas, así que si en algún momento te olvidás de filtrar por tenant_id en el handler del webhook, abrís una brecha de datos entre clientes. No es teórico: pasa más seguido de lo que te gustaría en sistemas multi-tenant que crecen rápido.
Pruebas sin dominio real ni gastar créditos de API
Acá es donde el artículo de Srinivasa Rao tira un dato que vale oro: montó un suite de tests que corren sin dominio verificado, sin consumir créditos de Resend y sin configurar un solo webhook. ¿Cómo? Mockeando absolutamente todo.
El setup de testing recomendado tiene tres capas de mock:
- Mock del cliente HTTP de Resend: en vez de llamar a la API real, interceptás las llamadas con
unittest.mockopytest-mocky devolvés respuestas simuladas. Verificás que tu código arma bien el payload y maneja correctamente la respuesta. - Mock del webhook de Resend: simulás que Resend te pega con eventos de
email.sent,email.bounced, etc., y verificás que tu handler actualiza las tablas correctas con los datos correctos. - Mock de verificación DNS: en vez de esperar tiempos reales de verificación, devolvés
verified: trueen los tests cuando querés probar el flujo feliz, yverified: falsepara probar reintentos.
La gracia de este enfoque es que podés correr los tests en CI sin depender de servicios externos y sin que un cambio en la API de Resend te rompa la build de la nada. Además, no quemás créditos de API en cada push — cosa que si estás arrancando un SaaS con varios clientes, suma.
Consideraciones de escalabilidad y aislamiento entre tenants
El aislamiento entre tenants no es un detalle cosmético: es la diferencia entre un producto que escala y uno que se cae a pedazos cuando el Cliente B descubre que puede ver los contactos del Cliente A. Hay tres estrategias, y cuál elegís depende de cuánto estés dispuesto a pagar en infraestructura y complejidad. Esto se conecta con lo que analizamos en nuestra guía sobre hreflang SEO.
| Estrategia | Cómo funciona | Ventaja | Contra |
|---|---|---|---|
| Filtrado por tenant_id | Todas las queries incluyen WHERE tenant_id = ? | Simple, una sola DB | Si te olvidás un filtro, exponés datos |
| Schemas separados | Cada tenant tiene su schema en PostgreSQL | Aislamiento más fuerte | Migraciones más complejas, overhead de conexiones |
| Bases de datos separadas | Cada tenant en su propia DB | Aislamiento total | Costos de infraestructura, backups por separado |
Para la mayoría de los casos, el filtrado por tenant_id con una buena capa de tests que verifique el aislamiento es suficiente. Eso sí: implementá cuotas y límites por cliente desde el día uno. Cada tenant debería tener un límite de contactos, de emails por mes y de dominios verificados, almacenado en una tabla de configuración. Si no ponés límites temprano, un cliente que importa 200.000 contactos y manda un broadcast te puede arruinar la reputación del pool de IPs compartido de Resend — y con eso, la entregabilidad de todos los demás clientes.
En producción, PostgreSQL es la opción obvia: las migraciones de FastAPI con Alembic, índices compuestos sobre (tenant_id, domain_id) y (tenant_id, campaign_id), y particionamiento si el volumen de eventos de webhook se dispara. Todo esto documentado en el artículo de Martin Palopoli sobre multi-tenancy real con FastAPI y PostgreSQL, que aborda planes, cuotas y aislamiento de datos con ejemplos concretos.
Errores comunes al armar un sistema email multi-tenant con FastAPI
Después de ver varias implementaciones (propias y ajenas), estos son los tropiezos que aparecen una y otra vez:
1. Delegar la lógica de contactos a Resend. Creés que usando Audiences por tenant estás cubierto, hasta que necesitás segmentar por comportamiento y la API te dice “no”. La corrección es simple: contactos siempre en tu base, Resend solo recibe el payload final del email.
2. No implementar reintentos con backoff en los webhooks. Resend te manda un evento, tu handler falla por un timeout de base de datos, y el evento se pierde para siempre. Resend tiene reintentos del lado de ellos, pero si tu endpoint devuelve 500 consistentemente durante dos horas, algunos eventos van a caerse. Implementá una cola interna (Redis o similares) para desacoplar la recepción del webhook del procesamiento.
3. Olvidarse del tenant_id en una query anidada. El error clásico: hacés un JOIN entre campañas y contactos, y en una subconsulta te olvidás de filtrar por tenant. El Cliente B termina viendo métricas de contactos del Cliente A. Testealo con dos tenants y datos cruzados; si el test no falla cuando debería, tu suite tiene un agujero.
4. Asumir que la verificación DNS es instantánea. La verificación DNS puede tomar un tiempo considerable. ¿Cuántos developers leen eso y lo ignoran? La mayoría. Implementá el polling y el estado en la UI desde el primer sprint, no como un “después lo mejoramos”. Para más detalles técnicos, mirá el tutorial de OpenClaw sin API.
Preguntas Frecuentes
¿Resend soporta segmentación de contactos por API?
No en el sentido dinámico que necesitás para multi-tenant. Los Segmentos de Resend se configuran manualmente desde el dashboard y no tienen una API de filtrado que puedas manejar programáticamente. Por eso la recomendación es que los segmentos los calcules vos en tu base de datos y solo le mandes a Resend los destinatarios finales de cada envío.
¿Cómo almaceno contactos y campañas en mi base de datos con FastAPI?
Con un modelo relacional simple: tablas de tenants, domains, contacts, segments y campaigns, todas con tenant_id como columna de partición lógica. Usás SQLAlchemy o el ORM que prefieras, y en cada query de negocio filtrás por el tenant_id del request autenticado. En desarrollo va SQLite; en producción, PostgreSQL con índices compuestos sobre las queries más frecuentes.
¿Qué problemas tiene usar Broadcasts de Resend en multi-tenant?
Los Broadcasts no tienen noción de tenant: disparás uno y sale para todos los contactos de la Audience asociada. No podés restringir por cliente, por dominio verificado, ni por segmento dinámico definido en tu backend. Además, los eventos post-envío (bounced, opened) quedan atados al Broadcast, no a tu lógica de negocio por tenant, lo que complica el reporting.
¿Cómo verifico dominios de múltiples clientes con una sola cuenta de Resend?
Registrás cada dominio mediante la API de Resend, le devolvés al cliente los registros DNS que tiene que configurar, y hacés polling del endpoint de verificación cada 30-60 segundos hasta que el estado pase a verified. La verificación puede demorar, pero usualmente se resuelve en minutos. Implementar un job asíncrono que actualice el estado en tu base evita que el cliente tenga que esperar sin feedback.
¿Se puede testear la integración con Resend sin dominio real ni créditos?
Sí, mockeando las llamadas HTTP a la API de Resend y simulando los eventos de webhook. La suite documentada por Srinivasa Rao ejecuta tests sin dominio verificado y sin consumir créditos. El enfoque usa pytest-mock para interceptar las requests y devolver respuestas simuladas, y un handler de webhook en modo test que recibe payloads predefinidos.
Conclusión
Lo que los docs de Resend no te dicen — y lo que aprendés a las piñas después de semanas de implementación — es que las Audiences, Segmentos y Broadcasts no están pensados para multi-tenancy. No hay vuelta: si querés que varios clientes manden desde sus dominios con control fino y aislamiento real, la arquitectura tiene que ser Resend para envío, tu base de datos para todo lo demás.
La buena noticia es que con FastAPI, SQLAlchemy y un diseño de base de datos que priorice el tenant_id desde el día uno, el sistema no solo funciona: escala, se testea sin depender de servicios externos, y te deja cambiar de proveedor de envío sin migrar datos de negocio. La mala noticia (bah, no tan mala) es que tenés que bancarte el polling de verificación DNS y escribir una capa de tests a prueba de olvidos de filtrado entre tenants.
Si estás arrancando un SaaS que manda emails desde múltiples dominios, empezar con este patrón te ahorra tener que reescribir la capa de envío seis meses después, cuando los clientes pidan segmentación de verdad y el modelo de Broadcasts de Resend ya no te dé ni para arrancar.
Fuentes
- Building a Multi-Tenant Email System with FastAPI and Resend: What the Docs Don’t Tell You — Artículo original de Srinivasa Rao (11 de junio de 2026) con la experiencia de implementación real, tests y código.
- Setting up Resend for multi-tenants — Guía oficial de Resend donde la propia empresa recomienda manejar contactos en base de datos propia.
- Multi-tenancy real con FastAPI y PostgreSQL: Planes, cuotas y aislamiento de datos — Artículo de Martin Palopoli sobre estrategias de aislamiento y límites por tenant.






