Canal de prueba WhatsApp (sandbox) — runbook de override y reversión
Cómo enrutar un número de prueba de WhatsApp al backend DEV sin tocar producción, y cómo revertirlo cuando se cambie a otro número.
Escenario: una sola app de Meta (XENPIA) sirve prod y test. No se puede tener dos Callback URL a nivel de app, así que el número de prueba se redirige a DEV con un override de webhook por WABA (
subscribed_apps.override_callback_uri). El resto de WABAs siguen yendo a la callback de la app (prod). Es reemplazo, no suma: esa WABA deja de ir a prod y va a DEV.
Desde el panel (lo normal)
Admin → Canales de prueba → WhatsApp (backend de Test, superadmin) muestra a dónde entrega
Meta hoy el número y lo mueve sin curl:
| Estado | Qué significa |
|---|---|
| Entrega a Test | La WABA tiene el override hacia WHATSAPP_ONBOARDING_WEBHOOK_OVERRIDE_URL de este backend |
| Entrega a producción | Suscrita sin override: los mensajes van a la URL de la app (api.xenpia.com) |
| Entrega a otra URL | Override hacia una URL que no es la de este Test. Revísalo antes de tocar nada |
| Sin suscripción | La app XENPIA no está suscrita a la WABA: no llega nada a ningún lado |
- Liberar para producción quita el override (
DELETE+POSTsin override en/{waba}/subscribed_apps). Después conecta el número al bot de producción desde su panel (Número de Xenpia (portafolio)). Ese paso por sí solo no toca la suscripción: si la app ya está suscrita, la conserva, así que sin liberar antes el número seguiría entregando a Test. - Devolver a Test vuelve a poner el override con
META_VERIFY_TOKEN. Un bot de producción que use el número se queda sin mensajes. - Al guardar el canal con otro estado, el panel pregunta si mover también el webhook: desactivar no lo mueve salvo que marques la casilla (liberar para producción es una decisión explícita); activar lo propone marcado, porque sin override el sandbox no recibe nada.
Usa el waba_id y el access_token del pack: ese token necesita whatsapp_business_management
sobre la WABA. Tras cada cambio el backend relee la suscripción en Meta y falla si no quedó como
se pidió. Endpoints: GET y PUT /api/admin/sandbox-channels/whatsapp/webhook
({ "destino": "test" | "produccion" }). El cliente solo elige el destino; la URL de Test sale
del entorno del backend.
Los apartados de abajo quedan para diagnosticar o para hacerlo a mano.
Onboarding de clientes (Embedded Signup) y este override. Conectar una WABA desde el panel hace
POST /{waba}/subscribed_apps, que sinoverride_callback_uriborraría el override. El backend lo evita: si la app ya está suscrita a la WABA, no vuelve a suscribirla (verdocs/interno/canales/whatsapp.md§3.b). Para que las WABA nuevas conectadas desde Test entreguen a Test, defineWHATSAPP_ONBOARDING_WEBHOOK_OVERRIDE_URLen el backend de Test. ConCHANNEL_MODE=sandbox, el onboarding está bloqueado (403).
Arquitectura en 4 gates (para diagnosticar)
Cada mensaje entrante pasa por, en orden
(backend/src/channels/meta/whatsapp/whatsapp.webhook.controller.ts):
- Entrega del webhook — Meta manda el evento a la Callback URL. Con override,
la WABA de prueba entrega a
apitest. Si no llega nada al log, falla aquí. - Modo canal — el backend solo usa el pack si
CHANNEL_MODE=sandbox. - Enrutado sandbox — casa
phone_number_idcontrasandbox_channel_pack(status=active) y exige un lease de un bot. Sin lease: "pack matched but no lease — message dropped". - Envío de salida — usa el
access_tokendel pack. Necesita permisowhatsapp_business_messagingsobre ese número, o devuelve(#131005).
Valores de referencia (DEV, a 2026-09-05)
| Cosa | Valor |
|---|---|
| App de Meta | XENPIA — App ID 2313638885771550 |
| WABA de prueba actual | 4072346909762147 |
| Phone number id actual | 899575756580187 |
| Callback DEV (override) | https://apitest.xenpia.com/api/webhook/whatsapp |
| Callback PROD (app-level) | https://api.xenpia.com/api/webhook/whatsapp |
| Verify token | valor de META_VERIFY_TOKEN en el backend DEV (actualmente whatsapp_verify_9f3k2a) |
| Backend DEV (contenedor) | xenpia-chatbot-backend-1 (Docker en el servidor Debian) |
| Frontend DEV | https://test.xenpia.com |
| Graph API | v24.0 |
| Pack (BD) | tabla public.sandbox_channel_pack, fila channel_code='whatsapp' |
⚠️ Había dos apps suscritas a esta WABA: XENPIA (con override) y "Business Agent" (
1143680903703001, sin override). Una WABA entrega a todas las apps suscritas, así que "Business Agent" también recibe estos mensajes. Si no debe, quítala (ver más abajo).
Cómo obtener un token para las llamadas al Graph API (con whatsapp_business_management
para subscribed_apps y whatsapp_business_messaging para enviar): Business
Settings → Usuarios del sistema → (usuario) → Generar token, app XENPIA,
marcando ambos permisos, y con la WABA correspondiente en sus activos
asignados (control total). Ideal: token sin caducidad para un pack estable.
A) Revertir el número actual (4072346909762147 / 899575756580187)
Objetivo: que DEV deje de recibir/responder por este número.
# token con whatsapp_business_management sobre la WABA a revertir
export WABA_TOKEN='<TOKEN_SYSTEM_USER>'
# 1. Ver el estado de suscripción actual
curl -s "https://graph.facebook.com/v24.0/4072346909762147/subscribed_apps" \
-H "Authorization: Bearer $WABA_TOKEN"; echo
# 2a. Quitar la suscripción de XENPIA a esta WABA (elimina el override).
# Tras esto, esta WABA ya NO entrega a XENPIA (ni a DEV ni a prod por XENPIA).
curl -s -X DELETE "https://graph.facebook.com/v24.0/4072346909762147/subscribed_apps" \
-H "Authorization: Bearer $WABA_TOKEN"; echo
# 2b. (Opcional) Si quieres que esta WABA vuelva a ir a PROD por XENPIA,
# re-suscribe SIN override (usa la callback a nivel de app = prod):
# curl -s -X POST "https://graph.facebook.com/v24.0/4072346909762147/subscribed_apps" \
# -H "Authorization: Bearer $WABA_TOKEN"; echo
En la BD de DEV, desactiva el número en el pack y suelta el lease para que DEV no lo trate como sandbox:
-- desactivar el canal whatsapp del pack
update public.sandbox_channel_pack
set status = 'inactive', updated_at = now()
where channel_code = 'whatsapp';
-- soltar el lease del canal (si lo hubiera)
delete from public.sandbox_channel_leases
where channel_code = 'whatsapp';
Alternativa por UI: Admin → Canales de prueba → WhatsApp → Estado = Inactivo → Guardar; y en la tarjeta del bot, Liberar el lease.
Quitar la otra app suscrita ("Business Agent"), si aplica:
# necesita un token con permiso sobre ESA app / WABA
curl -s -X DELETE "https://graph.facebook.com/v24.0/4072346909762147/subscribed_apps?subscribed_app_id=1143680903703001" \
-H "Authorization: Bearer <TOKEN>"; echo
B) Dar de alta un número de prueba NUEVO
Sustituye NUEVA_WABA y NUEVO_PHONE_ID por los del número nuevo.
1. Token del system user para el número nuevo
- La NUEVA_WABA tiene que estar en los activos asignados del system user (control total).
- Genera token (app XENPIA) con
whatsapp_business_messaging+whatsapp_business_management. - Verifica que ese token puede enviar (ventana de 24h abierta, o usa una plantilla):
export NEW_TOKEN='<TOKEN_NUEVO>'
curl -s -X POST "https://graph.facebook.com/v24.0/NUEVO_PHONE_ID/messages" \
-H "Authorization: Bearer $NEW_TOKEN" -H "Content-Type: application/json" \
-d '{"messaging_product":"whatsapp","to":"<TU_MOVIL>","type":"text","text":{"body":"prueba token nuevo"}}'; echo
# Esperado: {"messages":[...]} (no (#131005))
2. Override del webhook de la nueva WABA hacia DEV
curl -s -X POST "https://graph.facebook.com/v24.0/NUEVA_WABA/subscribed_apps" \
-H "Authorization: Bearer $NEW_TOKEN" \
-d "override_callback_uri=https://apitest.xenpia.com/api/webhook/whatsapp" \
-d "verify_token=whatsapp_verify_9f3k2a"; echo
# Esperado: {"success":true} (Meta hace un handshake GET contra apitest)
# verificar
curl -s "https://graph.facebook.com/v24.0/NUEVA_WABA/subscribed_apps" \
-H "Authorization: Bearer $NEW_TOKEN"; echo
Si
verify_tokencambió en el backend DEV, usa el valor real deMETA_VERIFY_TOKEN:docker exec xenpia-chatbot-backend-1 printenv | grep META_VERIFY
3. Actualizar el pack con las credenciales nuevas (Admin → Canales de prueba → WhatsApp; deja en blanco lo que no cambie, los vacíos conservan el valor previo), o por SQL:
update public.sandbox_channel_pack
set status = 'active',
credentials = credentials
|| jsonb_build_object(
'access_token', '<TOKEN_NUEVO>',
'phone_number_id', 'NUEVO_PHONE_ID',
'waba_id', 'NUEVA_WABA'
),
updated_at = now()
where channel_code = 'whatsapp';
4. Asignar el canal a un bot (toma el lease + crea el channel account sandbox): frontend DEV → edición del bot → tarjeta Canales de prueba → WhatsApp → Asignar.
C) Verificación de punta a punta
# 1. El endpoint responde (handshake) — debe imprimir 12345
curl -s "https://apitest.xenpia.com/api/webhook/whatsapp?hub.mode=subscribe&hub.verify_token=whatsapp_verify_9f3k2a&hub.challenge=12345"; echo
# 2. Logs en vivo mientras mandas un WhatsApp al número de prueba
docker logs -f xenpia-chatbot-backend-1 2>&1 \
| grep -iE "whatsapp|sandbox|phone_number_id|no active bot|returned 0 actions|dispatched|131005"
Lectura del log:
| Log | Significado / acción |
|---|---|
| (nada) | El webhook no llega → override/suscripción de la WABA, o campo messages sin marcar |
...Complete the WhatsApp wizard... | CHANNEL_MODE no es sandbox en el backend |
Sandbox WhatsApp: no pack/lease for phone_number_id=... | pack inactivo o phone_number_id no coincide |
pack matched but no lease — message dropped | falta asignar el canal a un bot |
Bot reply ready ... actions=1 + (#131005) Access denied | token del pack sin whatsapp_business_messaging o número no propio |
Dispatched 1/1 action(s) | ✅ funciona de punta a punta |
Gotchas
#131005 Access deniedal enviar = problema de token/propiedad, no de código.- el token del pack necesita
whatsapp_business_messaging; 2) si aun con messaging falla, el número puede estar bajo otra app/BSP ("Business Agent") y no es "tuyo" para enviar.
- el token del pack necesita
- Una sola Callback URL por app. La separación DEV/prod es por WABA vía override, no por app. No confundir con la callback a nivel de app (esa es prod).
- Campo
messagesdel webhook debe estar suscrito a nivel de app (una vez), o no llega ningún mensaje aunque el override esté bien. - Caducidad del token. Si el token del system user caduca (60 días), el envío
empieza a dar
#131005/190. Usar token sin caducidad para el pack. - Multi-app en la WABA. Revisa
subscribed_apps: si hay más de una app suscrita, todas reciben los mensajes. Quita las que no correspondan. - Comandos curl en una sola línea al pegarlos por SSH; las continuaciones con
\se rompen con las líneas en blanco del pegado. - Secretos. No pegar
access_tokenen chats/tickets. Si se expone, revocarlo y regenerar. Preferir la UI de admin (no deja el secreto en logs de SQL).
Email sandbox (avisos del flujo)
En CHANNEL_MODE=sandbox todo envío que no sea web pasa por el pack y exige un lease
del canal (ChannelDispatcherRegistry). Eso incluye el nodo Enviar mensaje de un
flujo cuando manda un aviso por correo: sin lease de email falla con
"sandbox: canal inactivo o sin lease para este bot".
Los leases son por canal: un bot puede tener a la vez el de WhatsApp y el de email.
- Admin → Canales de prueba → Email:
smtp_host,smtp_port(587 si se deja vacío),smtp_user,smtp_password,smtp_from_email; Estado = Activo → Guardar. Son los mismos nombres que leeEmailDispatcher. Un pack guardado antes confrom_addressse sigue aceptando como remitente. - Edición del bot → tarjeta Canales de prueba → Email → Asignar. Crea la cuenta
[sandbox] emaildel tenant, que es la queRecipientRoutingServiceencuentra para el contacto destino. - El contacto destino necesita un email en su ficha (o una identidad de canal email).
El asunto que define el nodo se conserva: el registro reemplaza las credenciales por las
del pack, pero mantiene subject, que es un dato del mensaje y no una credencial.