Dentro de un servicio de notificaciones multi-proveedor: colas, soluciones alternativas y confianza
Un recorrido por el diseño de un servicio de notificaciones fiable: almacenamiento de credenciales, solución alternativa de proveedores, reintentos con BullMQ y listas de espera para mensajes defectuosos, equidad entre usuarios y verificación de firmas de webhook.
Fuentes: Building NotifyKit: The Engineering Behind Reliable Notifications
Enviar un correo electrónico o un SMS parece ser una tarea sencilla: se llama a la API del proveedor y listo. En entornos de producción, esa única llamada a send() se convierte silenciosamente en un problema de sistemas distribuidos, ya que los proveedores establecen tiempos de espera, imponen límites de frecuencia, se desconectan, entregan los mensajes fuera de orden y las reintentos pueden generar duplicados. Este artículo analiza la arquitectura de un servicio de notificaciones de código abierto y multiinquilino desarrollado con Node.js, BullMQ y Redis, abordando siete áreas: secretos, fallos del proveedor, diseño de colas, ordenamiento, capacidad de entrega, equidad entre inquilinos y verificación de webhook. Para cada una de ellas se explica la decisión tomada, el razonamiento detrás de ella y las limitaciones que se aceptan conscientemente, para que pueda reutilizar ese razonamiento en sus propios sistemas.
Dos tipos de secretos, dos estrategias de almacenamiento
Una plataforma de notificaciones alberga varias credenciales, y la pregunta clave para cada una es sencilla: ¿necesitará el sistema alguna vez recuperar el valor original?
Claves del proveedor: encriptarlas, ya que deben poder recuperarse
Los clientes aportan sus propias claves API del proveedor de correo electrónico, y la plataforma debe presentar esas claves al proveedor en cada envío. Esto descarta el uso de hashing; el valor debe poder recuperarse. El diseño primero valida cada clave con su proveedor y luego la encripta con AES-256-GCM antes de almacenarla. GCM es un modo autenticado, por lo que además de garantizar confidencialidad detecta modificaciones: un texto encriptado que haya sido alterado no se puede descifrar, en lugar de generar una credencial plausible pero incorrecta.
El texto en claro nunca entra en contacto con la base de datos, los registros ni las cachés. Se descifra en exactamente dos lugares: para mostrar una vista previa enmascarada en el panel de control, y dentro del proceso de envío justo antes de la solicitud al proveedor externo. Limitar el descifrado a un par de rutas de código bien definidas facilita enormemente la auditoría en caso de fugas accidentales.
Claves API emitidas por la plataforma: hash, ya que solo es necesario verificarlas
Las claves API que la plataforma emite a sus propios clientes siguen una lógica opuesta. El sistema nunca necesita recuperarlas, solo confirmar que la clave presentada es auténtica. Por lo tanto, la clave se muestra una sola vez al crearla, y solo su hash SHA-256 se guarda. En cada solicitud, la clave recibida se procesa con hash y se compara con el valor almacenado. Una clave perdida no puede recuperarse y debe generarse de nuevo.
Un hash rápido como SHA-256 es adecuado aquí porque las claves son cadenas aleatorias largas con alta entropía. Esa misma elección sería incorrecta para contraseñas humanas, las cuales necesitan un algoritmo deliberadamente lento como bcrypt, scrypt o Argon2 para resistir intentos de adivinación.
Secretos de firma de webhooks: algo intermedio
El secreto utilizado para firmar los webhooks salientes es generado por la plataforma, al igual que una clave API, pero su valor en bruto se necesita cada vez que se firma un webhook, similar a una clave de proveedor. Por lo tanto, se cifra con AES-256-GCM cuando está inactivo, se descifra justo a tiempo para cada operación de firma y solo se mantiene en memoria mientras está en uso.
Sobrevivir a proveedores poco fiables con un orden de respaldo
Cualquier sistema vinculado a un único proveedor hereda las interrupciones, limitaciones de velocidad y tiempos de espera de dicho proveedor. El servicio reduce esa exposición mediante una lista de prioridades por cliente. Un cliente puede registrar varios proveedores, como SendGrid, Resend y Postmark, y asignarles un orden de prioridad. Cuando una solicitud de envío no especifica ningún proveedor, se utiliza esa lista de prioridades, comenzando por el de mayor prioridad.
El sistema recorre la lista. Si un proveedor falla debido a que está inactivo, tiene limitaciones de velocidad o es demasiado lento, el fallo se registra y se prueba el siguiente proveedor. El primer éxito pone fin al proceso, y el cliente nunca se da cuenta de la solución alternativa a menos que todos los proveedores fallen.
También es posible asignar un mensaje específico a un proveedor, opcionalmente con una solución alternativa designada; en ese caso solo se intenta esa combinación. No todos los errores se tratan de la misma manera:
- Fallas permanentes, como credenciales rechazadas o solicitudes mal formadas, provocan un fallo inmediato, ya que intentar de nuevo no puede solucionarlas.
- Fallas transitorias, como tiempos de espera, límites de frecuencia y respuestas 5xx del proveedor, se intentan nuevamente con retrocesos temporales.
Cuando toda la lista falla, el error asciende hasta la cola, cuya propia política de intentos múltiples toma el control. La solución alternativa no hace que desaparezcan las fallas del proveedor; simplemente brinda a cada mensaje más oportunidades de tener éxito antes de que el fallo sea visible para el cliente.
Un detalle importante a tener en cuenta: un tiempo de espera no demuestra que el proveedor rechazó el mensaje. Un proveedor puede aceptar una solicitud y luego no responder a tiempo, lo que podría hacer que la misma notificación se envíe dos veces. Utilizar una clave de idempotencia cuando el proveedor la soporta, o aceptar duplicados ocasionales en mensajes de bajo riesgo, son las formas habituales de abordar este problema. Nuestro artículo sobre claves de idempotencia para endpoints POST en Node.js explica este patrón en profundidad.
Arquitectura de colas
Entregar una notificación dentro de la solicitud API que la activó vincula la latencia del solicitante con el proveedor más lento. Un proveedor lento o no disponible mantendrá la llamada HTTP del cliente en espera hasta que finalmente tenga éxito o se agote el tiempo de espera. El servicio evita esto separando la aceptación de la entrega.
Desacoplar la solicitud de la entrega
La API solo valida la solicitud, crea una fila Job en la base de datos y pone el trabajo en cola. BullMQ en Redis se encarga de la programación, con una cola para cada tipo de trabajo:
notifications-emailpara correos electrónicos de los clientesnotifications-webhookpara webhooks de los clientesplatform-emailpara las alertas internas del propio servicionotifications-failedcomo cola de correos no entregados para trabajos que han agotado los intentos
Las colas separadas permiten que un tipo de tarea se retrase o se ajuste sin afectar a las demás. Para un análisis más detallado sobre el diseño de tareas en BullMQ, consulte construyendo sistemas de tareas en fondo confiables con BullMQ y Redis.
Las reintentos cuentan los pasos completos, no los proveedores
Cada tarea cuenta con tres intentos con retroceso exponencial, y el detalle importante es qué significa un “intento”. No se trata de un intento por proveedor. Un único intento consiste en probar todos los proveedores del cliente en orden de prioridad, deteniéndose en el primer éxito. Cuando todos los proveedores probados fallan, la tarea ha agotado un intento, y el siguiente intento comienza nuevamente con el proveedor de mayor prioridad. Solo después de tres intentos fallidos se copia la tarea, incluido el error, a la cola de correos no entregados, y la original se marca como FAILED.
La base de datos es la fuente de verdad incluso cuando la cola en sí falla. Un Job del cliente, o un PlatformEmailLog para correos internos, se registra con el estado PENDING antes de que nada sea colocado en la cola. Si Redis no es accesible o al agregar el trabajo se produce un error, este se registra, se informa del fallo y la fila se actualiza a FAILED con el motivo. Ninguna solicitud desaparece silenciosamente entre la API y la cola.
Para qué sirve la cola de correos no entregados
Cuando un trabajo agota sus tres intentos, se crea una copia en notifications-failed que contiene el contenido completo, el error y una marca de tiempo, mientras que el registro en la base de datos se establece en FAILED con el mismo motivo. Registrar el fallo dos veces es intencional, ya que cada registro está dirigido a un público diferente:
- La fila de la base de datos está orientada al cliente y respalda a
GET /jobs/:id. - La entrada de DLQ está orientada al operador; es una copia desechable destinada a ser monitoreada y sobre la cual se actuar.
Un DLQ solo es útil si hay algo que lo vigila. Cada tarea que llega dispara una alerta en Slack, reutilizando la ruta de notificación ya establecida para los fallos en el correo electrónico de la plataforma. Eso convierte la cola en una señal de alerta temprana: un aumento repentino de tareas en estado de carta muerta en un corto período suele indicar algún problema sistémico, como una interrupción del proveedor o un despliegue defectuoso.
Nada sale automáticamente del DLQ. Una tarea fallida solo se vuelve a intentar cuando el cliente llama a POST /jobs/:id/retry, lo que la restablece en estado PENDING y reinicia el flujo.
Prioridad y concurrencia
Las tareas tienen una de tres prioridades: CRITICAL, NORMAL o LOW, que se corresponden con el soporte nativo de prioridades de BullMQ. El tráfico del plan gratuito siempre se asigna a LOW para que no compita con el tráfico de pago. Los correos electrónicos de la plataforma ignoran por completo esa lógica: siempre se ingresan como CRITICAL y utilizan un tiempo de espera más corto.
Los grupos de trabajadores extraen tareas continuamente, siendo cada uno ejecutado con una concurrencia de cinco, por lo que hasta cinco tareas por grupo se procesan al mismo tiempo.
Ordenamiento: una brecha intencionada
El servicio no garantiza el procesamiento de los pedidos por cliente. La concurrencia y la prioridad se ajustan para maximizar el rendimiento, y con cinco procesadores en paralelo además de intentos repetidos, dos mensajes enviados en secuencia pueden entregarse fácilmente en orden inverso. Para la mayoría de las notificaciones esto no supone un problema, ya que son independientes. Solo se convierte en un problema para pares relacionados causalmente donde el orden tiene importancia, como un mensaje que lógicamente debería seguir a otro.
Existen dos soluciones generales, cada una con un costo:
- Serializar por cliente. Dirigir las tareas de un cliente a través de un único canal para garantizar el orden, a costa del paralelismo de dicho cliente.
- Hacer que los mensajes sean tolerantes al orden. Adjuntar marcas de tiempo y dejar que los consumidores interpreten ellos mismos el orden, lo cual traslada la responsabilidad a cómo los clientes leen las entregas.
La limitación es conocida y fue aceptada en favor del rendimiento. El uso actual aún no justifica ninguno de los costos, por lo que la decisión se pospone hasta que los datos reales de los clientes muestren qué equilibrio es más adecuado. Documentar explícitamente una brecha como esta suele ser mejor que resolverla solo a medias.
Entrega: prevención de fallos antes y después del envío
Los correos electrónicos pueden rechazarse, terminar en la carpeta de spam o desaparecer dependiendo de la reputación del remitente y de las señales que se acumulan con el tiempo. El servicio aborda esto en dos etapas.
Antes del envío: verificación de dominio
Cuando un cliente desea enviar desde su propio dominio, el servicio registra dicho dominio en el proveedor elegido por el cliente y recibe los registros DNS que demuestran la propiedad. Estos registros varían según el proveedor, por lo que se muestran al cliente para que los agregue en su servidor DNS. El envío desde ese dominio permanece bloqueado hasta que se complete la verificación.
Después del envío: acción ante los eventos del proveedor
Los proveedores informan sobre entregas, aperturas, clics, rechazos y quejas por spam a través de webhooks. El servicio almacena estos eventos y responde según su tipo:
- Un rechazo definitivo (la dirección no existe o se rechaza de forma permanente) suprime automáticamente al destinatario, y los envíos posteriores a esa dirección se bloquean antes de contactar a cualquier proveedor.
- Una queja por spam provoca la misma supresión.
La regla es que solo las señales que indican un problema permanente provocan un bloqueo. Esto no resuelve por completo la capacidad de entrega, ya que la reputación aumenta o disminuye según factores que van más allá de un único mensaje, pero evita que el sistema intente enviar repetidamente a direcciones conocidas por fallar, lo cual es uno de los factores que dañan la reputación.
Impedir que los usuarios se perjudiquen entre sí
Todos los clientes comparten las mismas colas, grupos de trabajadores e instancia de Redis; nada está físicamente aislado. La equidad proviene de tres controles aplicados en diferentes etapas.
Primera barrera: límites de tasa por cliente en la API
Antes de que una solicitud se convierta en un trabajo, pasa por un límite de velocidad respaldado por Redis por cliente durante un período de 60 segundos, cuyo valor depende del plan:
- FREE: 5 solicitudes por minuto
- INDIE: 50 solicitudes por minuto
- STARTUP: 200 solicitudes por minuto
Cualquier aumento repentino en las solicitudes desde una cuenta es rechazado de inmediato y nunca sobrecarga la cola.
Segunda barrera: prioridad una vez aceptado
Los trabajos aceptados se procesan según su prioridad, y los trabajos del plan gratuito siempre tienen prioridad BAJA. Los procesadores siempre atienden primero los trabajos de mayor prioridad, por lo que incluso si una cuenta gratuita acumula solicitudes pendientes, las notificaciones de los clientes de pago se procesan antes.
Tercera barrera: cuotas mensuales
A largo plazo, las cuotas limitan el volumen de solicitudes. El plan gratuito permite enviar un total de 100 correos electrónicos y webhooks al mes. Los planes de pago solo tienen límite en los webhooks; los correos electrónicos no tienen límite para ellos.
Qué falta aún
No existe una aislación estricta de recursos: no hay trabajadores dedicados por cliente. Dos clientes del mismo nivel de prioridad comparten la misma capacidad, y dentro de un mismo nivel las tareas se procesan aproximadamente por orden de llegada. Con el volumen actual esto no ha supuesto un problema, ya que el tráfico cabe cómodamente dentro de lo que puede manejar la infraestructura compartida y ningún cliente ha ralentizado significativamente al otro. A mayor escala, sería necesario contar con aislamiento por cliente o un esquema de programación justo (por ejemplo, sistema round-robin entre clientes dentro de un mismo nivel de prioridad).
Verificación de webhooks en ambas direcciones
Cada proveedor integrado envía eventos de vuelta a través de webhooks: confirmaciones de entrega, rechazos, informes de spam y cambios en el estado de pago. Cada punto final receptor es una URL pública. Sin verificar al remitente, cualquiera podría publicar un evento falso de “entrega”, falsificar un rechazo para que se suprima la dirección de otra persona, o, lo que es más dañino, falsificar un evento de pago para marcar una suscripción como pagada.
Entrante: un esquema por proveedor
No existe un estándar común, por lo que cada integración representa un trabajo independiente de seguridad:
- SendGrid firma los datos con ECDSA, y la firma se verifica contra la clave pública de SendGrid antes de considerar confiables los datos.
- Resend envía webhooks a través de Svix, el cual utiliza un esquema HMAC con marca de tiempo que protege contra manipulaciones y retransmisiones no autorizadas.
standardwebhooks.El caso de Postmark presenta una trampa fácil de pasar por alto: el secreto debe compararse en tiempo constante, por ejemplo con crypto.timingSafeEqual de Node, y no con una comprobación normal de igualdad de cadenas. Una comparación ordinaria puede terminar en cuanto haya una diferencia de carácter, y esas diferencias de tiempo pueden permitir que un atacante recupere el secreto carácter por carácter en lugar de intentar descifrar todo el valor por fuerza bruta.
Ninguno de estos esquemas es intercambiable, y cada integración debe ser aprendida e implementada desde cero. La constante que rige en todos ellos es que nada de lo que llega a un endpoint de webhook se procesa hasta que se verifica su origen.
Salida: hacer que los clientes confíen en la plataforma
El mismo problema existe al revés cuando el servicio envía webhooks a los endpoints de los clientes. Si un cliente ha configurado una clave de firma, cada entrega incluye un encabezado X-Webhook-Timestamp y un encabezado X-Webhook-Signature. La firma es un HMAC-SHA256 calculado sobre la cadena timestamp.payload, lo que vincula el cuerpo del mensaje a un momento específico. El receptor vuelve a calcular la firma y también rechaza las marcas de tiempo demasiado antiguas, lo que impide que una solicitud capturada sea reproducida posteriormente.
Los clientes tienen un control total sobre su secreto: pueden generarlo, verificar su estado, rotarlo o eliminarlo. Incluso sin un secreto configurado, los webhooks siguen siendo entregados, aunque sin firmar. Esto favorece la compatibilidad retroactiva, pero también implica que los receptores deben optar por la verificación para estar protegidos. Para una perspectiva complementaria desde el lado receptor, consulte Comprender las claves de idempotencia en los puntos finales POST de Node.js.
Un fallback mínimo y luego la firma
deliver recorre los proveedores en orden y devuelve el primero que acepta el mensaje. Reintentar todo el trabajo es decisión de la cola.
type Provider = {
name: string;
send: (to: string, body: string) => Promise<void>;
};
const deliver = async (providers: Provider[], to: string, body: string) => {
const errors: string[] = [];
for (const provider of providers) {
try {
await provider.send(to, body);
return provider.name;
} catch (err) {
errors.push(`${provider.name}: ${(err as Error).message}`);
}
}
throw new Error(`all providers failed\n${errors.join('\n')}`);
};
La firma cubre timestamp.payload. El receptor rechaza un MAC incorrecto y una marca de más de cinco minutos.
import { createHmac, timingSafeEqual } from 'node:crypto';
const sign = (secret: string, timestamp: string, payload: string) =>
createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
const verify = (
secret: string,
timestamp: string,
payload: string,
signature: string,
) => {
const ageMs = Math.abs(Date.now() - Number(timestamp));
if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) return false;
const expected = Buffer.from(sign(secret, timestamp, payload));
const received = Buffer.from(signature);
if (expected.length !== received.length) return false;
return timingSafeEqual(expected, received);
};
Puntos clave
- Elija entre cifrado y hasho al decidir si es necesario recuperar algún momento el valor original.
- Trate las soluciones de respaldo del proveedor y los intentos de reintentar en colas como capas separadas, y defina con precisión qué significa un “intento”.
- Escriba un registro duradero en la base de datos antes de insertar el elemento en la cola, para que una interrupción en ella no cause la pérdida de una solicitud.
Lecturas relacionadas
- Construyendo sistemas de trabajos en fondo confiables con BullMQ y Redis — Aprenda cómo diseñar pipelines de trabajos en segundo plano resistentes en Node.js con BullMQ y Redis, abordando temas como las reintentos, la concurrencia, la idempotencia y el monitoreo.
- De Modulo a anillo hash: escalando un fleco de cache en Node.js sin temporales — Entienda por qué el sharding hash-mod-N daña las bases de datos cuando cambian los nodos, cómo se comparan los métodos de hashing como rendezvous, jump y ring, y cómo crear un anillo ponderado equilibrado en Node.js.