Build1 publisher3 min readPublished
Parsing Meta's epoch-seconds webhook timestamp with new Date() bills every WhatsApp reply as a template
Developers who parse Meta's epoch-seconds webhook timestamp with new Date() get NaN and pay template rates on every WhatsApp reply, according to a dev.to post. The opposite slip, mixing milliseconds with seconds, holds the window open so free-form replies bounce silently.
The Engineer · Build desk

What happened
- Meta's Cloud API allows free-form WhatsApp messages only within 24 hours of the customer's last message; outside that window, only approved templates go through.
- The post's fix converts every timestamp to epoch seconds at the boundary and falls back to a template whenever a timestamp is unreadable or in the future.
- The 24-hour window resets on the customer's inbound message, not on the business's outbound reply.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- cost The NaN bug still delivers every reply, so it passes delivery testing while the operator pays template rates on conversations that sat inside the free window.
- exposure Under the unit mix-up customers stop getting free-form replies, and alerting that keys on errors stays quiet because the logs record nothing.
- constraint Every route still has to answer the window question before each send, so picking a tool only changes who writes, and owns, the timestamp check.
- decision The last-inbound time has to be stamped in the inbound webhook handler; stamping outbound sends keeps the window reading open after it has closed.
`new Date("1753276800")` does not read a numeric string as seconds. It returns an Invalid Date [6]. Subtract that from `Date.now()` and you get NaN, and `NaN < 86400000` evaluates to false [6]. The free-form branch never runs. According to the dev.to post that documents the bug, the automation keeps answering customers and every answer is billed as a template [6]. Meta's webhook sends that timestamp as a string of epoch seconds [5], so the naive check breaks on the first real payload. It breaks toward the template path, where messages still get delivered.
The reverse mistake costs more. If one code path stores `Date.now()` in milliseconds and another stores epoch seconds, elapsed time comes out a thousand times too small, the post says, and the window reads as open long after it closed [7]. A thousand-fold error turns a 24-hour window into 24,000 hours, about 1,000 days [13]. As the post describes Meta's rule, anything other than an approved template sent outside the window comes back as a re-engagement rejection [3]. The free-form replies bounce, and in the author's account nothing in the logs says why [7].
The post's wider argument is that the choice of tool does not move this problem. It lists three routes: a SaaS platform, Meta's Cloud API wired up directly, or Google Sheets with Apps Script [1]. All three, it says, send through the same Cloud API under the same rule [2]. Whether a given SaaS vendor's window check has either bug depends on that vendor's code, and the post does not test any SaaS implementation.
The fix is dull and correct. A `toEpochSeconds` function converts every shape the timestamp arrives in (Date objects, numbers, digit strings, date strings) to epoch seconds at the boundary. It treats any number above 1e11 as milliseconds [8]. Nothing downstream ever sees a millisecond. The inline comment says "Past year 2286 in seconds is really milliseconds" [8]. Year 2286 is where 1e10 seconds lands; 1e11 seconds is closer to year 5138 [14]. The comment is off by roughly 2,850 years. It does no harm: today's timestamps sit near 1.75e9 in seconds and 1.75e12 in milliseconds, and either cutoff falls between them [15].
The decision function, `replyMode`, fails closed. An unreadable timestamp returns template, and so does a timestamp in the future [9][10]. The naive code also fell to template on bad input, but only because of how NaN compares. This version makes it an explicit branch. The author wrote that it exists "because a template sent inside an open window costs money and a free-form sent outside one does not arrive at all." [9] I think that is the right default for a support inbox. An unneeded template charge shows up on the bill, while a bounced free-form reply, per the post, leaves nothing in the logs [7].
The last input is where the timestamp comes from. The window resets on the customer's message, not on the business's reply. "This is the detail that decides whether the rest of the code can ever be right," the author wrote [11]. In the Sheets build, Meta posts every inbound message to a `doPost` handler [12]. The post warns that it is easy to record the wrong event because the outbound path is the one being debugged [11]. Stamping outbound sends would reset the clock on the business's own messages and report an open window after the customer's 24 hours had run out [16].
What to watch
- Any change to how Meta's webhook encodes the timestamp field, since the new Date() failure depends on it arriving as a digit string.
- Whether SaaS WhatsApp platforms expose their window state, such as a seconds-left value, so customers can verify the check themselves.
- Template charges rising while inbound message volume stays flat, the billing pattern the NaN bug would produce.