Saltar al contenido principal

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:

EstadoQué significa
Entrega a TestLa WABA tiene el override hacia WHATSAPP_ONBOARDING_WEBHOOK_OVERRIDE_URL de este backend
Entrega a producciónSuscrita sin override: los mensajes van a la URL de la app (api.xenpia.com)
Entrega a otra URLOverride hacia una URL que no es la de este Test. Revísalo antes de tocar nada
Sin suscripciónLa app XENPIA no está suscrita a la WABA: no llega nada a ningún lado
  • Liberar para producción quita el override (DELETE + POST sin 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 sin override_callback_uri borraría el override. El backend lo evita: si la app ya está suscrita a la WABA, no vuelve a suscribirla (ver docs/interno/canales/whatsapp.md §3.b). Para que las WABA nuevas conectadas desde Test entreguen a Test, define WHATSAPP_ONBOARDING_WEBHOOK_OVERRIDE_URL en el backend de Test. Con CHANNEL_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):

  1. 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í.
  2. Modo canal — el backend solo usa el pack si CHANNEL_MODE=sandbox.
  3. Enrutado sandbox — casa phone_number_id contra sandbox_channel_pack (status=active) y exige un lease de un bot. Sin lease: "pack matched but no lease — message dropped".
  4. Envío de salida — usa el access_token del pack. Necesita permiso whatsapp_business_messaging sobre ese número, o devuelve (#131005).

Valores de referencia (DEV, a 2026-09-05)​

CosaValor
App de MetaXENPIA — App ID 2313638885771550
WABA de prueba actual4072346909762147
Phone number id actual899575756580187
Callback DEV (override)https://apitest.xenpia.com/api/webhook/whatsapp
Callback PROD (app-level)https://api.xenpia.com/api/webhook/whatsapp
Verify tokenvalor 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 DEVhttps://test.xenpia.com
Graph APIv24.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_token cambió en el backend DEV, usa el valor real de META_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:

LogSignificado / 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 droppedfalta asignar el canal a un bot
Bot reply ready ... actions=1 + (#131005) Access deniedtoken del pack sin whatsapp_business_messaging o número no propio
Dispatched 1/1 action(s)✅ funciona de punta a punta

Gotchas​

  • #131005 Access denied al enviar = problema de token/propiedad, no de código.
    1. 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.
  • 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 messages del 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_token en 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.

  1. 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 lee EmailDispatcher. Un pack guardado antes con from_address se sigue aceptando como remitente.
  2. Edición del bot → tarjeta Canales de prueba → Email → Asignar. Crea la cuenta [sandbox] email del tenant, que es la que RecipientRoutingService encuentra para el contacto destino.
  3. 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.