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:
| Modo | Quién es dueño del número | Cómo se conecta | Token |
|---|---|---|---|
xenpia_owned | Xenpia (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_api | El cliente, número nuevo | Embedded Signup, evento FINISH | Token de integración de negocio del cliente |
coexistence | El cliente, número que sigue en la app WhatsApp Business | Embedded Signup, evento FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING | Token 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
idinterno 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 permisoswhatsapp_business_management+whatsapp_business_messaging. Es el mismo token para todos los WABAs propios de Xenpia — no cambia entredevyprod, 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)
| Variable | Para qué |
|---|---|
WHATSAPP_ACCESS_TOKEN | System User Token del BM. Se usa para casi todas las llamadas a Graph API. |
WHATSAPP_BUSINESS_MANAGER_ID | BM de Xenpia. Se usa para listar WABAs propios (xenpia-wabas) y crear WABAs nuevos. |
WHATSAPP_WABA_ID | WABA por defecto (fallback cuando no se especifica uno). |
META_APP_ID | ID de la app de Meta que debe quedar suscrita a los webhooks de cada WABA. |
META_VERIFY_TOKEN | Token de verificación del webhook (GET /webhook/whatsapp, handshake inicial de Meta). |
WHATSAPP_PHONE_NUMBER_ID | Fallback 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_SECRET | Canje del code de Embedded Signup y verificación de la firma de los webhooks. |
META_WEBHOOK_SIGNATURE | enforce (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_SECRETS | Secretos 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_URL | Solo 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_VERSION | Versió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:
| # | Paso | Qué hace | Endpoint / lugar |
|---|---|---|---|
| 1 | Verificar (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 |
| 2 | Registrar en Cloud API | Activa 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) |
| 3 | Suscribir la app al WABA | Le 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 |
| 4 | Vincular en la base de datos | Conecta 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:
featureTypees 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.setupva vacío: nunca el portafolio de Xenpia. Enextras.setup,business.ides 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 enbuildEmbeddedSignupExtras(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 conGET /{waba}/phone_numbers, prefiriendo el que tieneis_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 yPOST /api/whatsapp/onboarding/select-numbertermina el onboarding con el elegido. - Sin
subscribed_appsno 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 sinoverride_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 enplanWabaSubscription(whatsapp-onboarding.ts). - En
CHANNEL_MODE=sandboxlos 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_datase pide al conectar. Si falla,POST /api/whatsapp/onboarding/syncrepite solo lo que falló, y pasadas 24 h marcaexpiredsin llamar a Meta: la única salida es que el cliente desconecte y vuelva a conectar. El error2593109es 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ón20261012000001).
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.
field | Qué hace | Dónde |
|---|---|---|
messages | Lo que escribe el cliente: guarda, invoca al bot y responde (§4) | whatsapp.webhook.controller.ts |
smb_message_echoes | Lo 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 pausa | whatsapp-coexistence.service.ts |
history | Hasta 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_sync | Agenda 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_update | PARTNER_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,
OutboxServicepasa cada ruta porplanDelivery(backend/src/channels/outreach/outreach-planner.ts). Mira el último mensaje entrante de la conversación (ContactReachService.lastInboundAt, índiceidx_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 conPOST /{waba}/message_templates. Los botones QUICK_REPLY llevanpayload = appt:confirm:<id>/appt:cancel:<id>(assembleTemplateComponents), y Meta lo devuelve en el webhook comomessages[].type = 'button'+button.payload.whatsapp-inbound.tsya lo traduce ainteractive.reply.id, así queapplyAppointmentReplyconfirma 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 detenants.country). Después de que Meta acepta, crea la identidad y la conversación con elwa_idque 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.
WhatsAppDispatcherdevuelve elwamid(providerMessageIds); se guarda enoutbox_queue.provider_message_id,messages.provider_message_idyscheduling_appointments.metadata.reminder.provider_message_id. - Webhook de estados.
whatsapp.webhook.controller.tsrecorre todas lasentry/changes(antes solo la primera) y pasavalue.statusesaDeliveryStatusService.applyUpdates:- el estado solo avanza (
sent<delivered<read), yfailedgana siempre; failedmarca la fila del outbox y el mensaje, y levanta una alerta encore_alerts(reminder_failed) que se ve en la campana del panel;delivered/readcierra sola la alerta abierta de esa cita;132015/132016marcan la plantilla como pausada/desactivada;- un
wamidque no salió por la cola (respuesta normal del bot) se ignora por ahora.
- el estado solo avanza (
- Traducción de errores:
backend/src/channels/outreach/whatsapp-error-codes.ts.
Diagnóstico de un recordatorio que no llegó, en orden:
core_remindersde la cita: ¿se programó conchannel_account_id? Sin él,RemindersServicelo descarta y alerta (no_channel: el tenant no tiene WhatsApp con bot).outbox_queue.last_error: por qué no salió o qué ruta se omitió.outbox_queue.delivery_status/provider_error_code: qué dijo Meta después.core_alertsabiertas 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:
| Capa | Dónde |
|---|---|
| UI | StepScenario 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 action | saveWhatsAppOAuthCredentials 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étodo | Path | Uso |
|---|---|---|
GET | /xenpia-wabas | Lista 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-numbers | Crea un número nuevo en un WABA (flujo xenpia_create). |
POST | /xenpia-numbers/request-otp | Pide el código OTP (SMS/voz). |
POST | /xenpia-numbers/verify-otp | Verifica 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/register | Registra el número en Cloud API (paso 2). |
POST | /xenpia-waba | Crea un WABA nuevo bajo el BM de Xenpia y lo suscribe automáticamente. |
POST | /onboarding/complete | Onboarding de un cliente por Embedded Signup: canje del code, suscripción, registro o sincronización de coexistencia (§3.b). |
POST | /onboarding/select-number | Termina el onboarding cuando la WABA tenía varios números. |
POST | /onboarding/sync | Reintenta 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:
-
¿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. -
¿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 ...) → mirarnext_step.verify: falta el paso 1 (ojo: si el número ya estáCONNECTEDy solo el código quedóEXPIRED, la lógica ya lo trata comodone— 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.
-
¿Está vinculado en la base de datos?
whatsapp_oauth_credentials.phone_number_id = <id>debe existir y apuntar a unchannel_account_idcon unbot_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. -
¿La app está suscrita al WABA?
GET /{waba_id}/subscribed_apps?access_token=...— sidataviene 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). -
¿Llegó algo a
messages? Si hay filasdirection = 'inbound'pero ningunaoutbound, el problema ya no es la conexión — es el procesamiento del bot (LangGraph, prompt, error enbotsService). 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:
- Comprobar que
META_APP_SECRETes el de la app que tiene suscrita la WABA (App Dashboard → Configuración → Básica). - Si los webhooks vienen de otra app (Instagram Login, app de pruebas), añadir su secreto a
META_WEBHOOK_EXTRA_SECRETS. - Como salida temporal,
META_WEBHOOK_SIGNATURE=loglos procesa y los sigue registrando. Volver aenforceen cuanto el log quede limpio.
8. Diagnóstico de la coexistencia
- El cliente no ve la opción de conectar su app. Falta
featureTypeenextras(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_statusysync_errorenwhatsapp_oauth_credentials.requestedsin avance = el campohistoryosmb_app_state_syncno 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_reasondice 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 elREVOKE/GRANTpor 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 cadaphone_number_idde 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-texty/api/messenger/send-textsiguen 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.