Dates and timezones
Most timezone bugs come from one confusion: an instant in time and a wall-clock reading are different things, and only one of them needs a zone. A fixed deadline is an instant. A recurring schedule is a wall-clock time that needs a zone to become an instant. A duration is neither. This page is about which is which, and what the API accepts for each.
Where the timezone actually matters
It changes behaviour for exactly one timer type. For the other three it is either cosmetic or ignored entirely — which surprises people who set it expecting the deadline to move.
| Timer type | Timezone required | Timing field | What the timezone does |
|---|---|---|---|
| Fixed date | No | deadline_at | Nothing to the deadline. Records the zone the timer is displayed and edited in. |
| Duration | No | duration_seconds | Unused. Elapsed time is the same everywhere. |
| Recurring | No, but you want it | recurrence | Each occurrence is computed in this zone. Omitting it runs the schedule in UTC. |
| Personalized | No | duration_seconds | Unused. Each identity gets an elapsed-time deadline. |
The deadline format
deadline_at must be an ISO 8601 date-time that identifies an unambiguous instant. In practice that means it ends in Z or carries a numeric offset like -05:00.
Accepted
"2030-01-01T14:00:00Z" // UTC
"2030-01-01T09:00:00-05:00" // explicit offset
"2030-01-01T14:00:00.000Z" // milliseconds are fineRejected — invalid or ambiguous
"2030-01-01T14:00:00" // no offset — which 14:00?
"2030-01-01 14:00:00" // space instead of T
"01/01/2030 2:00 PM" // not ISO 8601
"1893506400" // epoch seconds, not a string dateA timestamp with no offset is rejected rather than assumed to be UTC. Assuming would be the friendlier-looking choice and the wrong one: a developer in Berlin who sends 2030-01-01T14:00:00 means 14:00 in Berlin, and silently treating it as 14:00 UTC produces a countdown that is an hour off with no error to explain why.
new Date().toISOString() in JavaScript, datetime.now(timezone.utc).isoformat() in Python, and Instant.now().toString() in Java all produce an accepted format. Reach for one of those before building a string yourself.Which identifiers are accepted
Canonical IANA identifiers, and UTC. Anything else returns HTTP 400 with invalid_timezone.
Accepted
UTC, or an Area/Location identifier — Asia/Kolkata, America/New_York, Europe/London, Australia/Sydney, America/Argentina/Buenos_Aires.
Rejected
- Abbreviations —
IST,EST,CST. They are ambiguous: IST is India, Ireland, and Israel. - Numeric offsets —
+05:30. An offset is not a zone; it cannot know about DST. - Windows names —
India Standard Time,Pacific Standard Time.
The distinction between an offset and a zone is the substantive one. America/New_York is a set of rules that says when the offset changes; -05:00 is a single number that is correct for part of the year. A recurring 09:00 timer needs the rules, or it drifts to 08:00 every March.
Getting the exact list
GET /timezones returns precisely the identifiers this API accepts. It is the only authoritative source — build selectors and validation against it rather than against whatever your runtime happens to ship.
Timezone lookup
GET /timezones?search=kolkata
{
"data": [{ "id": "Asia/Kolkata" }],
"meta": {
"default": "UTC",
"source": "IANA Time Zone Database"
},
"request_id": "7b82b7f7-4d13-497b-9f20-58d46fd7a510"
}Runtime lists vary by installed database version. Intl.supportedValuesOf('timeZone') in JavaScript, zoneinfo.available_timezones() in Python, and ZoneId.getAvailableZoneIds() in Java are all fine for populating a dropdown, but a zone that exists on your machine and not on ours is a validation error at exactly the wrong moment. Use them for the UI and this endpoint for the check.
The optional search parameter filters the list, which is enough to build a type-ahead without shipping every identifier to the browser.
Daylight saving, and the two edge cases
Twice a year a local clock skips an hour or repeats one. For a fixed deadline this is already handled — an instant is an instant. For recurring schedules the API resolves the occurrence in the target zone, so a 09:30 daily timer stays at 09:30 local across the change rather than drifting to 08:30.
Where it does affect you is in constructing a deadline from a local time in your own code. Two hours deserve care:
The hour that does not exist
When clocks spring forward, 02:30 is skipped entirely in that zone. A deadline built from it is not a real instant. A zone-aware library will either shift it forward or tell you; string concatenation will silently produce the wrong time.
The hour that happens twice
When clocks fall back, 01:30 occurs twice with different offsets. "01:30" alone does not say which. Resolve it with a library, then send the resulting instant.
Converting a local time to a deadline
Local wall-clock to instant
// You have a local wall-clock time and a zone. You need an instant.
//
// Do NOT build the string by hand — "2030-03-08T02:30:00-05:00" may not
// exist in that zone, and hardcoding the offset breaks twice a year.
// Node 18+ / modern browsers, via a library that understands zones:
import { fromZonedTime } from "date-fns-tz";
const deadline = fromZonedTime("2030-03-08 02:30:00", "America/New_York");
// -> a Date at the correct instant, DST accounted for
await call("/timers", {
method: "POST",
headers: { "Idempotency-Key": "sale_2030_03" },
body: JSON.stringify({
name: "Spring sale ends",
type: "fixed",
deadline_at: deadline.toISOString(), // always ends in Z
timezone: "America/New_York", // for display and editing
}),
});deadline_at and timezone is the right move for a fixed countdown even though the zone does not move the deadline. It records the intent — so the dashboard shows the deadline in the zone the campaign was planned in, and editing it later does not require anyone to do offset arithmetic in their head.What comes back
Timestamps in responses are always UTC with milliseconds — 2030-01-01T14:00:00.000Z — regardless of the timer's configured zone. The timezone field travels alongside so you can format for display, and server_time on the status endpoint gives you our clock to render against.
Do the formatting at the edge, in the viewer's locale, from the UTC instant. Storing pre-formatted local strings is what makes a timezone change into a data migration. Reading remaining time is covered under read remaining time.