Saltar al contenido principal
En la WhatsApp Cloud API (@botmesh/meta) un negocio solo puede enviar mensajes libres (texto, media, botones…) dentro de las 24 horas siguientes al último mensaje del usuario: la ventana de atención al cliente. Fuera de ella solo pasan las plantillas aprobadas; Meta rechaza todo lo demás con el error de Graph 131047, que el provider traduce a OutsideSessionWindowError. @botmesh/meta incluye dos ayudas opcionales para esto:
  • sessionWindow: sigue la ventana de cada chat, reabre los chats cerrados con una plantilla y reintenta el mensaje original cuando el usuario responde.
  • usage: cuenta la información de facturación que Meta adjunta a los webhooks de estado, por chat y por categoría de precio.
Ambas existen solo en @botmesh/meta. Otros providers con una ventana parecida (error 63016 de Twilio, Gupshup, TikTok) rechazan con OutsideSessionWindowError, pero no tienen gestor.

Sin el gestor

Por defecto no se sigue nada. Un envío libre fuera de la ventana rechaza y tú decides qué hacer:
Las plantillas siempre se permiten: son la forma de volver a abrir la ventana.

Activar el gestor

Con sessionWindow, provider.send() (y por tanto ctx.reply, bot.send…) pasa por un SessionWindowManager:
  1. Cada mensaje o interacción entrante (pulsación de botón o de lista) registra la hora del último mensaje del chat. Las reacciones y los estados no abren la ventana.
  2. Un envío libre a un chat cuya ventana se sabe cerrada (último mensaje entrante de hace más de 24 h, o Meta informó 131047 desde entonces) se salta la petición condenada al fracaso y pasa directamente al paso 4 (proactive, activado por defecto). Un chat que nunca se vio no se considera cerrado: se intenta el envío.
  3. Si un envío libre falla con OutsideSessionWindowError, la ventana se marca como cerrada.
  4. El gestor envía la plantilla de reapertura, encola el contenido original y rechaza (o resuelve, ver onReopened).
  5. Cuando el usuario responde, el contenido encolado se envía solo, en orden, antes de cualquier envío nuevo a ese chat. Tus handlers también se ejecutan con esa respuesta, como siempre.
Un webhook de estado failed con código 131047 sobre un mensaje libre que envió el gestor (Meta puede informarlo de forma asíncrona) dispara la misma reapertura y encolado, notificados por onReport porque send() ya había resuelto. Las plantillas (kind: 'template') no pasan por el gestor: siempre se envían directamente.

Qué hace send() tras reabrir

  • onReopened: 'reject' (por defecto): send() rechaza con SessionWindowReopenedError, una subclase de OutsideSessionWindowError con template, queued y templateResult. El código que ya maneja OutsideSessionWindowError sigue funcionando.
  • onReopened: 'resolve': send() resuelve con el resultado del envío de la plantilla, y raw es { queued, template, result }. Úsalo cuando quien envía no deba ver un error (por ejemplo, ctx.reply dentro de los handlers).
Si falla la propia plantilla de reapertura, send() rechaza (con el error 131047 original cuando lo hubo), el resultado es reopen-failed y el contenido no queda en cola. Si no se resuelve ninguna plantilla para el contenido, el resultado es no-template y se lanza el error original.

Elegir la plantilla

  • reopenTemplate: una plantilla para cualquier contenido, o un resolver (ctx) => SessionTemplate | undefined (síncrono o asíncrono). ctx trae chatId, content, reason ('error', 'proactive' o 'status') y lastInboundAt. Devuelve undefined para no reabrir.
  • reopenTemplateByKind: una plantilla por tipo de contenido (text, image, document…); tiene prioridad sobre reopenTemplate.
Los envíos simultáneos a un mismo chat cerrado comparten un solo envío de plantilla y, mientras un chat reabierto espera la respuesta, los envíos siguientes se encolan sin mandar otra plantilla.

Notificaciones

onReport(report) recibe cada resultado (los errores lanzados dentro se ignoran). report trae chatId, outcome y, según el caso, reason, content, template, result y error.

Opciones

Persistir el estado de la ventana

Por el store solo pasa el estado de la ventana: por chat, { lastInboundAt?, closedAt? } (epoch ms). El MemorySessionWindowStore por defecto está acotado (maxChats, por defecto 10 000) y se pierde al reiniciar. Implementa SessionWindowStore sobre Redis o SQL para conservarlo entre reinicios y procesos; los métodos pueden ser síncronos o asíncronos:
La cola de reintentos y los envíos seguidos guardan OutboundContent, así que siempre viven en memoria (acotados) y se pierden al reiniciar. Antes de entregarle media al gestor, el provider la sube una sola vez (Buffer, ruta o { stream }) y la cola solo guarda el id de media: los reintentos nunca vuelven a leer un stream ya consumido ni retienen bytes.

Varios números: forScope

La ventana pertenece a un par número de negocio + usuario. El provider acota su gestor por phoneNumberId y las claves del store pasan a ser <phoneNumberId>:<chatId>, así que un mismo store sirve a varios números. Para compartir un gestor (mismas opciones y store) entre providers, pasa la instancia; cada provider obtiene su propia vista con forScope(phoneNumberId), con sus propias claves de ventana, su cola y sus envíos seguidos:

Consultar un chat

provider.sessionWindow es el gestor (o undefined si no configuraste sessionWindow):
También se exportan SESSION_WINDOW_MS (24 h) y SESSION_WINDOW_ERROR_CODE (131047).

Estabilidad de la API

La API de la ventana de sesión tiene dos partes:
  • Pública, cubierta por semver: todo lo de esta página que una app configura o consulta. SessionWindowManager (constructor, forScope, state, isOpen, expiresAt, pending, clearPending), SessionWindowOptions, el contrato SessionWindowStore y su implementación de referencia MemorySessionWindowStore, SessionTemplate, ReopenContext, ReopenReason, ReopenTemplateResolver, SessionReport, SessionReportOutcome, SessionWindowReopenedError, SESSION_WINDOW_MS y SESSION_WINDOW_ERROR_CODE.
  • @experimental, fuera de semver: los ganchos que mueve CloudAPIProvider, send(), noteInbound() y noteFailedStatus(), y su tipo RawSend. Pueden cambiar en una versión menor. Llamarlos a mano se salta la subida única de media del provider y su seguimiento de estados: envía a través del provider o del bot.

Costos y uso

Cada evento status de la Cloud API trae la información de facturación de Meta normalizada en DeliveryStatus.platformData:
  • platformData.pricing: { billable, pricingModel?, category?, type? }. pricingModel es PMP (precio por mensaje, desde el 2025-07-01) o CBP (el modelo antiguo por conversación); type (PMP) es regular, free_customer_service o free_entry_point.
  • platformData.conversation (cuando Meta lo envía): { id?, originType?, expiresAt? } (expiresAt en epoch ms).
provider.usage (un UsageTracker, activado por defecto) agrega ese pricing por chat y por categoría. Meta repite el pricing en sent, delivered y read; cada id de mensaje se cuenta una sola vez.
usage: false desactiva el conteo (el pricing sigue llegando a platformData). También puedes pasar tu propia instancia de UsageTracker, por ejemplo para compartirla entre providers. estimatedCost es solo la suma de las tarifas que configuraste: la factura de Meta es la fuente de verdad.

Ver también