A WhatsApp drip or follow-up sequence is a list of steps, each with a delay and often a sending window: "two hours later, but only 10 AM to 7 PM on weekdays". Computing the next send time sounds like previousSentAt + delay. It isn't. A step due at 9:30 PM on a Friday has to wait until Monday morning in the business's own time zone, the next step's delay should count from Monday, not from Friday, and the opening time has to stay put when the clocks change. This post builds that scheduler in plain TypeScript, with tests for each case.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
Three rules for step timing
- A delay counts from the previous step's actual send. If step 2 waited all weekend for its window, step 3's "two hours later" means two hours after step 2 went out on Monday, not two hours plus the weekend.
- Windows are in the business's time zone, not the server's. A Mumbai shop's "10 AM" is 04:30 UTC.
- A step due outside its window moves to the next opening. Not to the next minute, and not skipped.
Wall-clock time without a date library
Intl.DateTimeFormat can tell you the local time of any instant in any IANA time zone. Going the other way, from "10:00 on 2 November in Asia/Kolkata" to an instant, needs a small correction loop:
// Wall-clock time in a named time zone, without a date library.
export type Wall = { year: number; month: number; day: number; hour: number; minute: number; weekday: number };
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export const toWall = (at: Date, timeZone: string): Wall => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone, year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", weekday: "short", hourCycle: "h23",
}).formatToParts(at);
const get = (t: string) => parts.find((p) => p.type === t)!.value;
return {
year: +get("year"), month: +get("month"), day: +get("day"),
hour: +get("hour"), minute: +get("minute"), weekday: WEEKDAYS.indexOf(get("weekday")),
};
};
// The instant a wall-clock time happens in a zone. Two passes settle the
// offset, including on daylight-saving change days.
export const fromWall = (w: Omit<Wall, "weekday">, timeZone: string): Date => {
const guess = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
let at = guess;
for (let i = 0; i < 2; i++) {
const seen = toWall(new Date(at), timeZone);
const seenUtc = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute);
at += guess - seenUtc;
}
return new Date(at);
};
fromWall guesses that the wall time is UTC, looks at what that instant reads as in the zone, and shifts by the difference. A second pass settles it on days when the offset changes. It's enough for scheduling at whole minutes; for anything stricter, use Temporal or a time-zone library.
Next allowed time, and next send time
import { fromWall, toWall } from "./zoned.ts";
// A step may send only between `from` and `to` (local "HH:MM", from < to)
// on the given weekdays (0 = Sunday). No window means any time.
export type SendWindow = { from: string; to: string; days: number[] };
const minutes = (hhmm: string) => {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
};
// The earliest moment at or after `due` that falls inside the window.
export const nextAllowed = (due: Date, window: SendWindow | undefined, timeZone: string): Date => {
if (!window) return due;
const open = minutes(window.from);
const close = minutes(window.to);
for (let dayOffset = 0; dayOffset <= 7; dayOffset++) {
const day = toWall(new Date(due.getTime() + dayOffset * 86_400_000), timeZone);
if (!window.days.includes(day.weekday)) continue;
const now = dayOffset === 0 ? day.hour * 60 + day.minute : -1;
if (now >= close) continue; // today's window has already closed
if (now >= open) return due; // inside the window right now
return fromWall({ year: day.year, month: day.month, day: day.day, hour: Math.floor(open / 60), minute: open % 60 }, timeZone);
}
throw new Error("Window allows no day of the week");
};
// Delays count from when the previous step actually went out, so a step that
// waited for its window doesn't make the next step wait that time again.
export const nextSendAt = (previousSentAt: Date, delayMs: number, window: SendWindow | undefined, timeZone: string) =>
nextAllowed(new Date(previousSentAt.getTime() + delayMs), window, timeZone);
nextAllowed walks forward day by day from the due time, skipping days the window doesn't allow and today if the window has already closed, and returns either the due time itself (if it's already inside the window) or that day's opening time in the business's zone. nextSendAt adds the delay to the previous step's actual send time before asking.
What the tests check:
- Inside the window: a step due at 11:30 AM on a Friday sends at 11:30.
- Before opening: due at 6:30 AM, it waits until 10:00 the same day.
- After closing on Friday: due at 7:30 PM, it waits until 10:00 on Monday.
- No double wait: step 1 went out late on Friday; step 2, two hours later, lands on Monday at 10:00; step 3, two hours after that, is Monday at 12:00, not Monday plus another weekend.
- Daylight saving: in Europe/London, where clocks go back on 25 October 2026, a 9 AM window opens at 08:00 UTC on the Friday before and at 09:00 UTC on the Monday after.
- An impossible window, with no days allowed, throws instead of looping forever.
Store the computed time, recompute on change
Compute next_send_at when a step is sent, store it on the recipient's row, and have the scheduler pick up rows whose time has come. Two things follow from that:
- Editing the series affects future steps only. Recompute pending rows if you change a window or delay, or accept that in-flight recipients keep their old schedule; decide which, and say so in the UI.
- A reply can cancel the row. For "stop on reply" series, mark the recipient finished when an inbound message arrives, so the scheduler never picks up their next step.
Edge cases worth deciding
-
Windows that cross midnight, such as 8 PM to 2 AM. The code above assumes
from < to; split such a window into two, or handle the wrap explicitly. - Day boundaries on clock-change nights. The day-by-day walk adds 24 hours at a time, which is fine for daytime windows. A window that opens near midnight on a clock-change night should walk by calendar date instead.
- The customer's time zone vs the business's. Most businesses think in their own hours. If your customers span time zones, store a zone per contact and pass it in instead.
- Steps that can't send. A free-text step only reaches someone who messaged in the last 24 hours, and a template can fail. Decide whether a failure ends the journey or retries, and make the recipient's row say so, so a campaign doesn't sit at "sending" forever.
Checklist
- Compute each step's time from the previous step's actual send.
- Evaluate windows in the business's IANA time zone.
- Move a step due outside its window to the next opening.
- Test Friday-evening, before-opening and daylight-saving cases.
- Store
next_send_at, and decide what edits do to in-flight recipients. - End the journey on reply when the series says to.
- Make failed or skipped steps visible on the recipient's row.
WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: end-to-end testing campaigns with a fake Graph API and rate limits in practice.
Top comments (0)