24-hour window and pricing (Cloud API)
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:
import { OutsideSessionWindowError } from '@botmesh/core'
try {
await bot.send(chatId, 'Your order shipped')
} catch (err) {
if (!(err instanceof OutsideSessionWindowError)) throw err
await bot.send(chatId, {
kind: 'template',
name: 'order_update',
language: 'en',
components: [/* template parameters, Graph format */],
})
}Templates are always allowed: they are the way back into the window.
Enabling the manager
import { CloudAPIProvider } from '@botmesh/meta'
const provider = new CloudAPIProvider({
accessToken: process.env.META_TOKEN!,
phoneNumberId: process.env.META_PHONE_ID!,
verifyToken: process.env.META_VERIFY_TOKEN!,
appSecret: process.env.META_APP_SECRET!,
sessionWindow: {
reopenTemplate: { kind: 'template', name: 'resume_conversation', language: 'en' },
reopenTemplateByKind: {
document: { kind: 'template', name: 'document_ready', language: 'en' },
},
onReport: (r) => logger.info({ chatId: r.chatId, outcome: r.outcome }, 'session window'),
},
})With 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.
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 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).
import { SessionWindowReopenedError } from '@botmesh/meta'
try {
await bot.send(chatId, 'Your invoice is attached')
} catch (err) {
if (err instanceof SessionWindowReopenedError) {
// err.template went out; err.queued === true → delivered when the user replies
} else throw err
}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).ctxhaschatId,content,reason('error','proactive'or'status') andlastInboundAt. Returnundefinedto not reopen.reopenTemplateByKind: a template per content kind (text,image,document...); it takes precedence overreopenTemplate.
sessionWindow: {
reopenTemplate: async ({ chatId }) => {
const lang = await users.language(chatId)
return { kind: 'template', name: 'resume_conversation', language: lang }
},
}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.
outcome | Meaning |
|---|---|
queued | Content queued; the reopen template was sent (or already pending) |
reopened | Template sent; content not queued (retryOnReply: false) |
sent | Queued content delivered after the user replied |
failed | Queued content failed again on retry |
expired | Queued content older than pendingTtlMs when the user replied |
dropped | Content discarded: the chat's queue or the chat table was full, or clearPending() |
no-template | No template configured or resolved: nothing reopened |
reopen-failed | The reopen template itself failed |
Options
| Option | Default | Description |
|---|---|---|
reopenTemplate | — | Template, or (ctx) => template | undefined |
reopenTemplateByKind | — | Template per content kind (wins over reopenTemplate) |
proactive | true | Skip the free-form send when the window is known closed (only when a template resolves) |
retryOnReply | true | Queue the content and retry it when the user replies |
onReopened | 'reject' | 'reject' (throw SessionWindowReopenedError) or 'resolve' |
onReport | — | Outcome callback |
store | MemorySessionWindowStore | Where window state lives |
windowMs | 24 h | Window length |
maxPendingPerChat | 20 | Queued sends per chat (oldest dropped) |
maxPendingChats | 1000 | Chats with queued sends (oldest chat's queue dropped) |
pendingTtlMs | 24 h | Queued sends older than this are discarded on reply |
maxTrackedSends | 500 | Free-form sends remembered to react to async 131047 statuses (0 disables) |
scope | — | Business number of the manager; CloudAPIProvider sets its phoneNumberId |
now | Date.now | Clock (tests) |
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:
import type { SessionWindowStore } from '@botmesh/meta'
const store: SessionWindowStore = {
async get(key) {
const raw = await redis.hGet('wa:window', key)
return raw ? JSON.parse(raw) : undefined
},
async set(key, state) { await redis.hSet('wa:window', key, JSON.stringify(state)) },
async delete(key) { await redis.hDel('wa:window', key) },
}
new CloudAPIProvider({ /* ... */ sessionWindow: { store, reopenTemplate } })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:
import { SessionWindowManager } from '@botmesh/meta'
const windows = new SessionWindowManager({ store, reopenTemplate })
const sales = new CloudAPIProvider({ /* ... */ phoneNumberId: 'SALES_ID', sessionWindow: windows })
const support = new CloudAPIProvider({ /* ... */ phoneNumberId: 'SUPPORT_ID', sessionWindow: windows })
sales.sessionWindow === windows.forScope('SALES_ID') // true (memoised)Querying a chat
provider.sessionWindow is the manager (or undefined when sessionWindow is not configured):
const sw = provider.sessionWindow!
await sw.state(chatId) // 'open' | 'closed' | 'unknown'
await sw.isOpen(chatId) // true only when known open
await sw.expiresAt(chatId) // epoch ms when the window closes, or undefined
sw.pending(chatId) // sends queued for the chat
sw.clearPending(chatId) // drop them (each reported as 'dropped')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 API status 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).
import { pricingOf } from '@botmesh/meta'
bot.on('status', (status) => {
const pricing = pricingOf(status.platformData)
if (pricing?.billable) metrics.increment(`wa.billable.${pricing.category ?? 'unknown'}`)
})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.
const provider = new CloudAPIProvider({
/* ... */
usage: { rates: { marketing: 0.0625, utility: 0.008 } }, // price per billable message
})
provider.usage!.forChat(chatId) // { messages, billable, free, estimatedCost, byCategory } | undefined
provider.usage!.totals() // the same, across every chat
provider.usage!.chats() // chats with counters, least recently updated first
provider.usage!.reset(chatId) // clear one chat (totals kept); reset() clears everythingusage option | Default | Description |
|---|---|---|
rates | — | Price per billable message by category, or (entry) => number | undefined (rates vary by country). Unknown → 0 |
maxChats | 10000 | Chats with per-chat counters (least recently updated evicted; totals survive) |
maxTrackedMessages | 10000 | Message ids remembered to count each message 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