Skip to main content
On the WhatsApp Cloud API (@botmesh/meta) a business can send free-form messages (text, media, buttons…) only within 24 hours of the user’s last message: the customer-service window. Outside it only approved templates go through; Meta rejects anything else with Graph error 131047, which the provider maps to OutsideSessionWindowError. @botmesh/meta ships two optional helpers for this:
  • sessionWindow: tracks each chat’s window, reopens closed chats with a template and retries the original message when the user replies.
  • usage: counts the billing info Meta attaches to status webhooks, per chat and per pricing category.
Both live only in @botmesh/meta. Other providers with a similar window (Twilio error 63016, Gupshup, TikTok) reject with OutsideSessionWindowError but have no manager.

Without the manager

By default nothing is tracked. A free-form send outside the window rejects, and you decide what to do:
Templates are always allowed: they are the way back into the window.

Enabling the manager

With sessionWindow set, provider.send() (and so ctx.reply, bot.send…) goes through a SessionWindowManager:
  1. Every inbound message or interaction (button or list tap) records the chat’s last inbound time. Reactions and statuses do not open the window.
  2. A free-form send to a chat whose window is known closed (last inbound older than 24 h, or Meta reported 131047 since) skips the doomed request and goes straight to step 4 (proactive, on by default). A chat never seen is not treated as closed: the send is attempted.
  3. If a free-form send fails with OutsideSessionWindowError, the window is marked closed.
  4. The manager sends the reopen template, queues the original content and rejects (or resolves, see onReopened).
  5. When the user replies, the queued content is sent automatically, in order, before any new send to that chat. Your handlers also run on that reply as usual.
A failed status webhook with code 131047 for a free-form message the manager sent (Meta can report it asynchronously) triggers the same reopen and queue, reported through onReport since send() already resolved. Templates (kind: 'template') bypass the manager and are always sent directly.

What send() does after reopening

  • onReopened: 'reject' (default): send() rejects with SessionWindowReopenedError, a subclass of OutsideSessionWindowError with template, queued and templateResult. Code that already handles OutsideSessionWindowError keeps working.
  • onReopened: 'resolve': send() resolves with the template’s send result, and raw is { queued, template, result }. Use it when callers should not see an error (for example, ctx.reply inside handlers).
If the reopen template itself fails, send() rejects (with the original 131047 error when there was one), the outcome is reopen-failed and the content is not queued. When no template resolves for the content, the outcome is no-template and the original error is thrown.

Choosing the template

  • reopenTemplate: one template for any content, or a resolver (ctx) => SessionTemplate | undefined (sync or async). ctx has chatId, content, reason ('error', 'proactive' or 'status') and lastInboundAt. Return undefined to not reopen.
  • reopenTemplateByKind: a template per content kind (text, image, document…); it takes precedence over reopenTemplate.
Concurrent sends to the same closed chat share one template send, and while a reopened chat waits for the reply, further sends are queued without sending another template.

Reports

onReport(report) receives every outcome (errors thrown inside it are swallowed). report has chatId, outcome and, depending on the case, reason, content, template, result and error.

Options

Persisting the window state

Only the window state goes through the store: per chat, { lastInboundAt?, closedAt? } (epoch ms). The default MemorySessionWindowStore is bounded (maxChats, default 10 000) and is lost on restart. Implement SessionWindowStore over Redis or SQL to keep it across restarts and processes; methods may be sync or async:
The retry queue and the tracked sends hold OutboundContent, so they always stay in memory (bounded) and are lost on restart. Before handing media to the manager, the provider uploads it once (Buffer, path or { stream }) and the queue only keeps the media id: retries never re-read a drained stream or hold media bytes.

Several numbers: forScope

The window belongs to a business number + user pair. The provider scopes its manager by phoneNumberId, and store keys become <phoneNumberId>:<chatId>, so one store can serve several numbers. To share one manager (same options and store) between providers, pass the instance; each provider gets its own view with forScope(phoneNumberId), with its own window keys, queue and tracked sends:

Querying a chat

provider.sessionWindow is the manager (or undefined when sessionWindow is not configured):
SESSION_WINDOW_MS (24 h) and SESSION_WINDOW_ERROR_CODE (131047) are exported too.

API stability

The session-window surface is split in two:
  • Public, covered by semver: everything on this page an app configures or queries. SessionWindowManager (constructor, forScope, state, isOpen, expiresAt, pending, clearPending), SessionWindowOptions, the SessionWindowStore contract and its reference MemorySessionWindowStore, SessionTemplate, ReopenContext, ReopenReason, ReopenTemplateResolver, SessionReport, SessionReportOutcome, SessionWindowReopenedError, SESSION_WINDOW_MS and SESSION_WINDOW_ERROR_CODE.
  • @experimental, outside semver: the hooks CloudAPIProvider drives, send(), noteInbound() and noteFailedStatus(), and their RawSend type. They may change in a minor release. Calling them by hand skips the provider’s one-time media upload and its status bookkeeping: send through the provider or the bot instead.

Pricing and usage

Every Cloud API status event carries Meta’s billing info normalized in DeliveryStatus.platformData:
  • platformData.pricing: { billable, pricingModel?, category?, type? }. pricingModel is PMP (per-message pricing, since 2025-07-01) or CBP (legacy conversation-based); type (PMP) is regular, free_customer_service or free_entry_point.
  • platformData.conversation (when Meta sends it): { id?, originType?, expiresAt? } (expiresAt in epoch ms).
provider.usage (a UsageTracker, on by default) aggregates that pricing per chat and per category. Meta repeats the pricing on sent, delivered and read; each message id is counted once.
usage: false disables counting (pricing still reaches platformData). You can also pass your own UsageTracker instance, for example to share it between providers. estimatedCost is only the sum of your configured rates: Meta’s invoice is the source of truth.

See also