Build notes · Reliability

The WhatsApp quiet hours that were loudest at 3am

· found in a blocking-decision review · Founder, Klaros

We had a quiet-hours gate. It worked. It compared the current time against the configured window and refused to send inside it, and it had passed review, and it was exactly backwards for most of the planet.

The gate called getUTCHours(). That is the server's idea of what time it is, which is nobody's. Every regulation that defines quiet hours defines them at the called party's location: the US TCPA sets 8pm to 9am local, and several states are stricter at 8pm to 8am. A window is a statement about the recipient's evening, and we were evaluating it against Greenwich.

TL;DR

The window inverted rather than drifted. For a recipient in India, a 21:00 to 08:00 window evaluated in UTC silenced sending from 02:30 to 13:30 their time, and permitted it at 03:00 local.

Nothing errored. Messages were withheld and sent exactly as designed. The only symptom is engagement quietly getting worse, which is indistinguishable from the market being hard.

Store a zone, not an offset. Offsets move twice a year, on dates that differ by country and change by legislation.

An unknown zone must fail open. Silencing somebody on the basis of information you do not have is the worse error.

What the inversion actually looks like

India is UTC+05:30, which makes the arithmetic easy to follow. Take a configured window of 21:00 to 08:00, intended to mean "do not message people late at night".

Recipient localUTCIntendedWhat happened
03:0021:30blockedsent
07:0001:30blockedsent
10:0004:30allowedblocked
13:0007:30allowedblocked
18:0012:30allowedsent

The effective silence ran from 02:30 to 13:30 local, which is most of a working day, and the hours we most wanted to protect were the ones we sent into. The error is not proportional to distance from Greenwich in a gentle way either: it is worst exactly where a deployment is furthest from it, which is most of the places WhatsApp is actually used.

Why nobody noticed

Because nothing failed. A quiet-hours gate that refuses to send produces no error, no retry and no alert; the message simply does not go, and the send path reports that it behaved correctly. Meanwhile the messages that went at 3am arrived, delivered, and looked like normal successful traffic.

The only downstream signal is worse engagement, and worse engagement is the most over-explained metric there is. It gets attributed to copy, to timing strategy, to the market. It is rarely attributed to a timezone, because a timezone is not a thing that appears on the dashboard where the disappointment lives.

Zones, not offsets

The tempting fix is to store the recipient's UTC offset and add it. That is wrong for months at a time. Offsets move with daylight saving, the transition dates differ by country, and legislatures change them with modest notice. A stored +05:30 is fine for India and quietly wrong for half the year in a dozen other markets.

So the window is evaluated in the recipient's IANA zone at the moment of the check, which is what Intl is for.

src/services/policy-engine.mjs

const nowMins = timezone
  ? localMinutes(now, timezone)
  : now.getUTCHours() * 60 + now.getUTCMinutes();
if (nowMins === null) return false;

That last line is the part worth arguing about. localMinutes returns null for a zone it cannot resolve, and the function then reports "not in quiet hours" — it fails open.

Failing closed feels safer and is not. An unknown timezone is missing information, and treating missing information as a reason to withhold means silencing somebody on the basis of nothing, invisibly, with no error anyone can debug. Failing open risks a message at an awkward hour for a contact whose zone we do not know. That is a real cost and it is the smaller one.

The cap in the same commit

The same review turned up a second thing, and it is the more embarrassing half because we already knew about it.

Meta's error 131049, "not delivered to maintain healthy ecosystem engagement", means the recipient is over their own per-user marketing limit. Meta's guidance is to stop rather than retry, because retrying inside the window is a guaranteed failure that spends nothing but the number's quality rating.

Our audience resolver had excluded recently-capped contacts from campaigns for some time. Nothing applied the same exclusion to the lifecycle senders, so on a real trial reminder walked into a wall we already had a map of, and reported the failure as though nothing could have been done. The check now lives in policyCheck, so every proactive producer inherits it rather than each one remembering.

That is the recurring shape in this codebase, and it has its own note: something reports success, or reports a failure it could have avoided, and no page looks broken. 131049 is now documented in our open error dataset, including the part most references omit: do not retry it, and do not suppress the contact either, because the cap lifts on its own and utility messages are unaffected.

Sending on a schedule?

Klaros evaluates quiet hours in the recipient's zone and refuses to launch into a cap it already knows about. It runs on your own WhatsApp Business Account through Meta's Cloud API, billed by Meta directly with no markup in between.

Questions about quiet hours and send windows

Whose timezone do messaging quiet hours use?

The recipient's, always. Every regulation that defines quiet hours defines them at the called party's location: the US TCPA sets 8pm to 9am local, and several states are stricter at 8pm to 8am. Evaluating the window against server time or UTC is not a rounding error, it inverts the window for anyone far from Greenwich.

What actually goes wrong if quiet hours are computed in UTC?

For a recipient in India, a 21:00 to 08:00 window evaluated in UTC silences sending from 02:30 to 13:30 their local time, which covers most of a working day, and permits it at 03:00 local. You block the messages you wanted to send and send the ones you meant to block, and nothing errors, so the only signal is engagement quietly getting worse.

Why not store the UTC offset instead of a timezone name?

Because offsets move. Daylight saving shifts them twice a year, and the shift dates differ by country and change by legislation, so a stored offset is wrong for months at a time in exactly the places most likely to complain about being messaged at night. An IANA zone name resolved through Intl at evaluation time is correct across those transitions.

What should happen when the recipient timezone is unknown?

Do not apply the window. An unknown zone is missing information, and turning it into a guess means silencing somebody based on nothing. Failing open sends a message that might arrive at an awkward hour; failing closed silently withholds messages for reasons no operator can see or debug.

What is WhatsApp error 131049?

Meta declining to deliver a marketing template "to maintain healthy ecosystem engagement" because that recipient is over their own per-user marketing limit. Meta's guidance is to stop rather than retry: retrying inside the window is a guaranteed failure that spends nothing but the number's quality rating. Do not suppress the contact either, since the cap lifts on its own and utility and service messages are unaffected.

Written 3 September 2026. We append when the facts change. Related: the broadcast that lost its tail, the opt-out our own sales code almost answered, all build notes.