Scheduling & time zones
Three ways to say when. Two of them are about a clock, and the whole page is really about whose.
| You want | You send |
|---|---|
| Now | Nothing. Omit all of these. |
| A moment you have already worked out | send_at — an ISO-8601 instant with an offset. |
| “5 PM”, meaning 5 PM somewhere | send_at_local, and optionally timezone. |
| “9 AM wherever they are” | local_time. Paid plans. |
send_at and send_at_local are two ways of saying the same thing.
Sending both is a 400 rather than a guess about which one you meant.
Store the intent, not the instant
If you already have an instant — a job queue computed it, a user picked a countdown —
send send_at and nothing here applies to you.
await nb.messages.send({
audience: { segment_id: "..." },
content: { title: "Doors open" },
sendAt: new Date("2026-08-25T11:15:00Z"),
});
Most scheduling is not that. Most scheduling is somebody typing 5 PM, and 5 PM
is not an instant until you say where. send_at_local is a naive wall-clock
time — no Z, no offset — resolved against a zone:
POST /v1/messages
{ "content": { "title": "Doors open" },
"send_at_local": "2026-08-25T17:00",
"timezone": "Asia/Kathmandu" } // optional; defaults to the app's zone
send_at, having resolved “5
PM” against whatever browser the person was sitting at. A campaign composed in a hotel
abroad quietly moved to that country’s clock, and a daily digest drifted by an hour at
every daylight-saving change. The instant is derivable from the intent; the intent is
not derivable from the instant — so the intent is what gets stored, and the send is
re-resolved when a government moves a clock.Whose clock is the default
Two zones, and they do different jobs.
| Zone | What it does |
|---|---|
| Organization Settings → Organization | A seed. New apps start with it, and nothing schedules against it. Change it and existing apps keep their own. |
| App Settings → App | The scheduling clock. A send_at_local with no timezone means this one, and it is what the composer shows beside the date field. |
Recipientusers.timezone, devices.timezone | Only used by local_time. Captured automatically by the web and mobile SDKs; settable through the API and importable from a CSV. |
The app is the default rather than your browser on purpose: your browser is where the campaign was written, which has nothing to do with when it should arrive.
IANA names only
A zone is a name from the IANA database — Asia/Kathmandu,
Europe/Berlin, America/New_York. GET /v1/timezones
returns every name this deployment knows, grouped by current UTC offset, which is what the
console’s picker is built from.
+05:45 and GMT+5 are refused with a 400, even though
JavaScript will accept them. An offset cannot survive a daylight-saving change, so a
schedule stored as one drifts by an hour twice a year with nobody touching it.Two spellings of one place are one place. Asia/Kathmandu and
Asia/Katmandu are the same zone, as are Asia/Kolkata and
Asia/Calcutta; whichever you send, the canonical spelling is what gets stored,
so a picker and an API client cannot fill your database with two names for Nepal.
Where a zone comes from decides what happens to a bad one:
| Written by | An unusable value |
|---|---|
App or org settings, and timezone on a send | 400, with the reason. You are at a keyboard and can fix it. |
POST /v1/users | 400. A deliberate server-side write, so you still have the right value to hand. |
POST /v1/devices and the SDKs | Dropped, never an error. This runs inside your app mid-session, and failing a session over a time zone costs more than the time zone is worth. The person reads as having no zone, which is a state we handle. |
| CSV import | Dropped, row still imports. |
The same hour, everywhere
local_time is a time of day, not a date. Everyone gets the message at that
hour on their own clock.
POST /v1/orgs/:orgId/apps/:appId/messages
{ "content": { "title": "Good morning" },
"audience": { "all": true },
"local_time": "09:00" }
{ "strategy": "recipient_local_time",
"zone_groups": 5, // scheduled jobs, NOT recipients
"first_at": "...", "last_at": "...",
"groups_tomorrow": 2 }
Two things this is honest about, because both surprise people:
- Delivery spans up to 24 hours. The campaign is not finished when you press the button. Report numbers climb all day, and that is the feature working.
- A zone where 9 AM has already passed gets it tomorrow. The moment is
gone. Sending a good-morning message at tea time because the campaign started late is
worse than sending it in the morning, so
groups_tomorrowtells you how many groups that is before you send.
What it costs, and why it is a paid feature
One campaign becomes one scheduled job per distinct clock in your audience — not one per recipient. A send to two million people schedules the same handful of jobs as a send to two hundred, and each job resolves only its own slice in SQL rather than loading the audience and throwing most of it away.
The groups come from the zones your people actually keep, so an audience entirely in Nepal is two jobs, not thirty-nine. It is still the one feature that costs materially more to run than the send it schedules — the campaign occupies the scheduler for a day instead of one pass — which is why it is on paid plans and ordinary scheduling is on every plan.
People with no zone
They get the app’s clock. It is the only defensible guess: it is the clock the person composing was thinking in.
What matters is that you know the number first. GET
/v1/orgs/:orgId/apps/:appId/timezone-reach — and the panel in the composer built on
it — reports how many recipients reported a zone, how many did not, and how many distinct
zones there are, across everyone you can reach on any channel:
{ "known": 41203, "unknown": 288, "distinct_zones": 27,
"app_timezone": "Asia/Kathmandu" }
A silent fallback is how a European list gets messaged at 4 AM. This is the number that stops it.
Cancelling
A scheduled send is cancellable until the worker claims it, from Delivery → Scheduled in the console. A recipient-local campaign is many jobs, so cancelling part-way through leaves the groups that already fired — the console shows which.
Automations are different
Everything here schedules one campaign. Sending when something happens to one person — a delay after an event, a welcome an hour after signup — is an automation, which has its own trigger and its own delay and does not use these fields.