Infraestructura propia, en producción
La cartera se debita antes de que salga el mensaje
El hub es la capa que está entre nuestros productos y la API oficial de WhatsApp: envía, recibe, cobra y guarda la prueba de cada conversación. La restricción que diseñó todo lo que viene después es comercial antes que técnica: Meta cobra por conversación y no por intento, así que una cola que reprocesa sin control no produce un log feo, produce una factura.
- 2, cada uno con número y marca propiosProductos en producción sobre el hub
- 29Plantillas aprobadas por Meta
- 5, más cola muerta en PostgresColas de trabajo
- 25 documentosDecisiones de arquitectura registradas
- 523 unitarias y 46 de integraciónPruebas automáticas
- cerca de 5 minutosDel push al código en el aire
- R$ 50 por defecto, ajustableTecho de gasto diario por producto
El canal que todos quieren y la factura que nadie ve llegar
Toda empresa quiere hablar con el cliente donde ya está, y ya está en WhatsApp. El camino oficial de Meta, sin embargo, cobra por conversación iniciada, y el precio cambia con la categoría: marketing cuesta más que utilidad y autenticación, mientras que la conversación de servicio, abierta dentro de la ventana de veinticuatro horas después de una respuesta del cliente, no cuesta nada. Quien integra sin entender esa tabla no se equivoca en un informe, se equivoca en una factura.
El segundo problema no es de precio, es de repetición. La Cloud API no acepta clave de idempotencia en el envío, así que un POST que agota el tiempo límite pudo haber entregado el mensaje o no, y la respuesta honesta a esa ambigüedad es intentarlo de nuevo. Sin un contrato propio de unicidad por encima del transporte, cada reintento se convierte en una conversación nueva cobrada y, peor, en un mensaje repetido en el teléfono de alguien que no pidió dos.
El tercero es jurídico y no perdona improvisación: un mensaje de marketing exige consentimiento registrado, la baja tiene que valer en el acto, y el historial tiene que existir para poder exportarse o borrarse después. Resolver las tres cosas una vez, en un solo lugar, sale más barato que resolverlas a medias dentro de cada producto: esa cuenta fue la que dio origen al hub.
Una sola capa, dos productos y una marca que nunca aparece
El hub es multi-tenant por diseño, y no por configuración: cada producto entra por una clave de API propia, tiene cartera propia y ve solo sus datos, mientras el enrutamiento lleva a cada tenant a su propio número. Dos productos funcionan así en producción hoy, bajo el portafolio Meta de MVAS y con una cuenta de WhatsApp Business separada para cada uno. El techo es de dos números por portafolio y sube a veinte cuando el portafolio queda verificado, así que crecer desde aquí es decisión de cuenta, no obra nueva en el código.
Lo que hace que esto funcione como marca blanca es un detalle del propio WhatsApp: el nombre visible se aprueba por número, individualmente, por Meta. Quien recibe el mensaje ve la marca de ese número y nada más: el intermediario no firma pie de página, no aparece en el perfil y no entra en la conversación. Cambiar el nombre después exige una nueva aprobación, así que la flexibilidad vive en el enrutamiento de tenant a número, y no en el mensaje.
Una premisa nuestra cayó por medición en el camino. Asumíamos que Meta solo entrega número de prueba y que producción exigiría línea propia, con chip y verificación por SMS o voz. En el flujo de nombre visible, Meta provisiona un número suyo que nace ya activo, verificado y en el tier de doscientas cincuenta conversaciones únicas por día: comprobamos los dos por la Graph API antes de creerlo. Traer línea propia sigue siendo posible, pero por posesión y escala, no como requisito.
El débito ocurre antes de llamar a Meta
La regla que organiza todo lo demás es una inversión simple: la cartera del producto se debita antes de llamar a Meta, y la conciliación viene después, cuando el callback confirma lo que pasó. Es lo contrario de lo intuitivo, porque parece más natural cobrar cuando la entrega se confirma: salvo que cobrar después significa gastar sin saldo durante toda la ventana entre la llamada y la respuesta, y es exactamente en esa ventana donde una cola defectuosa multiplica el perjuicio.
El precio sale en reales, y el tipo de cambio no puede tumbar un envío. El cálculo usa la tasa diaria del banco central con un spread configurable, y la degradación está encadenada: si falla la cotización del día, vale la caché; si falla la caché, vale la última tasa guardada en la base; si nada de eso existe, vale la tasa fija del entorno. El servicio de cambio no lanza excepción por principio, porque el dinero no puede detenerse por la API de un tercero. Cada número entra en producción con cuarenta y ocho reglas de precio sembradas.
Encima de eso están las trabas de gasto. Existe un techo diario por producto, cincuenta reales por defecto y ajustable, para que un bucle mal escrito del otro lado no se convierta en una factura de cuatro cifras. El envío programado acepta hasta treinta días de antelación y se debita en la aceptación; cancelar hasta sesenta segundos antes del disparo reembolsa y emite el evento de cancelación. Una campaña es todo o nada: el importe entero sale de la cartera cuando se acepta y, si algo falla a mitad de camino, el lote se cancela y se reembolsa por completo en vez de salir a medias.
El reembolso duplicado es el error que nadie detecta mirando logs. Cerramos esa puerta en la base de datos, con un índice único parcial sobre la referencia del reembolso, y le enseñamos al servicio de cartera a tratar la violación de ese índice como operación ya hecha, y no como fallo: el segundo intento se vuelve una no-operación silenciosa en lugar de tumbar un worker. La traba es de la base, así que vale incluso cuando el error está en el código.
Entregar una vez, y una sola vez
La idempotencia va por una clave elegida por el producto: el mismo identificador de mensaje llegando dos veces produce un solo efecto. Eso es lo que hace seguro reintentar, y reintentar es obligatorio, porque la Cloud API no ofrece clave de idempotencia propia y un tiempo límite agotado en el envío es ambiguo por naturaleza. Asumimos entrega al menos una vez en el transporte y resolvemos la unicidad por encima de él, donde tenemos base de datos y control.
La política de reintento es una taxonomía, no un número. Un error de la franja 400 que viene de Meta es determinista y el cliente HTTP nunca lo reenvía; un error de la franja 500 y el límite de tasa suben marcados como recuperables y dejan que la cola vuelva a intentarlo con espera creciente; y el límite de envío de Meta, que es por número y crece con la calidad, se convierte en un acelerador por identificador de número dentro del worker de envío. Los errores que cambian la conducta del producto: ventana de veinticuatro horas expirada, destinatario inalcanzable, parámetro de plantilla incompatible, token vencido: están mapeados uno a uno, con nombre, y no caen en un balde genérico.
En el sentido inverso, el webhook de Meta trae contrato de tiempo: responder 200 en hasta veinte segundos, siempre, incluso con error interno, o Meta reenvía el evento. Entonces la ruta verifica la firma antes de procesar nada, publica el evento y devuelve; el trabajo real ocurre en cinco colas separadas, una para el envío, una para el webhook de entrada, una para el callback al producto, una para la eliminación de datos a pedido y una para la respuesta automática. Lo que falla después de todos los intentos no desaparece: cae en una cola muerta en Postgres, con el motivo registrado y un camino de reprocesamiento.
Ese mismo camino de envío sostiene el código de verificación como servicio. El producto pide la emisión, el hub genera seis dígitos, guarda solo el hash en BCrypt (nunca el código en claro, nunca en log), aplica límite doble en Redis por teléfono y por producto, invalida el código anterior en cada nueva emisión y resuelve la verificación con un intercambio atómico de estado, de modo que dos clics simultáneos no consuman el mismo código dos veces. El envío reutiliza el flujo que ya existe, con débito, cola e idempotencia ya resueltos, en vez de abrir una segunda puerta para el dinero.
Consentimiento como traba en el código, no como cláusula de contrato
Un mensaje de marketing solo sale hacia quien registró su aceptación, y la verificación es cerrada por defecto: sin el registro de opt-in, el envío se rechaza con un error propio antes del débito, y no después. La campaña no lo descubre a mitad del disparo: pre-filtra la lista y devuelve cuántos destinatarios quedaron fuera por falta de aceptación. Una plantilla de categoría desconocida se resuelve como marketing, porque ante la duda la decisión conservadora es la que no genera reclamación.
La baja es igual de literal. Un mensaje de entrada con la palabra exacta de cancelación marca al contacto como fuera de la lista, sin respuesta automática y sin ceremonia; la coincidencia es exacta a propósito, para que esa misma palabra en medio de una frase no saque de la lista a quien no lo pidió. Y el marcado corre en mejor esfuerzo: si falla, no tumba el procesamiento del webhook, porque perder el evento entero sería peor que repetir el intento.
El resto es higiene de datos tratada como código. Retención, exportación y eliminación a pedido corren en un worker propio, con la solicitud convertida en trabajo rastreable en vez de tarea manual. Los tokens de Meta quedan cifrados en reposo con AES-256-GCM, las claves de API de los productos existen solo como hash, y el teléfono se trata como dato personal en toda la malla de logs: como mucho aparecen los últimos cuatro dígitos.
Lo que sostiene esto cuando nadie está mirando
La operación corre en una VPS de ocho gigabytes con Docker Compose y Caddy cuidando el certificado, sin plataforma de orquestación en medio: fue una decisión explícita para no gastar memoria en un panel. En producción solo el proxy publica puerto; base, cola, API y consola quedan en la red interna, y el contenedor de la API corre sin privilegio de root. Cada cambio en la rama principal pasa por las pruebas, se convierte en imagen publicada en el registro y llega a la VPS por el ciclo del actualizador: del push al código en el aire, cerca de cinco minutos, sin SSH y sin credencial de producción guardada en el CI.
La malla de verificación es lo que permite tocar sin miedo: quinientas veintitrés pruebas unitarias y cuarenta y seis de integración corriendo contra Postgres y Redis reales en contenedor, además de las pruebas de punta a punta de la consola, dieciocho migraciones versionadas y veinticinco decisiones de arquitectura escritas: cada una diciendo qué se decidió, qué se descartó y por qué. Por eso integrarse con el hub no depende de que alguien recuerde nada.
Dos hallazgos recientes dicen más que cualquier declaración de rigor. El tiempo real de la consola llevaba semanas sin funcionar en el navegador y no producía ni un solo error en los logs: secuestrar la respuesta para escribir el flujo de eventos descarta las cabeceras puestas por hooks, incluida la de CORS, y la reconexión automática del propio EventSource enmascaraba la caída cada cinco segundos. El otro fue de observabilidad: la etiqueta de ruta de las métricas caía al camino crudo cuando la ruta no existía, y un escáner con diez mil caminos crearía diez mil series y tumbaría el monitoreo de la máquina. Hoy esa etiqueta es constante, y el conjunto de valores posibles de cada una se cierra antes de que la métrica exista.
Nada de esto es una maqueta: el hub responde en una dirección propia, la consola en otra, y dos productos en producción están conectados por clave de API, cada uno con su número y sus plantillas aprobadas por Meta. Parte del camino está lista y apagada a propósito: la recarga por Pix espera credenciales, la respuesta automática con IA existe detrás de una llave por producto y hoy está apagada en los dos, y el registro embebido de Meta espera revisión de aplicación. Lo preferimos así: encender por variable de entorno es decisión de negocio, escribirlo de nuevo es obra.