Saltar al contenido principal

Conexión de WhatsApp con Meta — cómo funciona y cómo depurarla

Este documento explica la arquitectura completa de la integración de WhatsApp Cloud API (Meta), qué hace falta para que un número envíe y reciba mensajes con un bot, y cómo diagnosticar el problema más común: "el bot no responde".

Hay tres formas de conectar un número, y cada una deja la fila de whatsapp_oauth_credentials con un onboarding_mode distinto:

ModoQuién es dueño del númeroCómo se conectaToken
xenpia_ownedXenpia (portafolio propio)Wizard, solo superadmin: número del portafolio o crear número (OTP + /register)WHATSAPP_ACCESS_TOKEN (fila con access_token vacío)
cloud_apiEl cliente, número nuevoEmbedded Signup, evento FINISHToken de integración de negocio del cliente
coexistenceEl cliente, número que sigue en la app WhatsApp BusinessEmbedded Signup, evento FINISH_WHATSAPP_BUSINESS_APP_ONBOARDINGToken de integración de negocio del cliente

Los dos modos de cliente existen porque Xenpia es Tech Provider verificado de Meta (septiembre de 2026). La decisión y sus límites están en el ADR 0006; el onboarding de clientes está en la §3.b.

1. Conceptos de Meta​

  • Business Manager (BM): la cuenta de negocio de Xenpia en Meta. Todo WABA que Xenpia administra (propio o de un cliente) vive bajo este BM.
  • WABA (WhatsApp Business Account): contenedor de uno o más números de teléfono. Puede crearse desde el wizard de la app (POST /api/whatsapp/xenpia-waba) o directamente en el panel de Meta Business Manager — este segundo camino es el que deja el WABA sin suscribir al webhook, ver §6.
  • Número de teléfono: pertenece a un WABA. Tiene un id interno de Meta (phone_number_id, el que usa la API) distinto del número visible (display_phone_number).
  • System User Token (WHATSAPP_ACCESS_TOKEN): token de larga duración de un usuario de sistema del BM, con permisos whatsapp_business_management + whatsapp_business_messaging. Es el mismo token para todos los WABAs propios de Xenpia — no cambia entre dev y prod, es una credencial de Meta, no de Supabase. Los WABAs de clientes conectados por Embedded Signup usan en cambio su propio token de integración de negocio, guardado por cuenta de canal (ver §3.b).
  • App de Meta ("XENPIA"): la app registrada en developers.facebook.com (META_APP_ID) que recibe los webhooks. Un WABA solo entrega webhooks a las apps que tiene suscritas (GET/POST /{waba_id}/subscribed_apps) — esto es independiente de que el número esté verificado o registrado.

2. Variables de entorno (backend)​

VariablePara qué
WHATSAPP_ACCESS_TOKENSystem User Token del BM. Se usa para casi todas las llamadas a Graph API.
WHATSAPP_BUSINESS_MANAGER_IDBM de Xenpia. Se usa para listar WABAs propios (xenpia-wabas) y crear WABAs nuevos.
WHATSAPP_WABA_IDWABA por defecto (fallback cuando no se especifica uno).
META_APP_IDID de la app de Meta que debe quedar suscrita a los webhooks de cada WABA.
META_VERIFY_TOKENToken de verificación del webhook (GET /webhook/whatsapp, handshake inicial de Meta).
WHATSAPP_PHONE_NUMBER_IDFallback de phone_number_id cuando la fila de credenciales de una cuenta no lo trae. Ya no permite enviar sin cuenta de canal (ver §5).
META_APP_SECRETCanje del code de Embedded Signup y verificación de la firma de los webhooks.
META_WEBHOOK_SIGNATUREenforce (por defecto): 401 a todo webhook sin firma válida. log: lo procesa y lo registra, solo para la transición. off: nunca en producción.
META_WEBHOOK_EXTRA_SECRETSSecretos de otras apps de Meta que envían webhooks a los mismos endpoints (app de Instagram Login, app de pruebas), separados por comas.
WHATSAPP_ONBOARDING_WEBHOOK_OVERRIDE_URLSolo Test. Las WABA conectadas por Embedded Signup desde ese entorno se suscriben con override_callback_uri hacia esta URL (ver §3.b). Vacía en producción.
WHATSAPP_API_VERSIONVersión de la Graph API (vNN.N) para WhatsApp, Messenger e Instagram. Si falta o está mal escrita, v24.0.

Todas viven en backend/.env (no está en git). El mismo valor de WHATSAPP_ACCESS_TOKEN sirve tanto en el servidor de dev como en el de prod — son credenciales de Meta, no de Supabase, así que no hace falta duplicarlas por ambiente.

3. Los 4 pasos para que un número funcione​

Un número de WhatsApp necesita los cuatro para poder enviar/recibir vía el bot. Si falta cualquiera, el síntoma es el mismo ("no responde") pero la causa y el arreglo son distintos:

#PasoQué haceEndpoint / lugar
1Verificar (OTP)Prueba que Xenpia es dueño del número — SMS o llamada con un código.POST /xenpia-numbers/request-otp → POST /xenpia-numbers/verify-otp
2Registrar en Cloud APIActiva el número para enviar/recibir (code_verification_status: VERIFIED no alcanza — hace falta esto).POST /xenpia-numbers/register (usa un PIN de verificación en 2 pasos)
3Suscribir la app al WABALe dice a Meta a qué app (webhook) entregar los mensajes entrantes de ese WABA. Sin esto, Meta recibe el mensaje pero no tiene a quién avisarle — no llega ni un log al backend.POST /{waba_id}/subscribed_apps
4Vincular en la base de datosConecta phone_number_id → channel_account → bot_channel_instance → bot, para que el backend sepa qué bot debe responder.Wizard, paso "Confirmar número" → whatsapp_oauth_credentials

Numbers creados desde el wizard de Xenpia (xenpia_create, o un WABA creado con POST /xenpia-waba) pasan los pasos 1–3 automáticamente. Números o WABAs creados directamente en el panel de Meta (xenpia_owned, el caso de "MC Especialistas Médicos") solo llegan verificados y registrados — el paso 3 (suscripción de webhooks) nunca se ejecutaba, y esa era exactamente la causa de que el bot no respondiera aunque todo lo demás estuviera bien.

GET /api/whatsapp/xenpia-numbers (el endpoint que lista los números de un WABA en el paso "número existente" del wizard) llama a ensureWabaSubscribed() cada vez: idempotente, best-effort, no bloquea la respuesta si falla. Así el paso 3 queda cubierto al conectar un WABA existente desde el wizard, sin intervención manual.

3.b Onboarding de clientes por Embedded Signup (Tech Provider)​

Todo el onboarding de un cliente ocurre en el backend, en una sola llamada: POST /api/whatsapp/onboarding/complete (whatsapp-onboarding.service.ts). El token del cliente nace y se guarda en el servidor; al navegador solo vuelve el número conectado.

Navegador Backend Meta
FB.login(config_id, extras: {
setup, featureType:
'whatsapp_business_app_onboarding',
sessionInfoVersion: '3' }) ───────────────────────────────────────────────────▶ ventana ES v4
postMessage WA_EMBEDDED_SIGNUP ◀────────────────────────────────────────────────── FINISH | FINISH_WHATSAPP_
(parseEmbeddedSignupEvent → modo) BUSINESS_APP_ONBOARDING
server action completeWhatsAppOnboarding ─▶ canje del code (vive 30 s) ──▶ oauth/access_token
GET /{waba}/phone_numbers ──▶ (+ is_on_biz_app)
POST /{waba}/subscribed_apps ──▶
guarda token + waba + modo
cloud_api: POST /{phone}/register (PIN 6 díg.)
coexistence: POST /{phone}/smb_app_data
sync_type=smb_app_state_sync, luego history

Reglas que no son obvias:

  • featureType es obligatorio para ofrecer la coexistencia: sin él la ventana de Meta nunca muestra "conectar tu cuenta existente de WhatsApp Business". Solo lo pasa el wizard de WhatsApp (useMetaEmbeddedSignup(undefined, { whatsappBusinessAppOnboarding: true })), no los de Instagram/Messenger.
  • setup va vacío: nunca el portafolio de Xenpia. En extras.setup, business.id es el portafolio del cliente y sirve para precargarlo. Antes se enviaba el de Xenpia (NEXT_PUBLIC_META_BUSINESS_MANAGER_ID). Un cliente sin acceso no lo notaba, porque Meta cae a la pantalla normal. Pero una cuenta con acceso (el equipo de Xenpia, un tenant que administra Xenpia) creaba la WABA del "cliente" dentro de Xenpia. Lógica en buildEmbeddedSignupExtras (frontend/src/sections/bot/utils/embedded-signup-extras.ts).
  • En coexistencia NO se llama a /register. El número ya está registrado en la app.
  • El evento de coexistencia no trae phone_number_id. El backend lo resuelve con GET /{waba}/phone_numbers, prefiriendo el que tiene is_on_biz_app. Si la versión de Graph no acepta ese campo, reintenta con los básicos.
  • Varios números en la WABA → responde needs_selection; el token ya quedó guardado y POST /api/whatsapp/onboarding/select-number termina el onboarding con el elegido.
  • Sin subscribed_apps no se da por conectado (subscribe_failed): sin suscripción no llega ningún webhook, que es el fallo más caro de diagnosticar (ver §6).
  • La suscripción no pisa un override de webhook. Antes de suscribir se lee GET /{waba}/subscribed_apps: si la app ya está suscrita (con o sin override_callback_uri) no se vuelve a llamar, porque re-suscribir sin override lo borra y la WABA de pruebas pasaría a entregar a producción. WHATSAPP_ONBOARDING_WEBHOOK_OVERRIDE_URL (solo en Test) hace que toda WABA conectada desde ese entorno se suscriba con override hacia él. Lógica en planWabaSubscription (whatsapp-onboarding.ts).
  • En CHANNEL_MODE=sandbox los tres endpoints responden 403. Test no guarda credenciales de clientes (su base lleva copias de las de producción); el panel tampoco muestra el asistente.
  • Sincronización en 24 h, una vez cada tipo. smb_app_data se pide al conectar. Si falla, POST /api/whatsapp/onboarding/sync repite solo lo que falló, y pasadas 24 h marca expired sin llamar a Meta: la única salida es que el cliente desconecte y vuelva a conectar. El error 2593109 es el cliente rechazando compartir el historial (declined), no un fallo.
  • El estado queda en whatsapp_oauth_credentials (onboarding_mode, onboarded_at, contacts_sync_status, history_sync_status, *_request_id, sync_error, disconnected_at, disconnect_reason; migración 20261012000001).

La configuración en el App Dashboard (config v4, dominios del SDK, campos del webhook) está en docs/interno/operaciones/instalacion-servidor.md §4.1.

3.c Webhooks por field y coexistencia​

POST /api/webhook/whatsapp recorre todas las entradas y cambios de cada entrega y despacha por change.field (whatsapp-webhook-router.ts). Antes leía solo entry[0].changes[0] y solo value.messages.

fieldQué haceDónde
messagesLo que escribe el cliente: guarda, invoca al bot y responde (§4)whatsapp.webhook.controller.ts
smb_message_echoesLo que el negocio escribe desde la app: saliente con source = 'phone' y pausa el bot en la conversación (motivo __phone__), salvo que ya estuviera en pausawhatsapp-coexistence.service.ts
historyHasta 6 meses de historial, por fases (0, 1, 2) y chunk_order: mensajes con source = 'history' y su hora original. Nunca invoca ni pausa al bot, y la bandeja no los cuenta como no leídos. Fase 2 al 100 % → history_sync_status = doneídem
smb_app_state_syncAgenda de la app: crea los contactos que falten con su nombre. No renombra ni funde fichas existentes, y una baja en el teléfono no borra nadaídem
account_updatePARTNER_REMOVED: el cliente desconectó desde la app (o Meta por inactividad). Marca disconnected_at/disconnect_reason y pausa el bot del canalídem

Los cuatro primeros se deduplican por provider_message_id dentro de la cuenta de canal, así que una reentrega de Meta no duplica nada. Los estados de entrega (statuses) los procesa DeliveryStatusService (rama de avisos a citas sin chat previo).

Para que Meta envíe estos campos hay que suscribirlos en el App Dashboard (WhatsApp → Configuration → Webhook fields): messages, history, smb_app_state_sync, smb_message_echoes y account_update. Un campo sin suscribir no da error: simplemente no llega.

4. Cómo se resuelve un mensaje entrante​

POST /api/webhook/whatsapp (Meta llama acá cuando llega un mensaje):

Meta → phone_number_id + display_phone_number
→ resolveChannelData()
1. whatsapp_oauth_credentials.phone_number_id = X (camino moderno)
2. channel_account_channels.wa_phone_number = display (fallback legacy)
→ channel_account_id
→ bot_channel_instances (status = 'active') → bot_id
→ botsService.sendMessage(...) → respuesta → dispatchers.dispatch('whatsapp', ...)

Si el paso 3 (subscribed_apps) falta, este flujo entero nunca arranca — no hay ningún log de error específico porque Meta simplemente no llama al webhook. Por eso la tabla messages queda sin ninguna fila (ni siquiera un intento fallido) para ese channel_account_id: es la señal más clara de que el problema es la suscripción, no el procesamiento del bot.

4b. Mensajes salientes: ventana de 24 h, plantillas y estados de entrega​

La Cloud API acepta mensajes libres solo 24 h desde el último mensaje del cliente. Fuera de eso exige una plantilla aprobada, y lo peor es cómo falla: el POST /messages responde 200 y el rechazo (131047) llega después, por el webhook de statuses. Antes ese webhook se descartaba y el outbox daba por entregado lo que nunca salió.

Cómo funciona ahora (ADR 0006):

  • Antes de enviar, OutboxService pasa cada ruta por planDelivery (backend/src/channels/outreach/outreach-planner.ts). Mira el último mensaje entrante de la conversación (ContactReachService.lastInboundAt, índice idx_messages_last_inbound):
    • dentro de 23 h (margen de 1 h): mensaje de sesión, como siempre;
    • fuera, con plantilla aprobada en channel_message_templates: plantilla;
    • fuera y sin plantilla: la ruta se omite con motivo, no se envía a ciegas.
  • Plantilla con botones. El recordatorio de cita usa la plantilla estándar xenpia_recordatorio_cita_v1 (UTILITY, backend/src/channels/outreach/standard-templates.ts), que se crea desde Ajustes con POST /{waba}/message_templates. Los botones QUICK_REPLY llevan payload = appt:confirm:<id> / appt:cancel:<id> (assembleTemplateComponents), y Meta lo devuelve en el webhook como messages[].type = 'button' + button.payload. whatsapp-inbound.ts ya lo traduce a interactive.reply.id, así que applyAppointmentReply confirma o cancela sin código nuevo en el webhook.
  • Contacto sin chat previo (cita cargada a mano): si el tenant activó tenant_modules.config.reminders.cold_outreach, el outbox escribe al teléfono de la ficha (normalizeToWaId, prefijo de tenants.country). Después de que Meta acepta, crea la identidad y la conversación con el wa_id que devolvió Meta (DispatchOutcome.recipientIds), no con el número normalizado: en México (52/521) o Argentina (549) no coinciden, y la respuesta del paciente abriría una ficha nueva. Si el número ya es identidad de otro contacto, no se escribe (phone_belongs_to_other_contact).
  • Rastro del envío. WhatsAppDispatcher devuelve el wamid (providerMessageIds); se guarda en outbox_queue.provider_message_id, messages.provider_message_id y scheduling_appointments.metadata.reminder.provider_message_id.
  • Webhook de estados. whatsapp.webhook.controller.ts recorre todas las entry/changes (antes solo la primera) y pasa value.statuses a DeliveryStatusService.applyUpdates:
    • el estado solo avanza (sent < delivered < read), y failed gana siempre;
    • failed marca la fila del outbox y el mensaje, y levanta una alerta en core_alerts (reminder_failed) que se ve en la campana del panel;
    • delivered/read cierra sola la alerta abierta de esa cita;
    • 132015/132016 marcan la plantilla como pausada/desactivada;
    • un wamid que no salió por la cola (respuesta normal del bot) se ignora por ahora.
  • Traducción de errores: backend/src/channels/outreach/whatsapp-error-codes.ts.

Diagnóstico de un recordatorio que no llegó, en orden:

  1. core_reminders de la cita: ¿se programó con channel_account_id? Sin él, RemindersService lo descarta y alerta (no_channel: el tenant no tiene WhatsApp con bot).
  2. outbox_queue.last_error: por qué no salió o qué ruta se omitió.
  3. outbox_queue.delivery_status / provider_error_code: qué dijo Meta después.
  4. core_alerts abiertas del tenant.

Simulacro sin enviar nada, con una cita concreta (también sin conversación):

cd backend && npm run test:outbox -- --cita=<uuid>

4c. Costo de los mensajes (cambio del 1 de octubre de 2026)​

Desde el 1 de octubre de 2026 Meta cobra también los mensajes de servicio: toda respuesta que no sea plantilla dentro de la ventana de 24 h (texto, botones, listas, Flows, imágenes), enviada por una persona o por un bot de Xenpia. Hasta el 30 de septiembre eran gratis. Se cobran a la misma tarifa que una plantilla de utilidad del país del destinatario, sin descuento por volumen. Las plantillas de utilidad enviadas dentro de la ventana también pasan a cobrarse.

Siguen gratis los mensajes que envía el cliente y todo lo que se envíe en las 72 h después de que el cliente llega desde un anuncio de clic a WhatsApp. Marketing no cambia (se cobra siempre).

Plazo del 30 de septiembre de 2026. Cada cuenta de WhatsApp Business necesita un método de pago en Meta. Si no lo tiene, desde el 1 de octubre Meta deja de entregar sus mensajes de servicio: el bot del cliente deja de responder y en Xenpia solo se ve como failed en el webhook de estados.

Qué implica al diseñar un bot: cada OutboundAction de un turno es un mensaje cobrado, así que un turno de 3 mensajes cuesta el triple que uno de 1. Las reglas (un mensaje por turno, texto y botones en un solo interactivo, límites de caracteres de botones y listas) y la cronología completa de precios 2025–2026, con fuentes, están en el skill mensajes-whatsapp (.claude/skills/mensajes-whatsapp/).

Los estados de entrega de Meta traen pricing.category (service, utility, marketing, authentication). Es el dato para medir el costo por tenant; hoy Xenpia no lo guarda.

5. Endpoints relevantes (backend/src/channels/meta/whatsapp/whatsapp.controller.ts)​

Todas estas rutas exigen sesión. El controlador pasa por PermissionGuard con @RequiresTenantMembership(): hace falta un Authorization: Bearer <JWT de Supabase> de un usuario que pertenezca al tenant_id que viaja en la query, el cuerpo o la ruta (un superadmin pasa siempre). Desde el navegador se llaman con backendFetch (frontend/src/lib/backend-fetch.ts), que añade las dos cosas; desde una server action se reenvía el token de la sesión, igual que en actions/scheduling.ts. Lo mismo vale para las rutas de onboarding de MetaController (/api/webhooks/meta/phone-numbers, embedded-signup-token, fb-pages, ig-account).

Las rutas del portafolio (xenpia-waba* y xenpia-numbers*) exigen superadmin (@RequiresSuperadmin(), que resuelve PermissionGuard con user_is_superadmin). Ser miembro o admin del tenant no basta. El portafolio de Xenpia tiene números de varios clientes. Antes, un admin de tenant podía listar todos los números, asignarse el de otro cliente y dejarlo sin número: al guardar, saveWhatsAppOAuthCredentials desvincula ese phone_number_id de cualquier otra cuenta y pausa sus bots. La regla se aplica en tres capas:

CapaDónde
UIStepScenario en whatsapp-wizard-dialog.tsx solo muestra las opciones del portafolio con is_super_admin && !impersonating; si no, preselecciona client
Backend@RequiresSuperadmin() en las 9 rutas del portafolio
Server actionsaveWhatsAppOAuthCredentials con access_token === '' (número del portafolio) exige rpc('is_superadmin')

El superadmin conecta el número tras Entrar al tenant. Las acciones de actions/bot.ts usan tenantScope() / dbForChannelAccount() (frontend/src/lib/tenant-scope.ts). Con la cookie de «Entrar al tenant» apuntando a ese tenant devuelven el cliente service-role, siempre filtrado por el tenant o por una fila ya verificada dentro de él. Sin esa cookie devuelven el cliente de sesión. Sin esto, RLS (que exige membership) dejaba vacía la pantalla Agentes del superadmin.

POST /api/whatsapp/send-text además exige channel_account_id y comprueba que esa cuenta sea del tenant_id. Ya no existe el envío "sin cuenta" con el número global de Xenpia: permitía a cualquiera escribir desde el número de la empresa.

MétodoPathUso
GET/xenpia-wabasLista WABAs del BM de Xenpia.
GET/xenpia-numbers?waba_id=Lista números de un WABA. Ahora también asegura la suscripción de webhooks (paso 3).
POST/xenpia-numbersCrea un número nuevo en un WABA (flujo xenpia_create).
POST/xenpia-numbers/request-otpPide el código OTP (SMS/voz).
POST/xenpia-numbers/verify-otpVerifica el código (paso 1).
GET/xenpia-numbers/status?phone_number_id=Estado de activación de un número (verify | register | done) — lógica en whatsapp-activation.ts.
POST/xenpia-numbers/registerRegistra el número en Cloud API (paso 2).
POST/xenpia-wabaCrea un WABA nuevo bajo el BM de Xenpia y lo suscribe automáticamente.
POST/onboarding/completeOnboarding de un cliente por Embedded Signup: canje del code, suscripción, registro o sincronización de coexistencia (§3.b).
POST/onboarding/select-numberTermina el onboarding cuando la WABA tenía varios números.
POST/onboarding/syncReintenta la sincronización de contactos/historial de coexistencia (solo en 24 h).

6. Diagnóstico rápido ("el bot no responde")​

En orden — cada paso descarta una causa distinta:

  1. ¿El backend está arriba? curl -i https://api.xenpia.com/api/health (o cualquier endpoint). Un 502 de Cloudflare significa que el problema es de infraestructura, no de WhatsApp.

  2. ¿El número está verificado y registrado en Meta? GET /api/whatsapp/xenpia-numbers/status?phone_number_id=...&tenant_id=... con el JWT de una sesión (Authorization: Bearer ...) → mirar next_step.

    • verify: falta el paso 1 (ojo: si el número ya está CONNECTED y solo el código quedó EXPIRED, la lógica ya lo trata como done — un código expirado en un número activo es normal y no bloquea nada).
    • register: falta el paso 2.
    • done: los pasos 1 y 2 están bien, el problema está en 3 o 4.
  3. ¿Está vinculado en la base de datos? whatsapp_oauth_credentials.phone_number_id = <id> debe existir y apuntar a un channel_account_id con un bot_channel_instances.status = 'active'. Si no hay fila, falta completar el wizard (paso 4) — el número puede estar perfecto del lado de Meta y aun así no tener ningún bot conectado.

  4. ¿La app está suscrita al WABA? GET /{waba_id}/subscribed_apps?access_token=... — si data viene vacío, ningún app recibe los webhooks de ese WABA. Confirmarlo comparando con un WABA que sí funcione (el de Xenpia siempre debería tener la app "XENPIA" en la lista).

  5. ¿Llegó algo a messages? Si hay filas direction = 'inbound' pero ninguna outbound, el problema ya no es la conexión — es el procesamiento del bot (LangGraph, prompt, error en botsService). Si no hay ninguna fila, el mensaje nunca llegó al backend → volver al punto 4.

7. Firma de los webhooks​

Meta firma cada webhook con X-Hub-Signature-256: sha256=<HMAC-SHA256 del cuerpo crudo con el app secret>. POST /api/webhook/whatsapp y POST /api/webhooks/meta la comprueban antes de procesar nada (backend/src/channels/meta/meta-signature.ts). main.ts guarda el cuerpo crudo en req.rawBody, porque la firma se calcula sobre esos bytes y no sobre el JSON ya parseado.

Si tras un despliegue dejan de entrar mensajes y el log muestra firma inválida o ausente:

  1. Comprobar que META_APP_SECRET es el de la app que tiene suscrita la WABA (App Dashboard → Configuración → Básica).
  2. Si los webhooks vienen de otra app (Instagram Login, app de pruebas), añadir su secreto a META_WEBHOOK_EXTRA_SECRETS.
  3. Como salida temporal, META_WEBHOOK_SIGNATURE=log los procesa y los sigue registrando. Volver a enforce en cuanto el log quede limpio.

8. Diagnóstico de la coexistencia​

  • El cliente no ve la opción de conectar su app. Falta featureType en extras (solo lo manda el wizard de WhatsApp) o la configuración de Embedded Signup no es v4.
  • Conectado, pero no aparecen contactos ni historial. Mirar contacts_sync_status / history_sync_status y sync_error en whatsapp_oauth_credentials. requested sin avance = el campo history o smb_app_state_sync no está suscrito en el App Dashboard. declined = el cliente no quiso compartir el historial en la app.
  • Lo que contestan desde el teléfono no sale en la bandeja. Falta suscribir smb_message_echoes.
  • El canal aparece "Desconectado desde la app". Llegó PARTNER_REMOVED; disconnect_reason dice por qué (PRIMARY_INACTIVITY (SYSTEM) = el teléfono dejó de usar la app). Se arregla volviendo a conectar desde el wizard.

9. Deuda pendiente​

  • El token de los clientes (access_token) se guarda en texto plano. Lo protegen el REVOKE/GRANT por columna (20260920000004) y que solo el backend lo lee, pero no está cifrado en reposo.
  • Las rutas xenpia-* exigen pertenecer al tenant, pero no atan cada phone_number_id de Xenpia a un tenant concreto: un miembro de cualquier tenant podría operar un número del portafolio de Xenpia conociendo su id.
  • POST /api/instagram/send-text y /api/messenger/send-text siguen sin guard.
  • El acceso de esta sesión al proyecto de Supabase de DEV vía CLI no está disponible (solo ve el proyecto de PROD) — cualquier diagnóstico de base de datos contra DEV requiere ejecutarlo manualmente (SQL Editor del dashboard) y pegar el resultado.