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
4.1 Los tres flujos comparten diálogo
type SignupMethod = 'embedded_signup' | 'coexistence' | 'migration'
embedded_signup— onboarding estándar de un número nuevo en la Cloud API.coexistence—featureType: '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, sinfeatureType. Lo que cambia es el final: Meta entrega la WABA destino pero deja el número sin registrar, así que hay que llamar a/registercon 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 negociohistory→ 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:
- Suscribe la WABA primero — una fila guardada sin suscripción de webhook es exactamente el estado roto que esta ruta arregla.
- 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 comosender_type: 'agent', no incrementanunread_county no disparan automatizaciones.history— el volcado del historial trassync_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í:
DELETE /api/whatsapp/configborraba todas las filas de la cuenta. Ahora borra una, resuelta por id.- 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. .maybeSingle()/.single()sin.limit(1)en cada consulta por cuenta (§6.5).- Conversaciones creadas sin entrada en la ruta de envío (§6.4).
- 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
- Nombrar las entradas "Ventas" y "Soporte".
- Automatización con keyword
holaen Ventas. Escribir "hola" a Soporte → no debe contestar. A Ventas → sí. - 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.
- Difundir con una plantilla de la WABA de Soporte: el paso 4 debe ofrecer solo números de esa WABA.
- 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.