{"id":3166,"date":"2026-11-11T11:19:21","date_gmt":"2026-11-11T05:49:21","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3166"},"modified":"2026-11-11T11:19:24","modified_gmt":"2026-11-11T05:49:24","slug":"whatsapp-24-hour-customer-service-window-state-machine","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-24-hour-customer-service-window-state-machine\/","title":{"rendered":"The WhatsApp 24-Hour Window as a State Machine: Session Replies, Templates and a Send Router That Never Guesses"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">The WhatsApp customer service window decides whether you can send a free-form reply or need an approved template, and it follows the customer&#8217;s last message, not yours. This guide shows how to track the window from the MO webhook and the inbox API, normalise their different timestamp units, derive the window instead of counting it, leave a safety margin at the edge, and route every send to a session reply, a template or a hold.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-24-hour-window-state-machine.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-24-hour-window-state-machine-1024x584.webp\" alt=\"A teal ring drawn as a three-quarter clock arc fading to orange, with blue circles entering from the left to restart it and three paths leaving to the right toward different shapes, representing a messaging window and the routes a send can take.\" class=\"wp-image-3167\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-24-hour-window-state-machine-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-24-hour-window-state-machine-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-24-hour-window-state-machine-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-24-hour-window-state-machine.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Every customer message restarts the clock. Every send asks the clock first.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#window-rules\">What Opens, Resets and Closes the Window<\/a><\/li>\n\n\n\n<li><a href=\"#two-clocks\">Two Clocks: the Service Window and the Free Entry Point Window<\/a><\/li>\n\n\n\n<li><a href=\"#platform-surface\">Where the Window Shows Up on the Platform<\/a><\/li>\n\n\n\n<li><a href=\"#timestamp-units\">The Timestamp Trap: Seconds on the Webhook, Milliseconds in the Inbox<\/a><\/li>\n\n\n\n<li><a href=\"#window-key\">The Window Key Is a Pair of Numbers<\/a><\/li>\n\n\n\n<li><a href=\"#state-machine\">The State Machine<\/a><\/li>\n\n\n\n<li><a href=\"#data-model\">The Data Model: Store Messages, Derive the Window<\/a><\/li>\n\n\n\n<li><a href=\"#record-inbound\">Recording Inbound Messages in Python<\/a><\/li>\n\n\n\n<li><a href=\"#inbox-probe\">Asking the Inbox When You Have No State<\/a><\/li>\n\n\n\n<li><a href=\"#send-router\">The Send Router: Session Reply, Template or Hold<\/a><\/li>\n\n\n\n<li><a href=\"#safety-margin\">Leave a Margin at the Edge<\/a><\/li>\n\n\n\n<li><a href=\"#session-send\">Sending a Session Reply on WAApi\/send<\/a><\/li>\n\n\n\n<li><a href=\"#session-failure\">When a Session Send Fails<\/a><\/li>\n\n\n\n<li><a href=\"#agent-queues\">Agent Queues and Replies That Wait Overnight<\/a><\/li>\n\n\n\n<li><a href=\"#cost\">What the Window Does to Your Bill<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">Launch Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When a customer messages your WhatsApp number, a 24-hour customer service window opens. While it&#8217;s open you can send ordinary free-form messages, which this platform calls session messages: you send them through <code>WAApi\/send<\/code> with no <code>templateName<\/code>. When it closes, only approved templates go through. Every new message from the customer resets the timer to a fresh 24 hours. Your own replies don&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t confuse this with the old 24-hour conversation pricing. Meta replaced that with per-message pricing on 1 July 2025, but the window itself didn&#8217;t go anywhere. It still decides whether a free-form message is allowed at all, and under per-message pricing it also decides whether a utility template is free or charged.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the window isn&#8217;t something you open and close. It&#8217;s a fact you derive: &#8220;the newest message this customer sent to this business number, plus 24 hours.&#8221; Build it that way. Store every inbound message once, keyed on its WhatsApp message id, from both the MO webhook and the inbox API. Convert the webhook&#8217;s <code>timestamp<\/code> (a string of seconds) and the inbox&#8217;s <code>timestamp<\/code> (a number of milliseconds) to one unit before they touch each other. Compute the window as <code>MAX(at_ms) + 24 h<\/code> per business and customer pair. Then put one router in front of every send that picks a session reply when the window is open with a safety margin, an approved template when it isn&#8217;t, and a hold when there&#8217;s no template to use.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The rule:<\/strong> a customer message (or call) opens a 24-hour window and every later one resets it. Outside the window you can only send templates.<\/li>\n\n\n\n<li><strong>Your replies don&#8217;t count.<\/strong> Track the customer&#8217;s last inbound message, never your last send.<\/li>\n\n\n\n<li><strong>Units differ.<\/strong> The MO webhook <code>timestamp<\/code> is a string of seconds (<code>\"1682397841\"<\/code>). The inbox <code>timestamp<\/code> is a number of milliseconds (<code>1752480000000<\/code>). Mix them and your window lands in January 1970.<\/li>\n\n\n\n<li><strong>Key the window on a pair:<\/strong> your WABA number and the customer&#8217;s number, both as digit strings.<\/li>\n\n\n\n<li><strong>Derive, don&#8217;t count.<\/strong> <code>last_inbound_ms = MAX(at_ms)<\/code> over stored messages gives the same answer whatever order events arrive in and however many times they&#8217;re replayed.<\/li>\n\n\n\n<li><strong>Leave a margin.<\/strong> Treat the last 30 minutes as closed, because you can&#8217;t see whether the check happens when the platform accepts your request or when the message is delivered.<\/li>\n\n\n\n<li><strong>Route every send through one function:<\/strong> OTPs go as authentication templates, marketing as marketing templates, everything else as a session reply when the window is OPEN and a template when it isn&#8217;t.<\/li>\n\n\n\n<li><strong>A failed session send shuts the window<\/strong> in your store until the customer writes again. Don&#8217;t retry it as another session message.<\/li>\n\n\n\n<li><strong>Cost follows the window<\/strong> under Meta&#8217;s per-message pricing: session replies and utility templates inside an open window are free, marketing and authentication templates are always charged. Your account&#8217;s rates are in the Rate Plan.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"window-rules\" class=\"wp-block-heading\">What Opens, Resets and Closes the Window<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Meta&#8217;s own wording is short. When a WhatsApp user messages you or calls you, a 24-hour timer called a customer service window starts. If they message or call again before it runs out, the timer resets to 24 hours. While it&#8217;s open you can send service messages, meaning text, media, interactive lists and reply buttons, location, contacts and the rest, without approval. Once it closes, you can only send pre-approved templates. The rule is on Meta&#8217;s <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/messages\/send-messages\" target=\"_blank\" rel=\"noopener nofollow\">service messages page<\/a>, and the platform&#8217;s <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-is-the-24-hour-customer-care-window-and-how-does-it-work\/\">24-hour customer care window entry<\/a> says the same thing in one paragraph.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What that means in practice:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Only the customer moves the clock.<\/strong> Meta&#8217;s pricing example is precise about this: at hour 26 the window closes because &#8220;24 hours have passed since the user last messaged you.&#8221; You can send ten replies at hour 23 and the window still closes at hour 24. A system that stores &#8220;last message in this conversation&#8221; without asking who sent it will be wrong on every busy thread.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A template doesn&#8217;t open a window. A reply to it does.<\/strong> A business-initiated template goes out regardless of the window. If the customer answers, by typing or by tapping a quick reply button, that answer is an inbound message and the window opens from that moment. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/template-vs-session-messages-whatsapp\/\">template versus session messages entry<\/a> describes this flow: start with a template, then keep the conversation going with session messages.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The window is per conversation partner, not per account.<\/strong> It exists between one business phone number and one WhatsApp user. A customer who wrote to your support number has an open window there and a closed one on your marketing number. More on that in <a href=\"#window-key\">the window key section<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s also a small caveat on Meta&#8217;s side worth building around: Meta lists a known issue where, in rare cases, you receive a message and still can&#8217;t respond within the window. That&#8217;s one more reason a failed session send has to be handled as a normal outcome rather than an exception. See <a href=\"#session-failure\">When a Session Send Fails<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"two-clocks\" class=\"wp-block-heading\">Two Clocks: the Service Window and the Free Entry Point Window<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s a second clock, and it&#8217;s easy to confuse with the first.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th><\/th><th>Customer service window<\/th><th>Free entry point (FEP) window<\/th><\/tr><\/thead><tbody><tr><td>Opened by<\/td><td>Any customer message or call<\/td><td>A customer arriving from a Click to WhatsApp ad or a Facebook Page call-to-action button, on the Android or iOS app, and you replying within 24 hours<\/td><\/tr><tr><td>Starts<\/td><td>When the customer&#8217;s message arrives<\/td><td>When you send that reply<\/td><\/tr><tr><td>Lasts<\/td><td>24 hours from the customer&#8217;s latest message<\/td><td>72 hours<\/td><\/tr><tr><td>What it changes<\/td><td>What you&#8217;re <strong>allowed<\/strong> to send<\/td><td>What Meta <strong>charges<\/strong> for what you send<\/td><\/tr><tr><td>After it closes<\/td><td>Templates only<\/td><td>Normal per-message pricing<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Meta&#8217;s <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/pricing\" target=\"_blank\" rel=\"noopener nofollow\">pricing page<\/a> is explicit that the two are independent: if the service window closes while the FEP window is still open, you can only send templates, but those templates are free until the 72 hours run out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For engineering, that split is the useful part. The service window is a <strong>permission<\/strong>. Get it wrong and your message fails. The FEP window is a <strong>price<\/strong>. Get it wrong and your cost forecast is off, but nothing breaks. So the router below never uses the FEP window to decide whether to send something. It only reads it, when it can, to label the expected cost. And since the entry point conditions include the device type the customer used, which you can&#8217;t see, the safe default is to assume a message is charged unless you&#8217;ve seen the referral data that marks an ad entry. The platform&#8217;s <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/which-whatsapp-messages-are-free\/\">free messages entry<\/a> lists the same three free cases.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"platform-surface\" class=\"wp-block-heading\">Where the Window Shows Up on the Platform<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">There&#8217;s no &#8220;window status&#8221; endpoint to ask. The window is something you work out from inbound messages, and you send differently depending on the answer. Four surfaces are involved.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Surface<\/th><th>What it gives you<\/th><th>Role in the window<\/th><\/tr><\/thead><tbody><tr><td>MO webhook (configured in the portal, report type MO)<\/td><td>One push per customer message: <code>messages[].id<\/code>, <code>from<\/code>, <code>type<\/code>, <code>timestamp<\/code> as a string of seconds, and <code>metadata.display_phone_number<\/code> for your number<\/td><td>Primary source. Resets the window in near real time<\/td><\/tr><tr><td><code>GET rest\/wa\/v1\/inbox<\/code><\/td><td>Rows with <code>waMsgId<\/code>, <code>wabaNumber<\/code>, <code>mobileNo<\/code>, <code>messageType<\/code>, <code>timestamp<\/code> as a number of milliseconds; newest first; filters for <code>wabaNumber<\/code>, <code>mobileNo<\/code>, <code>fromDate<\/code>, <code>toDate<\/code>; <code>limit<\/code> up to 200<\/td><td>Backfill and on-demand probe when your store has no row for someone<\/td><\/tr><tr><td><code>POST WAApi\/send<\/code><\/td><td>Sends a template when <code>templateName<\/code> is present, a session message when it&#8217;s left out<\/td><td>Where the router&#8217;s decision turns into a request<\/td><\/tr><tr><td><code>POST WAApi\/report<\/code><\/td><td>Delivery rows with <code>status<\/code> (for example <code>DELIVERED<\/code>, <code>READ<\/code>, <code>FAILED<\/code>), <code>cause<\/code>, <code>globalErrorCode<\/code>, <code>submitTime<\/code>, <code>deliveryTime<\/code>, <code>readTime<\/code><\/td><td>Tells you a session send failed, which feeds back into the window<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The send page states the session rule in one line: if you&#8217;re sending a session based message, send an open message and remove the <code>templateName<\/code> parameter, and if it&#8217;s in session the message will get delivered. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-whatsapp-business-message\/\">send reference<\/a>, the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-inbox\/\">inbox reference<\/a> and the <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-reports\/\">reports reference<\/a> have the full parameter tables. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-understand-and-use-the-mo-response-format-when-setting-up-a-webhook-for-whatsapp-business-api-with-smsgatewaycenter\/\">MO response format entry<\/a> shows the webhook body, and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">Receiving Messages on Four Channels<\/a> compares the inbox contracts across channels if you&#8217;re building one inbound pipeline for all of them.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-whatsapp-window-send-router-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-whatsapp-window-send-router-flow.svg\" alt=\"Three-panel flow from inbound sources (webhook seconds, inbox milliseconds, normalised and inserted once by wamid) to a window store (earliest time per message, only listed types reset, maximum per pair plus 24 hours, failures shut it) to a send router (OTP and marketing as templates, OPEN as a session reply, otherwise a template or a hold).\" class=\"wp-image-3169\"\/><\/a><figcaption class=\"wp-element-caption\">Two inbound sources feed one derived window, and every send asks it once.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"timestamp-units\" class=\"wp-block-heading\">The Timestamp Trap: Seconds on the Webhook, Milliseconds in the Inbox<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The two inbound sources describe the same event with different units and different JSON types.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Source<\/th><th>Field<\/th><th>Sample<\/th><th>JSON type<\/th><th>Unit<\/th><\/tr><\/thead><tbody><tr><td>MO webhook<\/td><td><code>messages[].timestamp<\/code><\/td><td><code>\"1682397841\"<\/code><\/td><td>string<\/td><td>seconds<\/td><\/tr><tr><td>Inbox API<\/td><td><code>inboxList[].timestamp<\/code><\/td><td><code>1752480000000<\/code><\/td><td>number<\/td><td>milliseconds<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what each sample means, and what happens when you read one in the other&#8217;s unit:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Value<\/th><th>Read correctly<\/th><th>Read in the wrong unit<\/th><\/tr><\/thead><tbody><tr><td><code>\"1682397841\"<\/code> (webhook)<\/td><td>2023-04-25 10:14:01 IST<\/td><td>As milliseconds: 1970-01-20 16:49:57 IST<\/td><\/tr><tr><td><code>1752480000000<\/code> (inbox)<\/td><td>2025-07-14 13:30:00 IST<\/td><td>As seconds: roughly the year 57,500, which most date libraries refuse<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The first mistake is the dangerous one, because it doesn&#8217;t throw. A seconds value stored as milliseconds says the customer last wrote in January 1970, so every window looks closed, and your router quietly sends paid templates to people who are mid-conversation. The reverse mistake would keep every window open forever, which is worse, but at least it tends to crash.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two habits fix it for good:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Convert at the parser, never later.<\/strong> The webhook parser multiplies by 1,000. The inbox parser doesn&#8217;t. Nothing downstream ever sees a raw <code>timestamp<\/code>.<\/li>\n\n\n\n<li><strong>Range-check the result.<\/strong> Any epoch millisecond value you&#8217;ll see in production falls between 1,577,836,800,000 (1 January 2020) and 4,102,444,800,000 (1 January 2100). A seconds value is about a thousand times too small to pass. One <code>if<\/code> turns a silent bug into a loud one.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">If you parse the inbox with a generic JSON library, the number type is safe here: <code>1752480000000<\/code> is well under the 9,007,199,254,740,991 limit for exact doubles. The IDs on other WhatsApp endpoints aren&#8217;t, which is a separate problem covered in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/lossless-json-parsing-messaging-api-19-digit-ids\/\">Lossless JSON Parsing for Messaging APIs<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"window-key\" class=\"wp-block-heading\">The Window Key Is a Pair of Numbers<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A window belongs to one business number and one customer. Key it on both:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Business:<\/strong> <code>metadata.display_phone_number<\/code> on the webhook, <code>wabaNumber<\/code> in the inbox and on <code>WAApi\/send<\/code>.<\/li>\n\n\n\n<li><strong>Customer:<\/strong> <code>messages[].from<\/code> on the webhook, <code>mobileNo<\/code> in the inbox, <code>mobile<\/code> on <code>WAApi\/send<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Then normalise both to digits only, once, at the parser. A display number can come with spaces or a plus sign, and the inbox filter asks for digits only. If one side of your join has <code>+91 70396 00000<\/code> and the other has <code>917039600000<\/code>, every lookup misses and every window looks closed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you run several WABA numbers, keying on the customer alone is the bug to watch for. Someone who replied to your delivery number an hour ago hasn&#8217;t opened anything on your sales number. A router that sees &#8220;this customer has an open window&#8221; and sends a session message from the wrong number gets a failure, and you&#8217;ll spend a while working out why only some of them fail.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"state-machine\" class=\"wp-block-heading\">The State Machine<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For one business and customer pair, at a given moment, the window is in exactly one of four states.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>State<\/th><th>Condition<\/th><th>What you can send<\/th><\/tr><\/thead><tbody><tr><td><code>NONE<\/code><\/td><td>You&#8217;ve never stored a message from this customer on this number<\/td><td>Templates only<\/td><\/tr><tr><td><code>OPEN<\/code><\/td><td><code>now &lt; last_inbound + 24 h - margin<\/code><\/td><td>Session messages and templates<\/td><\/tr><tr><td><code>CLOSING<\/code><\/td><td><code>last_inbound + 24 h - margin &lt;= now &lt; last_inbound + 24 h<\/code><\/td><td>Technically session messages, but route as closed<\/td><\/tr><tr><td><code>CLOSED<\/code><\/td><td><code>now &gt;= last_inbound + 24 h<\/code>, or a session send failed after the last inbound message<\/td><td>Templates only<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Transitions are simple because only two kinds of event exist:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>A customer message arrives<\/strong> (any type on your reset list). The pair moves to <code>OPEN<\/code>, from any state, including <code>CLOSED<\/code> after a failure.<\/li>\n\n\n\n<li><strong>Time passes.<\/strong> <code>OPEN<\/code> drifts into <code>CLOSING<\/code>, then <code>CLOSED<\/code>. No event fires for this. You compute it whenever you need it.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A third event, <strong>a session send fails<\/strong>, forces <code>CLOSED<\/code> early. It&#8217;s covered in <a href=\"#session-failure\">When a Session Send Fails<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Notice what isn&#8217;t an event: your own sends. Nothing you send moves the clock, which is why the state can be computed from inbound data alone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t run a timer per conversation that fires at the 24-hour mark. You&#8217;d need to cancel and restart it on every inbound message, survive restarts, and handle late events that arrive after it fired. Computing the state from stored data on demand avoids all of that. If you want a &#8220;windows closing soon&#8221; list for agents, run a query every few minutes, as shown below.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-whatsapp-window-states-table.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-whatsapp-window-states-table.svg\" alt=\"Table of six situations (no inbound yet, wrote 2 hours ago, wrote 23 hours 45 minutes ago, wrote 30 hours ago, ad entry 30 hours on, session send failed) against window state, session reply, utility template, marketing template and what the router sends.\" class=\"wp-image-3168\"\/><\/a><figcaption class=\"wp-element-caption\">The state decides what&#8217;s allowed; the category and the free entry point window decide what it costs.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"data-model\" class=\"wp-block-heading\">The Data Model: Store Messages, Derive the Window<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Store the inputs and derive the window. One row per customer message, keyed on the WhatsApp message id, plus a small table for failed session sends. The window itself is a view.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- One row per customer message, from the webhook or the inbox poll.\nCREATE TABLE wa_inbound (\n    wamid      TEXT PRIMARY KEY,           -- the customer's message id\n    business   TEXT NOT NULL,              -- your WABA number, digits only\n    customer   TEXT NOT NULL,              -- their number, digits only\n    at_ms      BIGINT NOT NULL,            -- epoch ms, earliest seen\n    msg_type   TEXT NOT NULL,\n    resets     SMALLINT NOT NULL,          -- 1 if the type moves the window\n    referral   SMALLINT NOT NULL DEFAULT 0,\n    source     TEXT NOT NULL               -- 'webhook' or 'inbox'\n);\nCREATE INDEX wa_inbound_pair ON wa_inbound (business, customer, at_ms);\n\n-- A session send that came back FAILED shuts the window until they write again.\nCREATE TABLE wa_session_failure (\n    send_ref   TEXT PRIMARY KEY,\n    business   TEXT NOT NULL,\n    customer   TEXT NOT NULL,\n    failed_ms  BIGINT NOT NULL\n);\n\n-- The window is derived, never stored as a counter.\nCREATE VIEW wa_window AS\nSELECT i.business,\n       i.customer,\n       MAX(i.at_ms)                    AS last_inbound_ms,\n       MAX(i.at_ms) + 86400000         AS closes_ms,\n       (SELECT MAX(f.failed_ms)\n          FROM wa_session_failure f\n         WHERE f.business = i.business\n           AND f.customer = i.customer) AS last_failure_ms\n  FROM wa_inbound i\n WHERE i.resets = 1\n GROUP BY i.business, i.customer;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Why this shape:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>wamid<\/code> as the primary key<\/strong> makes ingestion idempotent. The webhook can retry, a backfill can replay yesterday, and the inbox poll can return a row you already got from the webhook. Each lands once. The same idea is behind <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">Message Idempotency at Scale<\/a>, applied to inbound instead of outbound.<\/li>\n\n\n\n<li><strong><code>at_ms<\/code> keeps the earliest time seen<\/strong> for a message. If the webhook and the inbox disagree by a second or two on the same message, the earlier value closes the window slightly sooner, which is the safe direction.<\/li>\n\n\n\n<li><strong><code>resets<\/code> is decided at insert time<\/strong> from the message type, so the view doesn&#8217;t need to know the reset list.<\/li>\n\n\n\n<li><strong><code>MAX(at_ms)<\/code> doesn&#8217;t care about order.<\/strong> An old message arriving late can&#8217;t shrink the window, and a replay can&#8217;t extend it.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The view answers every question with one query. This one lists each pair&#8217;s state with a 30-minute margin:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- Who can still get a session reply, with half an hour to spare?\nSELECT business, customer, closes_ms,\n       CASE\n         WHEN last_failure_ms &gt;= last_inbound_ms THEN 'CLOSED'\n         WHEN :now &gt;= closes_ms THEN 'CLOSED'\n         WHEN :now &gt;= closes_ms - :margin THEN 'CLOSING'\n         ELSE 'OPEN'\n       END AS state\n  FROM wa_window;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run against three stored messages, a text at 13:30, a button tap at 14:30 and a reaction at 15:30 on 14 July 2025 (IST), at 14:00 the next day, it returns <code>closes_ms<\/code> of 1752570000000 (2025-07-15 14:30:00 IST) and <code>CLOSING<\/code>. The reaction didn&#8217;t move the clock, and the button tap did.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you keep a central outbound table, as in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">The Outbound Message Table<\/a>, add one column to it: <code>window_state_at_send<\/code>, the state your router saw when it made the decision. When a session reply fails, that column tells you in one glance whether the router was wrong or the platform was.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"record-inbound\" class=\"wp-block-heading\">Recording Inbound Messages in Python<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the whole module: parsers for both sources, the unit guard, idempotent recording, the state function and the router. It uses <code>sqlite3<\/code> so you can run it as-is. The SQL ports to PostgreSQL with <code>BIGINT<\/code> columns.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"\"\"Track the WhatsApp customer service window and route outbound sends.\n\nThe window is a fold over inbound messages: for each (business, customer)\npair it closes 24 hours after the newest message the customer sent.\nEverything here is order-independent and safe to replay.\n\"\"\"\nimport re\nimport sqlite3\nfrom dataclasses import dataclass\nfrom typing import List, Optional\n\nWINDOW_MS = 24 * 60 * 60 * 1000\nDEFAULT_MARGIN_MS = 30 * 60 * 1000\nMAX_FUTURE_MS = 5 * 60 * 1000\n\n# Plausible epoch-millisecond range: 2020-01-01 to 2100-01-01 UTC.\nMS_MIN = 1577836800000\nMS_MAX = 4102444800000\n\n# Inbound types allowed to move the window. Anything else is ignored\n# until you have seen it in your own traffic and decided it counts.\nRESETTING_TYPES = frozenset({\n    \"text\", \"button\", \"interactive\", \"image\", \"video\", \"audio\",\n    \"document\", \"location\", \"contacts\", \"sticker\",\n})\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS wa_inbound (\n    wamid      TEXT PRIMARY KEY,\n    business   TEXT NOT NULL,\n    customer   TEXT NOT NULL,\n    at_ms      INTEGER NOT NULL,\n    msg_type   TEXT NOT NULL,\n    resets     INTEGER NOT NULL,\n    referral   INTEGER NOT NULL DEFAULT 0,\n    source     TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS wa_inbound_pair\n    ON wa_inbound (business, customer, at_ms);\nCREATE TABLE IF NOT EXISTS wa_session_failure (\n    business   TEXT NOT NULL,\n    customer   TEXT NOT NULL,\n    failed_ms  INTEGER NOT NULL,\n    send_ref   TEXT NOT NULL PRIMARY KEY\n);\n\"\"\"\n\n\ndef digits(value) -&gt; str:\n    \"\"\"Phone numbers become digit strings at one boundary.\"\"\"\n    return re.sub(r\"\\D\", \"\", str(value))\n\n\ndef check_ms(value: int) -&gt; int:\n    \"\"\"Reject a value that is not epoch milliseconds.\"\"\"\n    if not MS_MIN &lt;= value &lt; MS_MAX:\n        raise ValueError(f\"not epoch milliseconds: {value}\")\n    return value\n\n\n@dataclass(frozen=True)\nclass Inbound:\n    wamid: str\n    business: str\n    customer: str\n    at_ms: int\n    msg_type: str\n    referral: bool\n    source: str\n\n\ndef parse_webhook(body: dict) -&gt; List&#91;Inbound]:\n    \"\"\"Read customer messages from an MO webhook body.\n\n    Handles the flat shape (metadata, messages, contacts) and Meta's\n    entry&#91;].changes&#91;].value wrapper. timestamp is a string of seconds.\n    \"\"\"\n    values = &#91;]\n    if isinstance(body.get(\"entry\"), list):\n        for entry in body&#91;\"entry\"]:\n            for change in entry.get(\"changes\", &#91;]):\n                values.append(change.get(\"value\", {}))\n    else:\n        values.append(body)\n    out = &#91;]\n    for value in values:\n        business = digits(value.get(\"metadata\", {})\n                          .get(\"display_phone_number\", \"\"))\n        for msg in value.get(\"messages\", &#91;]) or &#91;]:\n            out.append(Inbound(\n                wamid=str(msg&#91;\"id\"]),\n                business=business,\n                customer=digits(msg&#91;\"from\"]),\n                at_ms=check_ms(int(msg&#91;\"timestamp\"]) * 1000),\n                msg_type=str(msg.get(\"type\", \"\")),\n                referral=\"referral\" in msg,\n                source=\"webhook\",\n            ))\n    return out\n\n\ndef parse_inbox_row(row: dict) -&gt; Inbound:\n    \"\"\"Read one rest\/wa\/v1\/inbox row. timestamp is a number of ms.\"\"\"\n    return Inbound(\n        wamid=str(row&#91;\"waMsgId\"]),\n        business=digits(row&#91;\"wabaNumber\"]),\n        customer=digits(row&#91;\"mobileNo\"]),\n        at_ms=check_ms(int(row&#91;\"timestamp\"])),\n        msg_type=str(row.get(\"messageType\", \"\")),\n        referral=False,\n        source=\"inbox\",\n    )\n\n\ndef record(db: sqlite3.Connection, ev: Inbound, received_ms: int) -&gt; None:\n    \"\"\"Store an inbound message once. Safe to call from both sources.\"\"\"\n    at_ms = min(ev.at_ms, received_ms + MAX_FUTURE_MS)\n    resets = 1 if ev.msg_type in RESETTING_TYPES else 0\n    db.execute(\n        \"INSERT INTO wa_inbound (wamid, business, customer, at_ms,\"\n        \" msg_type, resets, referral, source)\"\n        \" VALUES (?, ?, ?, ?, ?, ?, ?, ?)\"\n        \" ON CONFLICT (wamid) DO UPDATE SET\"\n        \" at_ms = MIN(wa_inbound.at_ms, excluded.at_ms),\"\n        \" referral = MAX(wa_inbound.referral, excluded.referral)\",\n        (ev.wamid, ev.business, ev.customer, at_ms, ev.msg_type,\n         resets, int(ev.referral), ev.source))\n\n\ndef record_session_failure(db, business, customer, failed_ms, send_ref):\n    \"\"\"A session send came back FAILED: treat the window as shut.\"\"\"\n    db.execute(\n        \"INSERT OR IGNORE INTO wa_session_failure\"\n        \" (business, customer, failed_ms, send_ref) VALUES (?, ?, ?, ?)\",\n        (digits(business), digits(customer), failed_ms, send_ref))\n\n\ndef last_inbound_ms(db, business, customer) -&gt; Optional&#91;int]:\n    row = db.execute(\n        \"SELECT MAX(at_ms) FROM wa_inbound\"\n        \" WHERE business = ? AND customer = ? AND resets = 1\",\n        (digits(business), digits(customer))).fetchone()\n    return row&#91;0]\n\n\ndef window_state(db, business, customer, now_ms,\n                 margin_ms=DEFAULT_MARGIN_MS) -&gt; str:\n    \"\"\"NONE, OPEN, CLOSING or CLOSED for one pair at now_ms.\"\"\"\n    last = last_inbound_ms(db, business, customer)\n    if last is None:\n        return \"NONE\"\n    failed = db.execute(\n        \"SELECT MAX(failed_ms) FROM wa_session_failure\"\n        \" WHERE business = ? AND customer = ?\",\n        (digits(business), digits(customer))).fetchone()&#91;0]\n    if failed is not None and failed &gt;= last:\n        return \"CLOSED\"\n    closes = last + WINDOW_MS\n    if now_ms &gt;= closes:\n        return \"CLOSED\"\n    if now_ms &gt;= closes - margin_ms:\n        return \"CLOSING\"\n    return \"OPEN\"\n\n\n@dataclass(frozen=True)\nclass Route:\n    kind: str      # \"session\", \"template\" or \"hold\"\n    reason: str\n\n\ndef route(intent: str, state: str, template: Optional&#91;str]) -&gt; Route:\n    \"\"\"Pick how to send. intent: reply, notify, marketing or otp.\"\"\"\n    if intent == \"otp\":\n        return Route(\"template\", \"authentication template, short TTL\")\n    if intent == \"marketing\":\n        return Route(\"template\", \"marketing always goes as a template\")\n    if state == \"OPEN\":\n        return Route(\"session\", \"window open with margin to spare\")\n    if template:\n        return Route(\"template\", f\"window {state.lower()}, use {template}\")\n    return Route(\"hold\", f\"window {state.lower()}, no approved template\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What&#8217;s easy to miss in there:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>RESETTING_TYPES<\/code> is an allowlist.<\/strong> A message type you haven&#8217;t listed is stored but doesn&#8217;t move the window. If a new type shows up in your logs, look at what it is, decide, and add it. An unknown type then costs you a template you might not have needed, not a failed session send.<\/li>\n\n\n\n<li><strong><code>record()<\/code> clamps future timestamps<\/strong> to five minutes past the time you received the event. A sender clock that runs ahead would otherwise hold a window open after it really closed.<\/li>\n\n\n\n<li><strong><code>ON CONFLICT ... MIN(...)<\/code><\/strong> keeps the earlier timestamp when the same <code>wamid<\/code> arrives from both sources, and keeps the referral flag if either source carried it.<\/li>\n\n\n\n<li><strong>The failure check compares against the last inbound message.<\/strong> A failure older than the customer&#8217;s latest message doesn&#8217;t count, so the next message reopens the window without any cleanup job.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">These tests cover the edges. They run with plain <code>python3 test_wawindow.py<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import sqlite3\n\nimport wawindow as w\n\nH = 60 * 60 * 1000\nT0 = 1752480000000  # 2025-07-14 13:30:00 IST, the inbox sample value\n\n\ndef fresh():\n    db = sqlite3.connect(\":memory:\")\n    db.executescript(w.SCHEMA)\n    return db\n\n\ndef flat(wamid, ts_seconds, mtype=\"text\", extra=None):\n    msg = {\"from\": \"919876543210\", \"id\": wamid, \"type\": mtype,\n           \"timestamp\": str(ts_seconds)}\n    if mtype == \"text\":\n        msg&#91;\"text\"] = {\"body\": \"hi\"}\n    msg.update(extra or {})\n    return {\"metadata\": {\"phone_number_id\": \"1234\",\n                         \"display_phone_number\": \"91 70396 00000\"},\n            \"messaging_product\": \"whatsapp\", \"messages\": &#91;msg],\n            \"contacts\": &#91;{\"profile\": {\"name\": \"R\"}, \"wa_id\": \"919876543210\"}]}\n\n\ndef inbox(wamid, ms, mtype=\"text\"):\n    return {\"incomingId\": \"8801\", \"wabaNumber\": \"917039600000\",\n            \"mobileNo\": \"919876543210\", \"waMsgId\": wamid,\n            \"messageType\": mtype, \"timestamp\": ms}\n\n\nB, C = \"917039600000\", \"919876543210\"\n\n\ndef test_webhook_seconds_and_wrapped():\n    ev = w.parse_webhook(flat(\"wamid.A\", 1682397841))&#91;0]\n    assert ev.at_ms == 1682397841000 and ev.business == B\n    wrapped = {\"object\": \"whatsapp_business_account\",\n               \"entry\": &#91;{\"changes\": &#91;{\"value\": flat(\"wamid.B\", 1682397841),\n                                       \"field\": \"messages\"}]}]}\n    assert w.parse_webhook(wrapped)&#91;0].wamid == \"wamid.B\"\n\n\ndef test_unit_guard():\n    try:\n        w.check_ms(1682397841)  # seconds passed off as ms\n    except ValueError:\n        pass\n    else:\n        raise AssertionError(\"seconds accepted as ms\")\n    assert w.parse_inbox_row(inbox(\"wamid.C\", T0)).at_ms == T0\n\n\ndef test_same_message_two_sources_keeps_earlier():\n    db = fresh()\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.D\", T0 + 2000)), T0 + 9000)\n    w.record(db, w.parse_webhook(flat(\"wamid.D\", T0 \/\/ 1000))&#91;0], T0 + 9000)\n    assert w.last_inbound_ms(db, B, C) == T0\n    assert db.execute(\"SELECT COUNT(*) FROM wa_inbound\").fetchone()&#91;0] == 1\n\n\ndef test_out_of_order_never_shrinks():\n    db = fresh()\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.new\", T0 + 5 * H)), T0 + 6 * H)\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.old\", T0)), T0 + 6 * H)\n    assert w.last_inbound_ms(db, B, C) == T0 + 5 * H\n\n\ndef test_unlisted_type_does_not_reset():\n    db = fresh()\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.E\", T0)), T0)\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.F\", T0 + 20 * H,\n                                         \"reaction\")), T0 + 20 * H)\n    assert w.last_inbound_ms(db, B, C) == T0\n\n\ndef test_boundaries():\n    db = fresh()\n    assert w.window_state(db, B, C, T0) == \"NONE\"\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.G\", T0)), T0)\n    assert w.window_state(db, B, C, T0 + 23 * H) == \"OPEN\"\n    assert w.window_state(db, B, C, T0 + 24 * H - 30 * 60 * 1000) == \"CLOSING\"\n    assert w.window_state(db, B, C, T0 + 24 * H - 1) == \"CLOSING\"\n    assert w.window_state(db, B, C, T0 + 24 * H) == \"CLOSED\"\n\n\ndef test_future_timestamp_is_clamped():\n    db = fresh()\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.H\", T0 + 3 * H)), T0)\n    assert w.last_inbound_ms(db, B, C) == T0 + w.MAX_FUTURE_MS\n\n\ndef test_failure_closes_until_next_inbound():\n    db = fresh()\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.I\", T0)), T0)\n    w.record_session_failure(db, B, C, T0 + H, \"send-1\")\n    assert w.window_state(db, B, C, T0 + 2 * H) == \"CLOSED\"\n    w.record(db, w.parse_inbox_row(inbox(\"wamid.J\", T0 + 3 * H)), T0 + 3 * H)\n    assert w.window_state(db, B, C, T0 + 4 * H) == \"OPEN\"\n\n\ndef test_router():\n    assert w.route(\"reply\", \"OPEN\", None).kind == \"session\"\n    assert w.route(\"reply\", \"CLOSING\", \"order_update_v3\").kind == \"template\"\n    assert w.route(\"notify\", \"CLOSED\", None).kind == \"hold\"\n    assert w.route(\"otp\", \"OPEN\", \"login_code\").kind == \"template\"\n    assert w.route(\"marketing\", \"OPEN\", \"promo_nov\").kind == \"template\"\n\n\ndef test_referral_flag():\n    db = fresh()\n    ev = w.parse_webhook(flat(\"wamid.K\", T0 \/\/ 1000,\n                              extra={\"referral\": {\"source_type\": \"ad\"}}))&#91;0]\n    assert ev.referral\n    w.record(db, ev, T0)\n    assert db.execute(\"SELECT referral FROM wa_inbound\").fetchone()&#91;0] == 1\n\n\nif __name__ == \"__main__\":\n    n = 0\n    for name, fn in sorted(globals().items()):\n        if name.startswith(\"test_\"):\n            fn()\n            n += 1\n    print(f\"{n} tests passed\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Wire the webhook handler so it stores first and answers fast. Parse the body, call <code>record()<\/code> for each message inside one transaction, commit, and return 200. Anything slower, like routing a reply, happens after the commit from a queue. If the handler is also matching button taps, the classifier from <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-template-buttons-quick-reply-payload-tap-matching\/\">WhatsApp Template Buttons End to End<\/a> runs on the same stored row, so a tap both resets the window and gets routed by its payload.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"inbox-probe\" class=\"wp-block-heading\">Asking the Inbox When You Have No State<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The webhook is the live source, but you&#8217;ll hit cases where your store has nothing: a new deployment, a webhook outage, a customer who wrote before you started recording. Before you decide <code>NONE<\/code> means &#8220;never wrote&#8221;, ask the inbox. It returns rows newest first and filters by both numbers, so <code>limit=1<\/code> gives you the latest message in one call.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Ask the inbox when a customer last wrote to one business number.\n\/\/ timestamp in the inbox is a NUMBER of milliseconds; the MO webhook\n\/\/ sends a STRING of seconds. Normalise both to ms before comparing.\nconst BASE = 'https:\/\/unify.smsgateway.center\/rest\/wa\/v1\/inbox';\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\nfunction ymd(ms) {\n  return new Date(ms).toISOString().slice(0, 10);\n}\n\nexport async function lastInboundMs(wabaNumber, mobileNo, nowMs = Date.now()) {\n  const params = new URLSearchParams({\n    userid: process.env.SGC_USERID,\n    output: 'json',\n    action: 'list',\n    wabaNumber: String(wabaNumber).replace(\/\\D\/g, ''),\n    mobileNo: String(mobileNo).replace(\/\\D\/g, ''),\n    \/\/ Two days back and one forward: wide enough whatever zone the\n    \/\/ date-only bounds are read in.\n    fromDate: ymd(nowMs - 2 * DAY_MS),\n    toDate: ymd(nowMs + DAY_MS),\n    page: '1',\n    limit: '1', \/\/ newest first, so one row is the latest message\n  });\n  const res = await fetch(`${BASE}?${params}`, {\n    headers: { apikey: process.env.SGC_APIKEY },\n  });\n  const body = await res.json();\n  if (body.status !== 'success') {\n    throw new Error(`inbox read failed: ${body.reason}`);\n  }\n  const row = (body.inboxList || &#91;])&#91;0];\n  if (!row) return null; \/\/ no message in range: treat as no window\n  const ms = Number(row.timestamp);\n  if (!Number.isSafeInteger(ms) || ms &lt; 1577836800000) {\n    throw new Error(`unexpected timestamp unit: ${row.timestamp}`);\n  }\n  return ms;\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The date range is deliberately wide. The inbox filters on received date, and date-only bounds start at 00:00:00 and end at 23:59:59, but in which time zone isn&#8217;t stated. Two days back and one day forward covers any 24-hour window under any plausible zone, and the <code>timestamp<\/code> in the row is what you actually use.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two more details. The function checks the unit of <code>timestamp<\/code> before trusting it, for the same reason the Python parser does. And it reads <code>status<\/code> before touching <code>inboxList<\/code>: an account without WhatsApp enabled gets <code>\"status\": \"error\"<\/code> with a 403 <code>statusCode<\/code> and an empty list, which would otherwise look exactly like &#8220;no messages.&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Feed whatever this returns into the same <code>record()<\/code> path as everything else, using the row&#8217;s <code>waMsgId<\/code> as the key. Then the probe isn&#8217;t a second source of truth, just a late delivery of the first one. For bulk backfill after an outage, page through the inbox for the whole gap with <code>limit=200<\/code> and record every row; the <code>wamid<\/code> key makes it safe to overlap with what the webhook already delivered.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"send-router\" class=\"wp-block-heading\">The Send Router: Session Reply, Template or Hold<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every outbound WhatsApp message goes through one function that looks at two things: what the message is for, and the window state for that pair right now.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Intent<\/th><th>Window OPEN<\/th><th>Window CLOSING, CLOSED or NONE<\/th><\/tr><\/thead><tbody><tr><td><code>reply<\/code> (answering something the customer asked)<\/td><td>Session reply<\/td><td>Approved template if one fits, otherwise hold<\/td><\/tr><tr><td><code>notify<\/code> (order update, appointment change)<\/td><td>Session reply<\/td><td>Utility template, otherwise hold<\/td><\/tr><tr><td><code>otp<\/code><\/td><td>Authentication template<\/td><td>Authentication template<\/td><\/tr><tr><td><code>marketing<\/code><\/td><td>Marketing template<\/td><td>Marketing template<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two rows ignore the window on purpose.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>OTPs always go as authentication templates.<\/strong> Meta gives authentication templates a default delivery time-to-live of 10 minutes, against 30 days for everything else. A session message carrying a code can sit undelivered on an offline phone and arrive the next day, long after the code expired and after you may have issued another. The template&#8217;s short TTL is what you want for a code. If the window is open, that doesn&#8217;t change. <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/otp-delivery-india-reliability-checklist\/\">OTP Delivery in India<\/a> covers the rest of the OTP path, and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design\/\">Multi-Channel Fallback Design<\/a> covers what happens when WhatsApp isn&#8217;t the right channel at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Marketing always goes as a marketing template.<\/strong> Keeping promotional content on one path means one place for opt-out checks (see <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">Opt-Out and Suppression Across Four Channels<\/a>), one category on the invoice and one template to review. A promotion slipped into an open support window as a session message skips all three.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>hold<\/code> outcome is the one teams forget. If the window is closed and you have no approved template that fits, there&#8217;s no way to send that message on WhatsApp. Don&#8217;t fall back to sending it anyway &#8220;to see what happens.&#8221; Put it in a queue for a person, or send it on another channel the customer has agreed to, and record why.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Log the state with every decision. When someone asks why a customer got a paid template at 14:05 when they&#8217;d written at 14:20 the day before, the answer should be one row away.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"safety-margin\" class=\"wp-block-heading\">Leave a Margin at the Edge<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A session reply sent at 23 hours 59 minutes is a coin toss, and you can&#8217;t see the coin.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Between your router deciding &#8220;open&#8221; and WhatsApp checking the window there&#8217;s your own queue, the HTTP call to <code>WAApi\/send<\/code>, the platform&#8217;s processing and Meta&#8217;s. Whether the window is checked when the request is accepted or when the message is delivered isn&#8217;t something you can read anywhere, and the delay on a busy account isn&#8217;t published. So stop sending session messages a little before the edge.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The module uses 30 minutes. The trade-off is easy to reason about:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Too small a margin<\/strong> risks failed session sends at the edge. Each one costs a delay, a failure to handle, and a template anyway.<\/li>\n\n\n\n<li><strong>Too large a margin<\/strong> sends templates to people whose window was still open. Under Meta&#8217;s per-message pricing, a utility template inside an open window is free, so for notifications the extra cost of a generous margin can be zero. For replies, it means using a template where a free-form message would have done.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Pick the margin from your own data. Once you have a few weeks of sends, compare <code>window_state_at_send<\/code> against delivery outcomes and look at how close to the edge your failures start. Shrink the margin until you see them, then back off.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The margin also absorbs the small things: a few seconds&#8217; difference between your clock and Meta&#8217;s, rounding of the webhook&#8217;s whole seconds, the time a message spent in your own retry queue. Keep server clocks on NTP anyway. A margin can cover drift, not a clock that&#8217;s minutes out.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"session-send\" class=\"wp-block-heading\">Sending a Session Reply on WAApi\/send<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A session reply is a normal <code>WAApi\/send<\/code> call without <code>templateName<\/code>. The message text goes in <code>msg<\/code>, fully rendered.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\n# Session reply: no templateName, fully rendered text in msg.\n# Only call this after your window check says OPEN.\nset -euo pipefail\ncurl --silent --show-error --request POST \\\n  'https:\/\/unify.smsgateway.center\/WAApi\/send' \\\n  --header \"apikey: ${SGC_APIKEY}\" \\\n  --form-string \"userid=${SGC_USERID}\" \\\n  --form-string 'wabaNumber=9170396xxxxx' \\\n  --form-string 'mobile=919876543210' \\\n  --form-string 'sendMethod=quick' \\\n  --form-string 'msgType=text' \\\n  --form-string 'msg=Your order 48213 left our warehouse today. Track it here: https:\/\/example.com\/t\/48213' \\\n  --form-string 'output=json'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Four things to get right:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Render the text yourself.<\/strong> Template sends on this platform carry <code>{#var#}<\/code> placeholders in <code>msg<\/code> that line up with an approved template. A session message has no template behind it, so send the final text the customer should see.<\/li>\n\n\n\n<li><strong>Use <code>--form-string<\/code>, not <code>-F<\/code>,<\/strong> for anything you didn&#8217;t type yourself. With <code>-F<\/code>, a value starting with <code>@<\/code> or <code>&lt;<\/code> is read as a file path.<\/li>\n\n\n\n<li><strong>Send the <code>apikey<\/code> header with <code>userid<\/code>.<\/strong> The header works on every endpoint and keeps the password out of request bodies and logs.<\/li>\n\n\n\n<li><strong>Store the response before anything else.<\/strong> The send page&#8217;s sample response returns <code>status<\/code>, <code>messageId<\/code>, <code>mobile<\/code>, a numeric <code>statusCode<\/code> of 200 and <code>description<\/code>. Save the whole body against your outbound row, then look up the result on <code>WAApi\/report<\/code> once delivery reports arrive.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">For media inside the window, the same call takes <code>msgType=media<\/code> with <code>mediaType<\/code> and either <code>mediaId<\/code> or <code>mediaUrl<\/code>, as in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">The WhatsApp Business API Wire Contract<\/a>. The window rule doesn&#8217;t change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you send several session messages in a row and their order matters, wait for each one to be delivered before sending the next. Meta doesn&#8217;t guarantee that messages arrive in the order you sent them.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"session-failure\" class=\"wp-block-heading\">When a Session Send Fails<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A session send can fail even when your router did everything right: the margin was too thin, a message from the customer you thought you had never existed, or Meta&#8217;s rare can&#8217;t-respond case hit. Plan for it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On this platform, the outcome of a send shows up as a row on <code>WAApi\/report<\/code> with a <code>status<\/code> such as <code>DELIVERED<\/code>, <code>READ<\/code> or <code>FAILED<\/code>, plus <code>cause<\/code> and <code>globalErrorCode<\/code>. (Meta&#8217;s own code for &#8220;more than 24 hours since the customer last replied&#8221; is 131047, listed in its <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/support\/error-codes\" target=\"_blank\" rel=\"noopener nofollow\">error code reference<\/a>.) When you see <code>FAILED<\/code> on a send that went out without <code>templateName<\/code>, do three things:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Record a session failure<\/strong> for the pair, with the time it happened. In the module that&#8217;s <code>record_session_failure()<\/code>, and from then on <code>window_state()<\/code> returns <code>CLOSED<\/code> for that pair until the customer writes again.<\/li>\n\n\n\n<li><strong>Don&#8217;t retry it as a session message.<\/strong> A retry that fails the same way just adds a second failure. If the message still matters, send it once through the router, which will now pick a template or hold it.<\/li>\n\n\n\n<li><strong>Keep <code>cause<\/code> and <code>globalErrorCode<\/code> with the outbound row,<\/strong> verbatim. They&#8217;re how you&#8217;ll tell window failures apart from everything else later, once you&#8217;ve seen enough of them.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">This works even if you never learn which <code>globalErrorCode<\/code> means &#8220;window closed&#8221;. Treating every failed session send as a possible closed window costs at most one template you didn&#8217;t need. Treating it as transient and retrying can cost a stream of failures and an annoyed customer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To pull those failures, poll <code>WAApi\/report<\/code> with <code>fromDate<\/code> and <code>toDate<\/code>, <code>pageLimit<\/code>, and <code>startCursor<\/code> (start with 1, then pass <code>data.cursors.next<\/code>), read the outer <code>status<\/code> first, then walk <code>data.records<\/code>. Store <code>uuId<\/code> and <code>mobileNo<\/code> as text, because they come back as unquoted numbers and <code>uuId<\/code> is too long for a double. <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">Delivery Report Ingestion<\/a> covers paging and backfilling report windows.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"agent-queues\" class=\"wp-block-heading\">Agent Queues and Replies That Wait Overnight<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Human agents are where windows quietly expire. A customer writes at 18:40 on Friday, the ticket sits in a queue, and an agent picks it up on Saturday at 19:00. The window closed at 18:40. The agent types a reply, hits send, and it fails.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two changes stop that:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Show the clock in the agent&#8217;s view.<\/strong> Every open conversation should carry &#8220;window closes at 18:40 Sat&#8221; next to it, computed from the same view as the router. Sort the queue by that, not only by arrival time, so a conversation near its edge gets picked up before one that has 20 hours left.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Re-check at send time, not at pick-up time.<\/strong> The router runs when the agent presses send, not when they opened the ticket. If the window closed in the meantime, the agent&#8217;s draft can&#8217;t go as a session message. Offer the matching template, or tell the agent plainly that the customer has to write again first. Don&#8217;t silently swap a carefully written reply for a generic template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The inbox API gives you most of what you need for this view: filter with <code>isReplied=0<\/code> and <code>wabaNumber<\/code>, and each row&#8217;s <code>timestamp<\/code> tells you when the window closes. One caution: use the inbox&#8217;s <code>isReplied<\/code> flag only for the agent queue, never for the window. The window depends on the customer&#8217;s messages alone, and whether <code>isReplied<\/code> reflects replies sent through the API as well as the portal isn&#8217;t something to rely on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The same applies to anything you schedule. A &#8220;we&#8217;ll message you tomorrow at 10:00&#8221; promise made inside the window may land outside it. Decide the route when the message is due, not when it&#8217;s booked. <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/scheduling-messages-sms-whatsapp-rcs-telegram\/\">Scheduling Messages Across Four Channels<\/a> covers why the decision belongs in your own scheduler.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"cost\" class=\"wp-block-heading\">What the Window Does to Your Bill<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Since July 2025, Meta charges per message rather than per 24-hour conversation. The window now matters for price in three ways:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Message<\/th><th>Inside an open service window<\/th><th>Outside it<\/th><\/tr><\/thead><tbody><tr><td>Session message (no template)<\/td><td>Free<\/td><td>Not allowed<\/td><\/tr><tr><td>Utility template<\/td><td>Free<\/td><td>Charged<\/td><\/tr><tr><td>Marketing template<\/td><td>Charged<\/td><td>Charged<\/td><\/tr><tr><td>Authentication template<\/td><td>Charged<\/td><td>Charged<\/td><\/tr><tr><td>Any message inside an open free entry point window<\/td><td>Free<\/td><td>(FEP closed) normal rules<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s from Meta&#8217;s <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/pricing\" target=\"_blank\" rel=\"noopener nofollow\">pricing page<\/a>, and the platform&#8217;s <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-does-whatsapp-per-message-pricing-work\/\">per-message pricing entry<\/a> says the same and points you to the Rate Plan tab for your per-country rates by category.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two engineering points fall out of the table.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The router&#8217;s choices for <code>notify<\/code> inside the window are both free,<\/strong> a session message or a utility template. Choose on other grounds: a session message is flexible, a template keeps the copy approved and consistent. Outside the window, every utility notification costs one template. So the cheapest notification system isn&#8217;t one that sends fewer templates. It&#8217;s one that sends the notification while the customer&#8217;s window is still open, when that fits the business event.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Your estimate should follow the router, not a flat rate.<\/strong> For each planned send, take the state from the view, the intent and the template category, and price it from your rate plan. Under Meta&#8217;s rules, a batch of 10,000 order updates to people who wrote in the last few hours costs nothing; the same batch a week later is 10,000 utility templates. <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/reconciling-messaging-invoice-four-channels\/\">Reconciling a Messaging Invoice Across Four Channels<\/a> covers matching that estimate against what you&#8217;re actually billed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Meta notes that whenever a template is used, the business accepts the charges of the category applied to it at that time, and it can re-categorise a template. Read categories from your template registry, refreshed from the platform, rather than from what you submitted. <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-template-management-four-channels\/\">Message Template Management Across Four Channels<\/a> shows how to keep that registry current.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision Matrix<\/h2>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Situation<\/th><th>Window state<\/th><th>Route<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>Customer asked a question 2 hours ago<\/td><td>OPEN<\/td><td>Session reply<\/td><td>Allowed, free, no approval needed<\/td><\/tr><tr><td>Customer wrote 23 h 45 m ago, agent replying now<\/td><td>CLOSING<\/td><td>Template that fits, or ask them to write again<\/td><td>Inside the 30-minute margin<\/td><\/tr><tr><td>Order shipped, customer wrote yesterday afternoon<\/td><td>Depends on the hour<\/td><td>Session or utility template inside, utility template outside<\/td><td>Both free inside, template charged outside<\/td><\/tr><tr><td>Login code<\/td><td>Any<\/td><td>Authentication template<\/td><td>10-minute TTL, always charged<\/td><\/tr><tr><td>Weekend offer<\/td><td>Any<\/td><td>Marketing template<\/td><td>One path for promotional content and opt-outs<\/td><\/tr><tr><td>Session reply came back <code>FAILED<\/code><\/td><td>CLOSED<\/td><td>Template once, or hold<\/td><td>Never retry as a session message<\/td><\/tr><tr><td>Customer never wrote to this number<\/td><td>NONE (after an inbox probe)<\/td><td>Template, or hold<\/td><td>No window exists<\/td><\/tr><tr><td>Customer wrote to your other WABA number<\/td><td>NONE for this number<\/td><td>Template from this number, or reply from the number they used<\/td><td>The window is per pair<\/td><\/tr><tr><td>Ad click, you replied within 24 hours, now 30 hours later<\/td><td>CLOSED (FEP open)<\/td><td>Template, free while the FEP window lasts<\/td><td>FEP changes price, not permission<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"checklist\" class=\"wp-block-heading\">Launch Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>MO webhook configured for every WABA number, storing before answering 200.<\/li>\n\n\n\n<li>Webhook parser multiplies <code>timestamp<\/code> by 1,000; inbox parser doesn&#8217;t; both range-check the result.<\/li>\n\n\n\n<li>Business and customer numbers stored as digit strings, keyed as a pair.<\/li>\n\n\n\n<li>Inbound messages stored once by <code>wamid<\/code>, earliest time kept.<\/li>\n\n\n\n<li><code>RESETTING_TYPES<\/code> reviewed against the message types in your own logs.<\/li>\n\n\n\n<li>Window derived as <code>MAX(at_ms) + 24 h<\/code>, never kept as a timer.<\/li>\n\n\n\n<li>Future timestamps clamped to receive time plus a few minutes.<\/li>\n\n\n\n<li>Safety margin set (30 minutes to start) and reviewed against delivery outcomes.<\/li>\n\n\n\n<li>Every send goes through one router with intent and state; the state is logged with the send.<\/li>\n\n\n\n<li>OTPs always as authentication templates; marketing always as marketing templates.<\/li>\n\n\n\n<li>A <code>hold<\/code> path exists, with a queue or another consented channel behind it.<\/li>\n\n\n\n<li><code>FAILED<\/code> session sends recorded as failures; no session retries.<\/li>\n\n\n\n<li>Agent view shows each conversation&#8217;s closing time and re-checks at send.<\/li>\n\n\n\n<li>Inbox probe used when the store has no row, with a wide date range.<\/li>\n\n\n\n<li>Backfill job replays the inbox for any webhook outage.<\/li>\n\n\n\n<li>Cost estimates use state, intent and template category, not a flat rate.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some behaviours around the window aren&#8217;t pinned down by anything you can read today. Each item below gives the choice that stays correct either way, so none of them needs an answer before you ship.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Gate session sends before the call, and handle a failure after it.<\/strong> Whether an out-of-window session send is refused in the <code>WAApi\/send<\/code> response or accepted and reported later as <code>FAILED<\/code> isn&#8217;t stated. Check the window first, then treat a refusal and a <code>FAILED<\/code> row the same way: record a session failure and route again. Correct under both behaviours.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Treat every failed session send as a possibly closed window.<\/strong> Which <code>globalErrorCode<\/code> or <code>cause<\/code> marks &#8220;window closed&#8221; isn&#8217;t listed. If you shut the window on any session failure, the worst case is one template you didn&#8217;t strictly need. Keep the raw codes, and once you&#8217;ve seen enough of them you can narrow the rule.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Stop session sends before the edge, not at it.<\/strong> Whether the window is checked at acceptance or at delivery is unknown, and so is the queueing delay. A margin makes both answers irrelevant: if you never send in the last 30 minutes, it doesn&#8217;t matter which moment counts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. When two sources disagree on a message&#8217;s time, keep the earlier one.<\/strong> The webhook and the inbox could stamp the same <code>wamid<\/code> a little differently, and which clock each uses isn&#8217;t documented. The earlier value closes the window sooner, so it can cost a template but never a failed send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Let only listed message types move the clock.<\/strong> Meta&#8217;s rule says a message from the customer resets the window, but whether every webhook type counts (reactions, system notices, types added later) isn&#8217;t spelled out. An allowlist fails closed: an unlisted type delays nothing worse than a template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Never let a timestamp from the future hold a window open.<\/strong> Clamp each inbound time to your receive time plus five minutes. A sender clock that runs ahead then can&#8217;t keep a window alive after it&#8217;s really closed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Count calls only when you can see them.<\/strong> Meta says a call from the customer also resets the window, and calls don&#8217;t show up in the inbox or the MO webhook samples. Ignoring them can only make your window close early. It can never make you send into a closed one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Only use the free entry point window for cost estimates, and only when you see referral data.<\/strong> Whether the platform&#8217;s MO webhook passes Meta&#8217;s <code>referral<\/code> object through isn&#8217;t shown in the sample, and the FEP rules depend on the device the customer used. Price a message as charged unless you&#8217;ve seen the referral, and never let FEP change what you&#8217;re allowed to send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Query the inbox with a wider date range than the window.<\/strong> The time zone of date-only <code>fromDate<\/code> and <code>toDate<\/code> isn&#8217;t given. Two days back and one forward covers any zone, and you filter on the row&#8217;s own <code>timestamp<\/code> afterwards.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Leave <code>templateName<\/code> out for session sends and confirm on the report.<\/strong> The parameter table marks <code>templateName<\/code> as required while the note under it says to remove it for session messages. Follow the note, store the response, and confirm the outcome from the <code>WAApi\/report<\/code> row rather than from the send response alone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Don&#8217;t read the window from <code>isReplied<\/code>.<\/strong> Whether that flag tracks replies sent through the API as well as the portal isn&#8217;t stated. The window depends only on the customer&#8217;s messages, which you&#8217;re already storing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Send session text fully rendered.<\/strong> There&#8217;s no template behind a session message, so nothing fills placeholders for you. Whatever is in <code>msg<\/code> is what the customer will see.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Didn&#8217;t per-message pricing remove the 24-hour window?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Per-message pricing, which started on 1 July 2025, replaced the old 24-hour conversation charge. The 24-hour customer service window is a separate rule, and it still applies. Meta&#8217;s service messages page says that once the window closes you can only send pre-approved templates. Its pricing page says non-template messages can only be sent within an open window, and that utility templates sent inside it are free. So the window now controls what you can send and part of what you pay.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does my reply extend the 24-hour window?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Only the customer&#8217;s messages (and calls) reset it. Meta&#8217;s own pricing example closes the window at hour 26 because &#8220;24 hours have passed since the user last messaged you&#8221;, no matter what the business sent in between.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Does sending a template open the window?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. A template goes out whether the window is open or not. If the customer replies to it, by typing or tapping a quick reply, that reply opens the window from the moment it arrives.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I send a session message on SMSGatewayCenter?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Call <code>WAApi\/send<\/code> without <code>templateName<\/code>, with the full text in <code>msg<\/code>. Check the window yourself first, because there&#8217;s no endpoint that reports it, and confirm the outcome on <code>WAApi\/report<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why do all my windows look closed?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Usually a unit or format bug. Check that the webhook <code>timestamp<\/code> (string seconds) is multiplied by 1,000 and the inbox <code>timestamp<\/code> (number milliseconds) isn&#8217;t, and that both numbers in your key are digits only on both sides of the lookup.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is the window per customer or per business number?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Per pair. A customer with an open window on your support number has no window on your sales number until they write to that one too.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I send an OTP as a session message if the window is open?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">You can, but don&#8217;t. Authentication templates get a 10-minute default time-to-live, while other messages get 30 days, so a session OTP can arrive long after it expired. Use the authentication template every time.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Are session messages free?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, under Meta&#8217;s per-message pricing, service messages inside an open window carry no Meta charge, and so do utility templates sent inside it. Marketing and authentication templates are always charged. Your account&#8217;s rates by category are in the Rate Plan tab.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What&#8217;s the free entry point window?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A 72-hour window in which every message, templates included, is free. It opens when a customer reaches you from a Click to WhatsApp ad or a Facebook Page button on the mobile app and you reply within 24 hours. It changes the price, not what you&#8217;re allowed to send.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What should I do when a session reply fails?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Record a session failure for that pair so your router treats the window as closed, don&#8217;t retry it as a session message, and route the message again, which will pick a template or hold it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How big should the safety margin be?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Start at 30 minutes and tune it with your own delivery data. The right margin is the smallest one where failed session sends near the edge stop appearing.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Know Which Messages Still Fit Inside the Window<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Want session replies that land, templates only when you need them, and a cost forecast that matches the invoice? Set up the <a href=\"https:\/\/www.smsgatewaycenter.com\/whatsapp-business-api\/\">WhatsApp Business API<\/a> on <strong>SMSGatewayCenter<\/strong>, point the MO webhook at your own endpoint, and start routing every send through the window. If you&#8217;d like help with the setup, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to the team<\/a> or <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">try the sandbox first<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">More from our blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-template-buttons-quick-reply-payload-tap-matching\/\">WhatsApp Template Buttons End to End: Quick Reply Payloads, Dynamic URL Buttons and Matching the Tap<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/missed-call-verification-lead-capture-callback-design\/\">Missed Call Verification and Lead Capture: Callback Design, Session Matching and the Lead Pipeline<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/lossless-json-parsing-messaging-api-19-digit-ids\/\">Lossless JSON Parsing for Messaging APIs: 19-Digit IDs, Mixed Types and JSON Inside JSON<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-rich-cards-carousels-content-model-validation\/\">RCS Rich Cards and Carousels as a Content Model: Validate, Version and Send Cards Without Surprises<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design\/\">Multi-Channel Fallback Design: Platform Failover or Your Own Orchestrator for SMS, WhatsApp, RCS and Voice OTP<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>The WhatsApp customer service window decides whether you can send a free-form reply or need an approved template, and it follows the customer&#8217;s last message, not yours. This guide shows how to track the window from the MO webhook and the inbox API, normalise their different timestamp units, derive the window instead of counting it, leave a safety margin at the edge, and route every send to a session reply, a template or a hold.<\/p>\n","protected":false},"author":118,"featured_media":3167,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2412,1393,1391,2413,2411,2414,2410,632,2409,2408],"class_list":["post-3166","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-24-hour-window","tag-customer-service-window","tag-per-message-pricing","tag-send-routing","tag-session-message","tag-state-machine","tag-template-message","tag-whatsapp-business-api","tag-whatsapp-inbox-api","tag-whatsapp-webhook"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3166","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=3166"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3166\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3167"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3166"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3166"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3166"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}