DEV Community

Cover image for Modelling WhatsApp's 24-Hour Window Correctly (It's Harder Than a Timer)
WAPGROWAI for WAPGROWAI

Posted on

Modelling WhatsApp's 24-Hour Window Correctly (It's Harder Than a Timer)

Every WhatsApp integration eventually needs one function: can I send this customer a normal message right now? It looks like a timer — 24 hours since the customer last wrote — and most first implementations treat it that way. Then the bugs arrive: agents typing replies that WhatsApp rejects, chats marked "expired" that never had a window at all, filters that disagree with the badge next to them.

This post walks through how the WhatsApp 24-hour window actually works, the five modelling mistakes that cause those bugs, and the model we use in WAPGROWAI's team inbox. The snippets are small, framework-free TypeScript, and every one was run against the cases it describes.

The rule, precisely

From Meta's send-messages documentation:

When a WhatsApp user messages you or calls you, a 24-hour timer called a customer service window starts. If the user messages or calls you again before the timer expires, the timer resets to 24 hours.

Inside the window you can send any message type — text, media, interactive messages. Once it closes, you can only send pre-approved templates until the customer writes again. Try a free-form message anyway and the send fails with error 131047 ("More than 24 hours have passed since the recipient last replied").

Two details matter for everything below. The window is opened by the customer — a message or a call — never by you. And it is measured from Meta's clock, not yours.

It has also become a cost question: since 1 October 2026, Meta charges for service messages sent inside the window, so knowing exactly when it is open matters for the bill as well as the UX.

Mistake 1: Timing the window from when you received the message

The obvious implementation stamps new Date() when the webhook arrives. But webhooks are retried — Meta retries failed deliveries for up to 7 days — and a backlog or an outage can delay processing by minutes or hours. Anchor on your receive time and you believe you have time left that Meta doesn't agree you have. The free-form send fails, the agent doesn't know why.

Every inbound message carries Meta's own timestamp, as Unix seconds in a string. Use it:

// window.ts
export const SERVICE_WINDOW_MS = 24 * 60 * 60 * 1000;

export type WindowState = "awaiting_reply" | "open" | "expired";

// Meta sends Unix seconds as a string, e.g. "1749416383".
export const toWhatsAppDate = (timestamp?: string): Date | null => {
  const seconds = Number(timestamp);
  return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000) : null;
};

export const windowState = (lastInboundAt: Date | null, now = new Date()): WindowState => {
  if (!lastInboundAt) return "awaiting_reply";
  return now.getTime() - lastInboundAt.getTime() < SERVICE_WINDOW_MS ? "open" : "expired";
};
Enter fullscreen mode Exit fullscreen mode

In WAPGROWAI the anchor is a single column, conversations.last_inbound_at, set from toWhatsAppDate(message.timestamp), falling back to the insert time only if Meta's timestamp is missing.

Mistake 2: Letting your own messages open the window

This one is subtle because the code that causes it looks harmless: a shared findOrCreateConversation() helper that also "touches" the conversation. Campaigns, order notifications, CRM integrations and API sends all call that helper for outbound messages — and if it refreshes the window, your inbox now shows a chat as replyable when WhatsApp will reject anything but a template.

The fix is structural: exactly one function opens the window, and only the inbound paths call it — an incoming message (after it is safely stored) and an incoming call. Outbound helpers resolve the thread and never touch the anchor; a conversation created by a campaign starts with no anchor at all.

That one function should also only ever move the anchor forward. A retried webhook or an imported chat history can deliver an older message after a newer one, and it must not shorten a window that is already open. Doing it in the UPDATE itself makes it atomic:

UPDATE conversations
SET last_inbound_at = :at
WHERE id = :id
  AND (last_inbound_at IS NULL OR last_inbound_at < :at);
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Two states when there are three

A boolean isExpired can't tell these two apart:

last_inbound_at State Meaning
NULL awaiting_reply The customer has never written. No window ever existed.
less than 24h ago open Free-form messages allowed.
24h or more ago expired A window existed and has closed.

awaiting_reply and expired both block free-form messages, so it is tempting to merge them. Don't. They mean different things to the people reading your data: an expired chat is one your team let lapse; an awaiting reply chat is one where you messaged first — say, a campaign — and the customer hasn't answered. Counting the second kind as "expired" inflates your missed-conversation numbers with chats nobody could have answered.

The windowState() function above returns all three. At exactly 24 hours it returns expired, matching Meta's "24 hours have passed".

Mistake 4: Trusting a stored is_expired flag

Windows close on their own, with no event to react to. So most systems run a scheduler that flips a stored is_expired flag for chats past the cutoff. That flag is useful — it is cheap to index and filter on — but it lags: WAPGROWAI's scheduler runs every 60 seconds, so for up to a minute the flag says "open" about a chat Meta already considers closed.

The rule we follow: derive, don't read. The anchor is the source of truth; the flag is a mirror.

  • The send gate derives the state from last_inbound_at at the moment of sending:
// send-gate.ts
import { windowState } from "./window.ts";

export const assertCanSend = (
  message: { type: string },
  conversation: { lastInboundAt: Date | null },
  now = new Date(),
) => {
  if (message.type === "template") return;

  const state = windowState(conversation.lastInboundAt, now);
  if (state === "awaiting_reply") {
    throw new Error("This contact hasn't replied yet. Only approved templates can be sent.");
  }
  if (state === "expired") {
    throw new Error("The 24-hour window has closed. Only approved templates can be sent.");
  }
};
Enter fullscreen mode Exit fullscreen mode
  • List filters compare the anchor against a cutoff, not the flag. Compute the cutoff in your application and pass it as a parameter:
const cutoff = new Date(Date.now() - SERVICE_WINDOW_MS);
// open:           last_inbound_at >= cutoff
// expired:        last_inbound_at <  cutoff
// awaiting_reply: last_inbound_at IS NULL
Enter fullscreen mode Exit fullscreen mode

Don't write NOW() - INTERVAL 24 HOUR in SQL unless you are certain of your database session's time zone. If the driver stores UTC but the MySQL session runs in local time, NOW() is off by the UTC offset, and your filters disagree with your badges by hours.

  • The UI shouldn't poll either. Arm one timer for the exact moment the window closes:
// expiry-timer.ts
import { SERVICE_WINDOW_MS } from "./window.ts";

// Re-render exactly when the window closes, instead of polling.
export const onWindowClose = (lastInboundAt: Date | null, callback: () => void, now = Date.now()) => {
  if (!lastInboundAt) return () => {};
  const msLeft = lastInboundAt.getTime() + SERVICE_WINDOW_MS - now;
  if (msLeft <= 0) return () => {};
  const timer = setTimeout(callback, msLeft);
  return () => clearTimeout(timer);
};
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Mixing the window with workflow status

Inboxes have a workflow status — open, pending, solved — that an agent sets. It is tempting to add "expired" as one more status. But they are independent facts: a chat can be solved and expired (the issue was handled, the customer went quiet) or open and expired (an agent still owns it, and now needs a template to follow up).

Model them as two separate fields, with two separate filters. In WAPGROWAI's API, status is the workflow (open, pending, solved), and the window is a separate filter (window_open, expired, awaiting_reply) that can be combined with it. "Solved and expired" is then a normal query instead of an impossible one.

Edge cases worth handling

  • Calls open the window too. Meta's docs say a call from the customer starts the 24 hours, and providers such as 360dialog document the same when a customer answers a call your business placed; WAPGROWAI records both. If your voice integration doesn't record call activity, your inbox will push agents onto paid templates right after a conversation by phone.
  • Imported history must not rewind. When you import past chats — for example when a WhatsApp Business app number is connected with coexistence — record activity with the historical timestamps, quietly (no notifications), and rely on the forward-only update.
  • Blocked contacts don't count. Drop their messages before recording activity, so a blocked number can't keep a window alive.
  • Decide what counts. WAPGROWAI doesn't refresh the window for reactions or unsupported message types. That is the conservative choice: if you're wrong in that direction, an agent sends a template they didn't strictly need; wrong in the other, a reply gets rejected.

What the person in the inbox sees

All of this exists so an agent never has to think about it. In WAPGROWAI's inbox, when the window is closed the reply box is replaced by a short explanation and a Send Template button — different wording for "this customer has never replied" and "the 24-hour window has closed". The help guide on templates and the 24-hour window shows both states.

WAPGROWAI inbox chat with the window closed: Open status, Expired badge and a Send Template button
A chat whose window has closed: status Open, window Expired, and a reply box that offers only templates.

The same model drives reporting. A "missed chat" is a conversation whose window expired with no first response — which is only meaningful because never-replied chats are excluded from "expired" in the first place.

Summary

  • Anchor on Meta's timestamp, not your receive time.
  • Only customer messages and calls open the window — one function, forward-only.
  • Three states: awaiting_reply, open, expired.
  • Derive the state from the anchor everywhere; keep any stored flag as a mirror.
  • Keep the window separate from workflow status.

None of it is complicated once it is modelled correctly, and every shortcut shows up later as a rejected message or a number in a report that nobody trusts.


I work on WAPGROWAI, a WhatsApp Business Platform; the model above is the one running in its team inbox. For how a support team works with these rules day to day, see What Happens After a Customer Messages Your Business on WhatsApp. For handling the webhooks that feed all of this, see the previous post in this series.

Top comments (0)