Multi-número

Coexistencia (Embedded Signup) y bandejas por número

Cómo quedó integrado el onboarding por coexistencia de WhatsApp en este CRM y cómo se separó la bandeja en una entrada por número conectado.

Este documento explica por qué el código está como está. Está escrito contra el esquema real de este repositorio, no contra el de la implementación de referencia: aquí las filas se archivan por user_id, no por account_id, y no existen las tablas flows, ai_configs ni webhook_endpoints.

  • Stack: Next.js 16 (App Router) · React 19 · TypeScript · Supabase (RLS + cliente service-role para el webhook) · Tailwind v4
  • Graph API: v21.0
  • Migraciones que cubre: 021 → 024

1. Vocabulario

Término Qué es
WABA WhatsApp Business Account. Contenedor de Meta que agrupa números y plantillas.
Número / entrada / inbox Una fila de whatsapp_config. Un número conectado, con su propio token, sus automatizaciones y su bandeja.
Coexistencia El cliente sigue usando la app WhatsApp Business en su teléfono y el número queda conectado a la Cloud API.
Embedded Signup El diálogo de Meta que hace el onboarding dentro de tu web, vía Facebook Login for Business.
Workspace / organización profiles.organization_id. Es el user_id del dueño, y la clave bajo la que se archivan todas las filas compartidas.

2. Modelo de datos

2.1 whatsapp_config — una fila por número

Antes tenía UNIQUE(user_id): una cuenta, un número. Eso es lo primero que hay que romper.

Migración 021 (021_multi_phone_numbers.sql):

ALTER TABLE whatsapp_config DROP CONSTRAINT IF EXISTS whatsapp_config_user_id_key;

ALTER TABLE whatsapp_config
  ADD COLUMN IF NOT EXISTS label                TEXT,
  ADD COLUMN IF NOT EXISTS display_phone_number TEXT,
  ADD COLUMN IF NOT EXISTS verified_name        TEXT,
  ADD COLUMN IF NOT EXISTS is_default           BOOLEAN NOT NULL DEFAULT FALSE,
  ADD COLUMN IF NOT EXISTS connection_method    TEXT NOT NULL DEFAULT 'manual',
  ADD COLUMN IF NOT EXISTS onboarding_status    TEXT NOT NULL DEFAULT 'complete',
  ADD COLUMN IF NOT EXISTS coexistence_status   TEXT;

-- Un solo predeterminado por cuenta. Índice PARCIAL: varias filas con
-- is_default = FALSE conviven, dos con TRUE no.
CREATE UNIQUE INDEX whatsapp_config_one_default_per_account
  ON whatsapp_config (user_id) WHERE is_default;

ALTER TABLE conversations
  ADD COLUMN IF NOT EXISTS whatsapp_config_id UUID
    REFERENCES whatsapp_config(id) ON DELETE SET NULL;

ON DELETE SET NULL, nunca CASCADE: desconectar un número no puede borrar el historial de conversaciones del cliente.

Los CHECK van en ALTER TABLE ... ADD CONSTRAINT aparte, no dentro del ADD COLUMN IF NOT EXISTS: si la columna ya existía, el CHECK del ADD COLUMN no se aplica y la restricción se pierde en silencio.

phone_number_id no lleva UNIQUE. Es tentador —el webhook resuelve la cuenta por ese campo— pero dos miembros del mismo equipo pueden haber guardado hoy el mismo número por separado, y la migración fallaría al crear el índice. La 023 los fusiona; la exclusividad se aplica en el código: la ruta de descubrimiento se salta cualquier número que ya tenga cuenta, porque adoptarlo entregaría conversaciones ajenas.

2.2 El resto de las migraciones

# Archivo Qué hace
022 022_per_inbox_configuration.sql whatsapp_config_id en automations y broadcasts; waba_id en message_templates.
023 023_shared_workspace.sql RLS de organización en las tablas que faltaban y reasignación de las filas de los invitados.
024 024_own_rows_to_workspace.sql Trigger que archiva cada INSERT bajo el dueño del workspace.

Aplícalas en orden, 021 → 024, y despliega después. Una migración que falta produce el fallo más confuso de todos: el código escribe y filtra por una columna que no existe.

2.3 La semántica de NULL cambia por tabla

Esto es lo que más se presta a error. No es uniforme y no debe serlo:

Tabla whatsapp_config_id NULL significa Por qué
automations huérfana — no dispara nunca Independencia estricta entre entradas
broadcasts sale por el predeterminado La difusión elige remitente en el paso 4
conversations hilo anterior a la 021, se contesta por el predeterminado Solo compatibilidad
message_templates (waba_id) fila anterior a la 022 sin WABA que atribuirle Solo compatibilidad

La columna queda nullable en automations aunque la UI siempre obligue a elegir. Razón: ON DELETE SET NULL al desconectar un número conserva las automatizaciones y su historial en vez de borrarlas en cascada. La fila queda inerte y la interfaz la marca como "sin entrada asignada" para reasignarla.

Cada backfill asigna lo existente al número predeterminado, así que al desplegar nada cambia de comportamiento.

2.4 Sin índice único en conversations

La referencia crea un índice único por (cuenta, contacto, entrada). Aquí no, a propósito: este repo nunca tuvo uno, y las bases en producción tienen duplicados que harían fallar el CREATE UNIQUE INDEX a mitad del despliegue. En su lugar hay un índice normal para el plan de consulta, y el código conserva el patrón que ya se autocuraba: buscar la más antigua con .order('created_at').limit(1). Si algún día quieres el índice único, corre antes supabase/migrations/FUSIONAR_CONVERSACIONES_DUPLICADAS.sql.


3. El resolvedor central

Todo lo que necesite "¿qué número uso?" pasa por src/lib/whatsapp/resolve-config.ts:

resolveWhatsAppConfig(db, ownerId, configId?, columns = '*')
// id explícito (con .eq('user_id'), SIN fallback si no coincide)
//   → is_default
//   → el más antiguo

resolveConfigIdForContact(db, ownerId, contactId)
inboxBelongsToAccount(db, ownerId, configId): Promise<boolean>
listWhatsAppConfigs(db, ownerId, columns)
setDefaultWhatsAppConfig(db, ownerId, configId)
countConnectedInboxes(db, ownerId)
whatsAppNumberLabel(n)   // label → display_phone_number → verified_name → id

La ausencia de fallback es de seguridad, no un descuido. Si llega un configId que no pertenece a la cuenta, la función devuelve null en vez de caer al predeterminado. Un fallback silencioso convertiría un id ajeno en "usa el tuyo", que es justo lo que un atacante quiere. Toda ruta que acepte un whatsapp_config_id del cliente lo usa como guard y responde 400.

setDefaultWhatsAppConfig hace limpiar y luego marcar; el índice parcial de la 021 rechaza cualquier estado con dos predeterminados.

PUBLIC_COLUMNS es la lista que la UI puede ver. Nunca incluye access_token, verify_token ni webhook_headers; por eso el front lee los números por GET /api/whatsapp/numbers y no por Supabase directo.


4. Embedded Signup: coexistencia

type SignupMethod = 'embedded_signup' | 'coexistence' | 'migration'
  • embedded_signup — onboarding estándar de un número nuevo en la Cloud API.
  • coexistencefeatureType: 'whatsapp_business_app_onboarding'. Sustituye la pantalla de selección de WABA por el emparejamiento con la app WhatsApp Business del cliente.
  • migration — NO es un flujo propio de Meta. Mover un número desde otro Solution Partner corre el diálogo estándar, sin featureType. Lo que cambia es el final: Meta entrega la WABA destino pero deja el número sin registrar, así que hay que llamar a /register con el PIN de verificación en dos pasos. Saltarse ese paso deja una conexión que parece sana y no recibe nada.

Se guarda como connection_method = 'embedded_signup', porque literalmente es ese diálogo el que corre y porque el CHECK solo acepta esos tres valores.

4.2 extras y la versión — el detalle que más tiempo cuesta

src/lib/whatsapp/signup-extras.ts:

if (method === 'coexistence') {
  return { setup: {}, featureType: 'whatsapp_business_app_onboarding' }
}
return { setup: {}, sessionInfoVersion: '3', version: 'v2' }

Por qué coexistencia manda solo featureType y nada más:

Meta fija la versión del flujo en la configuración de Login, no en la llamada. Una configuración creada desde el paso "Products" del asistente es v4, y para v4 la documentación dice que el objeto extras está "purposely empty" — cualquier cosa que mandes ahí se ignora. Las configuraciones v2/v3 sí leen extras. Rellenar extras con campos de v2 en una configuración v4 no rompe nada, pero tampoco activa nada; el síntoma es un botón que abre el diálogo estándar y nunca el de coexistencia.

Se deja una escotilla: META_ES_VERSION fuerza v2/v3 sin recompilar.

Coexistencia no necesita una configuración de Login propia. Es un featureType en la llamada, no un producto aparte. El código cae a la configuración estándar; sin ese fallback el botón queda deshabilitado en silencio.

4.3 El evento postMessage

El punto más silencioso de todo el flujo. Un evento cuyo origen no reconoces se descarta sin dejar rastro, el llamador agota su timeout, y al usuario se le dice que Meta "no devolvió nada" — mientras Meta da el onboarding por bueno y ya suscribió la WABA.

src/lib/whatsapp/signup-events.ts:

const META_SIGNUP_ORIGINS = new Set([
  'https://facebook.com',
  'https://www.facebook.com',
  'https://business.facebook.com',   // ← sesiones de Business Suite
  'https://web.facebook.com',        // ← algunas regiones
]);

Lista de coincidencia exacta, nunca endsWith('facebook.com'): eso también aceptaría evil-facebook.com y dejaría que cualquier página inyectara un waba_id falso en el handler.

Solo se exige waba_id. El evento de coexistencia (FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING) no trae phone_number_id: el número es el que el negocio ya usa en la app, y Meta no le hace elegirlo. Exigir phone_number_id deja el flujo colgado sin error visible. Por eso la comprobación es por prefijo FINISH.

En el cliente el log se deja puesto — es donde el flujo se muere sin ruido:

console.info('[embedded-signup] evento de Meta:', event.origin, event.data);

4.4 Resolver el número desde la WABA

Como coexistencia no manda phone_number_id, el servidor lo deduce (embedded-signup.ts): si la WABA tiene exactamente un número, ese; con cero o con varios, error. Adivinar entre varios cablearía la cuenta al número equivocado.

4.5 NO llamar a /register en coexistencia

if (pin && method !== 'coexistence') {
  await registerPhoneNumber({ phoneNumberId, accessToken, pin })
}

Un número de coexistencia ya está registrado — está vivo en la app WhatsApp Business. Llamar a /register falla con el error #2655122 ("already registered"), que es justo lo que el flujo existe para evitar.

Migración BSP→BSP es lo contrario: exige el PIN y falla el request con code: 'pin_required' si no lo trae. Guardar una fila que dice "conectado" y no recibe nada es peor que rechazar el intento.

4.6 smb_app_data — una sola oportunidad, 24 horas

POST /{phone_number_id}/smb_app_data
{ messaging_product: 'whatsapp', sync_type: 'smb_app_state_sync' | 'history' }
  • smb_app_state_sync → los contactos de WhatsApp del negocio
  • history → el historial de chats

Se puede pedir una sola vez por onboarding y dentro de las 24 h. Pasado eso, el cliente tiene que desconectar y rehacer el flujo entero. Por eso se dispara inmediatamente después de guardar la fila, que es el único momento en que se sabe con certeza que estamos en ventana.

Los fallos se reportan, nunca se lanzan: la conexión ya está guardada y funcionando, y perderla por un error de sincronización sería mucho peor que un historial sin importar.

4.7 Claves guardadas y su cifrado

access_token y verify_token se guardan cifrados con AES-256-GCM (encryption.ts), formato iv:ciphertext:authTag. Hay un camino de solo-lectura para el formato CBC antiguo (iv:ciphertext) que se actualiza en sitio al leerlo.

GCM y no CBC porque CBC sin MAC no está autenticado: quien pueda escribir filas en whatsapp_config puede voltear bits del ciphertext sin que el descifrado falle.

ENCRYPTION_KEY debe ser la misma con la que se cifró cualquier fila insertada fuera de banda. Un token cifrado con otra clave produce una fila que parece perfecta y falla en cada envío.


5. Descubrir números que Meta concedió pero nunca se guardaron

POST /api/whatsapp/numbers — el botón "Buscar números en Meta" de Configuración → WhatsApp.

El detalle clave: coexistencia crea una WABA cliente del negocio, no una propia. Listar /{waba conectada}/phone_numbers no la encuentra nunca, porque el número vive bajo otra WABA. Hay que subir al negocio y bajar por las dos aristas:

const waba = await get(`${wabaId}?fields=owner_business_info`)
const businessId = waba?.owner_business_info?.id
for (const edge of ['owned_whatsapp_business_accounts',
                    'client_whatsapp_business_accounts']) { … }

En vez de perseguir cada forma en que el handshake de tres patas (popup → postMessage → POST) puede romperse, se reconcilia contra Meta: lo que el token guardado alcanza a ver es lo que la cuenta posee. Los errores de una arista o de una WABA concreta se tragan — un token que no puede leer un activo debe seguir mostrando el resto.

Por cada número que falta:

  1. Suscribe la WABA primero — una fila guardada sin suscripción de webhook es exactamente el estado roto que esta ruta arregla.
  2. Inserta la fila reutilizando el token del número que lo descubrió (mismo negocio, mismo token de usuario de sistema, que no caduca).

Con dos guards: un número que ya tiene otra cuenta se salta, y los que ya existen también. Es idempotente: re-suscribir una WABA es no-op en Meta.


6. Webhook: de phone_number_id a bandeja

src/app/api/whatsapp/webhook/route.ts.

6.1 Resolución

El webhook resuelve la cuenta por phone_number_id. Un phone_number_id que no esté en whatsapp_config se descarta con No config found for phone_number_id — que es el mensaje a buscar en los logs cuando "no llegan los mensajes".

config.id se hilvana por processMessage y findOrCreateConversation, y de ahí a las automatizaciones.

6.2 Campos que hay que suscribir en la app de Meta

whatsapp_business_account → messages, history, smb_app_state_sync, smb_message_echoes
  • smb_message_echoes — mensajes que el negocio envía desde su teléfono; entran como sender_type: 'agent', no incrementan unread_count y no disparan automatizaciones.
  • history — el volcado del historial tras sync_type: 'history'.
  • smb_app_state_sync — contactos de la agenda del negocio.

En smb_app_state_sync solo se procesa action === 'add'. Los demás se ignoran a propósito: que el cliente borre un contacto de su teléfono no puede borrar el historial del CRM.

6.3 Una conversación por (cuenta, contacto, canal, entrada)

Con un número la conversación era única por (cuenta, contacto). Correcto entonces, incorrecto desde la 021.

Lo que producía la forma antigua: el mensaje del segundo número se metía en el hilo del primero, y el webhook re-estampaba whatsapp_config_id al número que hubiera escrito de último. El hilo cambiaba de entrada en cada mensaje, las respuestas salían por el número equivocado, y el filtro por entrada no podía separarlos.

Síntoma que reporta el usuario: "escribo a un número y llega; escribo al otro y solo aparece el último, el anterior desaparece". No desaparece: el hilo entero se mudó de bandeja.

Lo retroactivo no se puede arreglar. Los hilos ya fusionados tienen mensajes de ambos números en una fila y los mensajes no guardan por qué número entraron. La separación empieza desde el siguiente mensaje.

6.4 El mismo fallo vivía en la ruta de envío

Al arreglar el webhook hay que arreglar todos los caminos que crean o resuelven conversaciones, no solo el que reporta el ticket. api/whatsapp/send/route.ts hacía .single() sobre whatsapp_config por cuenta; con dos números da error, se leía como "no configurado", y la bandeja decía que WhatsApp no estaba conectado. Ahora resuelve la entrada del propio hilo con resolveWhatsAppConfig, así la respuesta sale por el número al que escribieron.

6.5 .maybeSingle() es una trampa en todo el multinúmero

.maybeSingle() falla con ≥2 filas, y casi todo el código lo trataba como "no encontrado". Cada sitio que consulte whatsapp_config o conversations por cuenta necesita .limit(1) con un .order() determinista, o un count.

Caso real: inbox/page.tsx comprobaba la conexión con .maybeSingle(). Al conectar el segundo número eso empezó a dar error, data quedaba null, y la bandeja mostraba "WhatsApp no está conectado" con los dos números sanos. La forma correcta es contar:

const { count } = await supabase
  .from('whatsapp_config')
  .select('id', { count: 'exact', head: true })
  .eq('status', 'connected')
setWhatsappConnected((count ?? 0) > 0)

Ojo también con la diferencia de forma: .maybeSingle() devuelve un objeto, .limit(1) devuelve un array.


7. Configuración independiente por entrada

Motor Archivo Filtro
Automatizaciones lib/automations/engine.ts .eq('whatsapp_config_id', inboxId)
Plantillas (bandeja) components/inbox/template-modal.tsx por la WABA de la conversación
Difusiones api/whatsapp/broadcast/route.ts whatsapp_config_id del cuerpo, o el predeterminado

Los call sites sin conversación a mano

runAutomationsForTrigger también se llama desde el disparador conversation_assigned y desde api/automations/engine/route.ts. Ahí no siempre hay whatsapp_config_id, y pasar null dejaría esos disparadores sin funcionar nunca. Cadena de tres pasos:

const inboxId =
  input.whatsappConfigId ??
  (input.contactId ? await resolveConfigIdForContact(db, input.userId, input.contactId) : null) ??
  (await resolveWhatsAppConfig(db, input.userId, null, 'id'))?.id ?? null

Si inboxId sigue siendo null, la cuenta no tiene ningún número conectado — un workspace solo de Instagram/Messenger. Ahí el filtro se omite en vez de silenciar todas las automatizaciones. Con al menos una entrada, el filtro es estricto y una fila con whatsapp_config_id NULL no dispara.

Validación

Activar una automatización sin entrada asignada queda bloqueado en lib/automations/validate.ts (validateInboxForActivation): una automatización activa sin entrada no dispararía nunca, y eso hay que decirlo al guardar, no dejar que el usuario lo descubra en producción.


8. Plantillas por WABA

Meta aprueba las plantillas por WABA. Una plantilla aprobada en la WABA A sencillamente no existe en la B. Como coexistencia pone el número nuevo en una WABA cliente propia, enviar por ahí falla con "template name does not exist".

Migración 022:

ALTER TABLE message_templates ADD COLUMN IF NOT EXISTS waba_id TEXT;
-- backfill a la WABA del número predeterminado

CREATE UNIQUE INDEX message_templates_user_name_language_waba_key
  ON message_templates (user_id, name, language, waba_id);

Columnas planas y no una expresión con COALESCE, porque PostgREST solo sabe nombrar columnas reales en onConflict. La migración deduplica antes de crear el índice: sin unicidad previa, una cuenta puede tener ya dos filas con el mismo nombre e idioma y el CREATE UNIQUE INDEX fallaría; se conserva la más antigua y las plantillas se reconstruyen con un sync.

  • Sync recorre todas las WABAs de la cuenta, una vez por WABA (dos números en la misma WABA traerían el catálogo idéntico dos veces).
  • Submit acepta whatsapp_config_id; sin él, el predeterminado.
  • Selector de la bandeja filtra por la WABA de la conversación.
  • Difusiones: la plantilla se elige antes que el remitente, así que el paso 4 ofrece solo números de la WABA de la plantilla y fija el único válido cuando el predeterminado no sirve. Sin eso el envío salía por el número equivocado y fallaba en todos los destinatarios.

9. Interfaz

  • Submenú en el sidebar, bajo "Inbox": una fila por entrada, más "Todas las entradas". Es lo que hace evidente que hay entradas separadas; un desplegable perdido entre otros filtros no basta. Se oculta con un solo número, donde no hay nada que elegir.
  • El filtro vive en la URL (/inbox?inbox=<id>), no en estado local. Si no, el submenú y la lista quedan desincronizados. Además el enlace es compartible.
  • Nombre de la entrada en la conversación abierta, junto al teléfono del contacto: +51 9xx xxx xxx · Ventas. Sin eso no hay forma de saber a qué número te escribieron, y la respuesta sale justo por ese.
  • En la vista "todas", cada fila del listado lleva el nombre de su entrada.
  • Automatizaciones — selector de entrada en la TriggerCard, junto al tipo de disparador: es parte de cuándo corre, no un ajuste de otro sitio.
  • Difusiones — selector "Enviar desde" en el paso 4, acotado a la WABA de la plantilla.
  • Configuración → Números conectados — renombrar en línea (una PATCH por renombrado, al salir del campo o con Enter), promover el predeterminado, desconectar uno solo y los tres botones de onboarding.

El hook compartido

useWhatsAppNumbers() se extrajo antes de añadir el tercer selector: el mismo useEffect + fetch + cadena de fallback del nombre ya estaba copiado dos veces.

const { numbers, loading, refresh, hasMultiple, defaultId, wabaOf, labelOf } =
  useWhatsAppNumbers()

Se lee por /api/whatsapp/numbers y no por Supabase directo, porque whatsapp_config guarda tokens cifrados y esa ruta devuelve solo las columnas presentables.


10. Bugs latentes que aparecen al pasar a multinúmero

Todos reales, todos corregidos aquí:

  1. DELETE /api/whatsapp/config borraba todas las filas de la cuenta. Ahora borra una, resuelta por id.
  2. El guardado de webhook hacía .update(...).eq('user_id'): cambiaba todos los números a la vez y, como la tarjeta solo muestra uno, era invisible.
  3. .maybeSingle() / .single() sin .limit(1) en cada consulta por cuenta (§6.5).
  4. Conversaciones creadas sin entrada en la ruta de envío (§6.4).
  5. Desuscribir una WABA cortaría los webhooks de los números hermanos que la comparten — por eso desconectar una entrada no desuscribe nada.

Regla general: buscar todos los sitios que asumen "una fila por cuenta". grep de from('whatsapp_config') y from('conversations') con .single()/.maybeSingle() sin .limit(1).


11. Variables de entorno

Ver environment-variables.md y .env.local.example.

META_APP_SECRET
ENCRYPTION_KEY                   # 32 bytes en hex (64 caracteres)

META_APP_ID                      # id numérico de la app
META_EMBEDDED_SIGNUP_CONFIG_ID   # id de la configuración de Login
META_COEXISTENCE_CONFIG_ID       # opcional; cae a la anterior
META_ES_VERSION                  # opcional: 'v2' | 'v3'

Los cuatro ids de Embedded Signup se leen en runtime: el navegador los pide a GET /api/whatsapp/numbers. Son identificadores públicos — acaban en el navegador de todas formas — y servirlos desde el servidor evita la trampa de la versión anterior, que los inlinaba al compilar: el mismo valor había que declararlo dos veces, META_APP_ID para el servidor y NEXT_PUBLIC_META_APP_ID para el bundle, y poner solo el obvio dejaba los botones deshabilitados sin explicar por qué. Las variantes NEXT_PUBLIC_* se siguen aceptando como fallback.

Docker: los NEXT_PUBLIC_* se inlinan al compilar

Next inlina los NEXT_PUBLIC_* en tiempo de compilación, y esos sí hay que hornearlos: los de Supabase y NEXT_PUBLIC_SITE_URL. Esta imagen compila con marcadores (__NEXT_PUBLIC_SUPABASE_URL__) y los sustituye al arrancar en entrypoint.sh, sobre los .js, los .html, los .rsc (los metadatos canónicos y OG de cada página prerenderizada) y los .body (el sitemap.xml y el robots.txt, que salían anunciando el literal del marcador).

Los de Embedded Signup no pasan por ahí, y por eso no hay que reconstruir la imagen para cambiarlos.

Los secretos de servidor (SUPABASE_SERVICE_ROLE_KEY, ENCRYPTION_KEY, META_APP_SECRET) se leen en runtime y no se hornean.


12. Verificación

npm run typecheck && npm run check:inbox && npm run lint

check:inbox (scripts/check-multi-inbox.mts) cubre lo que no se ve en un type-check y falla en silencio en producción: la allowlist de orígenes, la clasificación del evento de coexistencia sin phone_number_id, y que extras de coexistencia lleve solo featureType. Corre con el stripper de tipos de Node, sin dependencias nuevas.

Prueba manual, con dos entradas conectadas

  1. Nombrar las entradas "Ventas" y "Soporte".
  2. Automatización con keyword hola en Ventas. Escribir "hola" a Soporte → no debe contestar. A Ventas → sí.
  3. Escribir al número A y al número B desde el mismo teléfono → dos hilos distintos, cada uno con su etiqueta de entrada.
  4. Difundir con una plantilla de la WABA de Soporte: el paso 4 debe ofrecer solo números de esa WABA.
  5. Desconectar una entrada: sus automatizaciones siguen existiendo, marcadas como "sin entrada asignada", y las conversaciones y mensajes no se borran.

13. Diagnóstico rápido

Síntoma Causa probable
El botón de coexistencia no hace nada Falta META_EMBEDDED_SIGNUP_CONFIG_ID, o META_APP_ID
El popup abre el flujo estándar, no coexistencia Configuración de Login v4 ignorando extras; mandar solo featureType, o forzar META_ES_VERSION=v2
El popup termina y la página no se entera Origen de postMessage fuera de la allowlist (business.facebook.com)
"Meta no devolvió los activos" Se exigió phone_number_id, que coexistencia no manda
Error #2655122 al conectar Se llamó a /register en un número de coexistencia
No llegan mensajes de un número No hay fila en whatsapp_config, o la WABA no está suscrita. Buscar No config found for phone_number_id en los logs y comprobar GET /{waba}/subscribed_apps
El número existe en Meta pero no en el CRM WABA cliente; usar "Buscar números en Meta"
"WhatsApp no está conectado" con números sanos .maybeSingle() fallando con ≥2 filas
Los dos números caen en un solo chat Falta la 021, o el código sigue re-estampando whatsapp_config_id
"template name does not exist" al enviar Plantilla de otra WABA; falta la 022 o el filtro del selector
Una automatización activa no dispara nunca whatsapp_config_id NULL — quedó huérfana al desconectar su número

Para la parte de equipo e invitaciones, ver workspace-compartido.md.