Ir al contenido

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).

Ventana de terminal
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

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ón
const { 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 s
const { json: estado } = await conecta.verSesion(s.session_id);
// c) Con status signup_completed/claimed: reclamar y guardar CIFRADO
const { 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.

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.

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.parse
const 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 | estado

Responde 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; waId es entonces su ID y el envío usa recipient (lo resuelve enviarTexto).
  • verSesion() → status=active y connection.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).
  • 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.