Conectar WhatsApp con Conecta
Regla de LemaBot (2026-10-01): todo proyecto que necesite WhatsApp se conecta a Lema Engine por Conecta. Nada de enlaces admin manuales, tokens copiados a mano ni la key admin de Engine dentro de otro proyecto.
Lema Engine (agentes.lemabot.com) es solo el puente con Meta: hace el Embedded Signup, guarda la conexión y
reenvía los eventos firmados al proyecto. No ejecuta agentes ni guarda mensajes. El agente vive en el proyecto.
1. Registrar la integración (operador, una vez por proyecto)
Sección titulada «1. Registrar la integración (operador, una vez por proyecto)»Lo hace LemaBot con la key admin de Engine (está en Coolify, app lemaengine-app, variable LEMA_API_KEY; nunca se
copia a otro proyecto). Antes, GET /v1/admin/integrations para no duplicar (409 si existe).
curl -sS -X POST https://agentes.lemabot.com/v1/admin/integrations \ -H "Authorization: Bearer $LEMA_API_KEY" -H 'Content-Type: application/json' \ -d '{ "tenant_id": "<slug-cliente>", "crm_id": "<slug-cliente>_crm", "name": "<Nombre visible>", "allowed_origins": ["https://<app-del-cliente>"], "relay_destination_url": "https://<app-del-cliente>/api/public/whatsapp-webhook", "multi_tenant": false }'La respuesta trae api_key (lema_ck_…) una sola vez → secreto CONECTA_API_KEY del proyecto.
| Tipo de proyecto | multi_tenant |
Notas |
|---|---|---|
| Un solo negocio (CRM de un cliente, Lovable) | false |
tenant_id=<slug> |
| SaaS con muchos clientes (Nortix…) | true |
Cada sesión manda tenant_ref; un solo relay, se enruta por X-Lema-Connection-Id |
2. Contrato que implementa el proyecto
Sección titulada «2. Contrato que implementa el proyecto»Con el canal del motor (src/lib/agente/core/canal):
import { clienteConecta, cifrar } from "@/lib/agente/core/canal";
const conecta = clienteConecta(process.env.CONECTA_API_KEY!); // SOLO servidor
// a) Botón "Conectar WhatsApp" → el servidor crea la sesiónconst { json: s } = await conecta.crearSesion({ client_name: "Mi Cliente" });// → el navegador abre s.connect_url en pop-up (sincrónico en el clic, SIN noopener)
// b) Estado: postMessage de Engine (esMensajeConecta) + polling cada 3-4 sconst { json: estado } = await conecta.verSesion(s.session_id);
// c) Con status signup_completed/claimed: reclamar y guardar CIFRADOconst { json: c } = await conecta.reclamar(s.session_id);await guardar({ connection_id: c.connection_id, phone_number_id: c.phone_number_id, waba_id: c.waba_id, access_token_enc: await cifrar(c.access_token!, process.env.ENCRYPTION_KEY!), relay_signing_key_enc: await cifrar(c.relay!.signing_key_hex!, process.env.ENCRYPTION_KEY!),});
// d) DESPUÉS de guardar: activar (502 = reintentable)await conecta.activar(s.session_id);La implementación completa (reintentos, estados, UI) está en plantillas/tanstack/conecta.functions.ts y
TabWhatsApp.tsx.
Estados de la sesión
Sección titulada «Estados de la sesión»pending → signup_completed → claimed → active. Salidas: failed (con error.code) y expired (60 min). Una
sesión fallida o vencida no se reutiliza: se crea otra. Si el pop-up se cierra sin terminar, la UI vuelve al estado
inicial.
3. Recibir mensajes (relay firmado)
Sección titulada «3. Recibir mensajes (relay firmado)»Engine reenvía el cuerpo crudo de Meta con tres cabeceras:
| Cabecera | Contenido |
|---|---|
X-Lema-Connection-Id |
connection_id de la conexión |
X-Lema-Relay-Timestamp |
segundos Unix |
X-Lema-Relay-Signature-256 |
sha256= + HMAC-SHA256(signing key, "<timestamp>.<cuerpo crudo>") |
import { verificarRelay, normalizarWebhook, numerosDelCuerpo, descifrar } from "@/lib/agente/core/canal";
const cuerpo = await request.text(); // crudo, antes de JSON.parseconst firma = await verificarRelay({ cuerpo, timestamp, firma: sig, claveHex: await descifrar(con.relay_signing_key_enc, KEY) });if (!firma.ok) return 401;if (numerosDelCuerpo(JSON.parse(cuerpo)).some((n) => n !== con.phone_number_id)) return 403;const eventos = normalizarWebhook(JSON.parse(cuerpo)); // entrante | eco | estadoResponde 2xx solo cuando el evento quedó guardado: si respondes error, Engine devuelve 502 a Meta y Meta
reintenta (hasta 7 días). Por eso el guardado es idempotente por wamid.
- eco: el negocio respondió desde la app WhatsApp Business (coexistencia) → el agente se aparta de esa conversación.
- BSUID: usuarios con nombre de usuario pueden llegar sin teléfono;
waIdes entonces su ID y el envío usarecipient(lo resuelveenviarTexto).
4. Verificación
Sección titulada «4. Verificación»verSesion()→status=activeyconnection.routing_ready=true.- Mensaje de prueba desde un número acordado aparece en la bandeja.
- Panel → WhatsApp muestra las últimas entregas del webhook (
whatsapp_webhook_log).
Rollback
Sección titulada «Rollback»- Parar conexiones nuevas:
PATCH /v1/admin/integrations/{id} {"status":"disabled"}(operador). - Retirar el routing de una conexión:
DELETE /v1/admin/meta/connections/{id}/callback-override(operador).
Fuente del contrato: repo lemabot/engine, docs/runbooks/conecta-integration.md y la ADR
2026-09-30-conecta-self-service-sessions.