Avisa: eventos de negocio que se convierten en mensajes de WhatsApp
Un backend que recibe «cita creada» y decide qué mensaje sale, cuándo, por qué ventana y a qué costo, sin que el sistema de origen hable nunca con Meta. Más de mil pruebas, 33 decisiones escritas y todavía ningún mensaje real.
La idea
Cada sistema que construyo termina necesitando WhatsApp: recordar una cita, enviar un comprobante, avisar que un pedido está listo. Resolverlo en cada uno es repetir lo mismo tres veces, y mal.
Avisa —antes NexoConecta— es la pieza que lo resuelve una vez. El sistema de origen solo reporta un evento de negocio, como «cita creada», con POST /api/v1/events. Avisa decide qué mensaje corresponde, cuándo enviarlo, si la ventana de conversación de 24 horas está abierta, qué plantilla usar y cuánto cuesta. El sistema de origen nunca habla con Meta.
Las reglas de cada rubro son playbooks en JSON versionado: un vertical nuevo se agrega con un archivo, no con código.
Cómo está montado
- Cinco colas, una por tipo de urgencia.
realtime,webhooks_in,scheduled,bulkywebhooks_out. La quinta llegó después: los avisos que salen hacia el sistema del cliente son otro eje. Porbulkllegarían media hora tarde, y porrealtimecompetirían miles de confirmaciones de entrega con la respuesta a un paciente. - La ventana de 24 horas es un estado guardado, no una consulta a Meta: se lee con un índice y sin cliente HTTP. Sin conversación previa se da por cerrada; ante la duda, el sistema falla cerrado.
- El costo se guarda tal como lo informa Meta, y las tarifas están versionadas por fecha: el resolutor exige la fecha como parámetro, sin un
now()por defecto que la esconda. - Idempotencia de verdad. Una tabla guarda la respuesta de cada petición y la devuelve byte a byte si se repite; al despachar, además, un lock de Redis por mensaje.
- Webhooks salientes firmados con HMAC-SHA256 sobre
timestamp.cuerpo, seis reintentos y protección contra SSRF. - Multi-tenant con Row Level Security en Postgres, forzado incluso para el dueño de las tablas: la aplicación nunca se conecta como superusuario, y sin contexto de cliente una consulta devuelve cero filas.
- Un simulador de Meta dentro de la aplicación, que entrega los webhooks tarde y en desorden, con una semilla reproducible. Todo lo que existe se probó contra él.
Lo que ya funciona
La API v1 con su SDK en PHP, los playbooks, el panel de operación con autenticación de dos factores, las alertas, el registro contable de cada mensaje y una revisión de seguridad cerrada. La última corrida completa: 1,053 pruebas en verde y análisis estático en el nivel más estricto de PHPStan. Hay 33 decisiones de arquitectura escritas.
Lo que todavía no
Nunca ha hablado con Meta. Falta el alta de la cuenta y el número, conectar un canal sin pasos manuales, enviar plantillas a revisión, los mensajes con archivos adjuntos, el panel para clientes y el primer despliegue. Por regla propia, ningún otro sistema mío se conecta hasta que esté terminado y probado contra Meta real.
Lo aprendido
Una herencia mal hecha puede apagar una capa de seguridad sin avisar. Tres modelos sobrescribían booted() sin llamar al padre y perdían el filtro por cliente. No hubo fuga porque Row Level Security seguía filtrando abajo. El arreglo no fue agregar tres llamadas al padre: el registro pasó a un trait, para que el cuarto modelo no repita el error.
Guarda resultados, no el estado de quien llama. La idempotencia guardaba los 403 y 429 como respuesta definitiva. Como la clave se deriva de la cita, el reintento del día siguiente recibía el 429 de ayer para siempre, y esa cita nunca tenía su recordatorio.
Un estado no es un lock. El tiempo de reintento de Redis era menor que el tiempo límite de las colas, así que dos workers podían enviar el mismo recordatorio. «Está en cola» no impedía que lo tomara otro.
Siguiente paso
El primer despliegue, el alta en Meta —cuenta, número de prueba y webhook— y el primer mensaje que salga de verdad.