Scheduling & time zones

Three ways to say when. Two of them are about a clock, and the whole page is really about whose.

You wantYou send
NowNothing. Omit all of these.
A moment you have already worked outsend_at — an ISO-8601 instant with an offset.
“5 PM”, meaning 5 PM somewheresend_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
The console used to send 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.

ZoneWhat 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.
Recipient
users.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.

An offset is not a time zone. +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 byAn unusable value
App or org settings, and timezone on a send400, with the reason. You are at a keyboard and can fix it.
POST /v1/users400. A deliberate server-side write, so you still have the right value to hand.
POST /v1/devices and the SDKsDropped, 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 importDropped, 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:

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.

Every slice is a partition, not a filter: each zone group claims a set of names and the unknown group claims everything else — no zone, an empty zone, or a zone no group took. Somebody added between scheduling and sending still gets the message.

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.

← Audience filtersDelivery errors →