@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.
@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:Enabling the manager
sessionWindow set, provider.send() (and so ctx.reply,
bot.send…) goes through a SessionWindowManager:
- Every inbound message or interaction (button or list tap) records the chat’s last inbound time. Reactions and statuses do not open the window.
- A free-form send to a chat whose window is known closed (last inbound
older than 24 h, or Meta reported
131047since) 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. - If a free-form send fails with
OutsideSessionWindowError, the window is marked closed. - The manager sends the reopen template, queues the original content
and rejects (or resolves, see
onReopened). - 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.
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 withSessionWindowReopenedError, a subclass ofOutsideSessionWindowErrorwithtemplate,queuedandtemplateResult. Code that already handlesOutsideSessionWindowErrorkeeps working.onReopened: 'resolve':send()resolves with the template’s send result, andrawis{ queued, template, result }. Use it when callers should not see an error (for example,ctx.replyinside handlers).
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).ctxhaschatId,content,reason('error','proactive'or'status') andlastInboundAt. Returnundefinedto not reopen.reopenTemplateByKind: a template per content kind (text,image,document…); it takes precedence overreopenTemplate.
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:
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, theSessionWindowStorecontract and its referenceMemorySessionWindowStore,SessionTemplate,ReopenContext,ReopenReason,ReopenTemplateResolver,SessionReport,SessionReportOutcome,SessionWindowReopenedError,SESSION_WINDOW_MSandSESSION_WINDOW_ERROR_CODE. @experimental, outside semver: the hooksCloudAPIProviderdrives,send(),noteInbound()andnoteFailedStatus(), and theirRawSendtype. 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 APIstatus event carries Meta’s billing info normalized in
DeliveryStatus.platformData:
platformData.pricing:{ billable, pricingModel?, category?, type? }.pricingModelisPMP(per-message pricing, since 2025-07-01) orCBP(legacy conversation-based);type(PMP) isregular,free_customer_serviceorfree_entry_point.platformData.conversation(when Meta sends it):{ id?, originType?, expiresAt? }(expiresAtin 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
- Channels: all providers and their capabilities.
@botmesh/metaREADME