Time Zones in APIs: The Mistakes Everyone Makes

Time zones are an infinite source of bugs because nobody believes they will be the one to make the obvious mistake. Here is the short list of decisions that prevents almost all of them.

Every API that touches dates eventually has a time zone bug, and the bug is almost always one of the same five things. The reason these mistakes recur is not that time zones are hard in some deep sense; it is that the obvious-looking choice is wrong, and the right choice requires discipline that compounds across every endpoint, every storage layer, and every client.

Mistake one: storing local time

The temptation is to store a date the way it was entered. A user in Tokyo creates an event at 9 AM local time, and the database row has "2026-04-28 09:00:00" in it. This breaks the moment two users in different time zones look at the same record, the moment daylight saving begins or ends, and the moment your server changes regions. The fix is older than I am: store every timestamp in UTC, every time, with no exceptions. Do the conversion at the edge.

If a column needs to remember the original wall-clock interpretation, store the IANA zone name (Europe/Berlin, not UTC+1) alongside the UTC instant. Three-letter abbreviations like CST and IST are ambiguous and refuse to die. The IANA database is the authoritative source.

Mistake two: using offset strings as zones

An offset like +05:30 is not a time zone. India is at +05:30, but so is part of Sri Lanka in some historical periods, and offsets do not carry the daylight saving rules that determine future conversions. If you accept +02:00 from a client, you can convert their current instant to UTC, but you cannot reliably display "next Tuesday at 9 AM their local time" because you do not know which jurisdiction +02:00 referred to.

API parameters that take a time zone should accept IANA names: America/New_York, Asia/Kolkata, Pacific/Auckland. Validate against a real IANA list, because clients will send EST or GMT+5 and you need to reject it loudly.

Mistake three: assuming ISO 8601 means one thing

ISO 8601 timestamps come in three meaningfully different shapes:

  • 2026-04-28T11:00:00Z — instant in UTC, unambiguous
  • 2026-04-28T11:00:00+02:00 — instant with offset, unambiguous
  • 2026-04-28T11:00:00 — local time, ambiguous

The third form is widely produced by accident. Python's datetime.now().isoformat() returns it. JavaScript's new Date().toJSON() returns the first form, but toISOString() on a date constructed from a local string can silently roll over a day. Decide what your API accepts on input (we recommend: only the first two forms, reject the third with a 400) and what it produces on output (always the first form, always with Z).

Mistake four: cron expressions in the wrong zone

"Run every day at 9 AM" is a question you cannot answer without a time zone, and the answer matters. 0 9 * * * in UTC is 9 AM Greenwich, which is 5 PM in Tokyo and 1 AM in San Francisco. If your scheduling system runs in UTC and your user thinks in their local zone, every job they create will run at the wrong time, and the bug will only surface as "late delivery" complaints once the schedule diverges from their working day.

The right pattern is to store the cron expression with an explicit zone (0 9 * * * America/Los_Angeles) and resolve the next run time in that zone, not in the worker's. Daylight saving makes this non-trivial: 2:30 AM does not exist on spring-forward day, and 1:30 AM happens twice on fall-back day. Document your tie-breaking rule (we treat the first occurrence as canonical) and stick with it.

This is exactly the problem CronPing handles for our users. The monitor's expected schedule is interpreted in the timezone the user sets at the project level, not in UTC and not in the worker's timezone, so DST changes do not produce false alerts.

Mistake five: dates as timestamps

"Birthday" is not a timestamp. "Invoice date" is not a timestamp. "Meeting day" sometimes is and sometimes is not. The trap is using a DATETIME column for things that are conceptually a date, because the moment you do, time-zone arithmetic enters the picture. A user born on March 5th in Tokyo and represented as 2000-03-05T00:00:00+09:00 is, in UTC, born on March 4th. If you display the birthday from a server in California, it will be the wrong day.

Use a DATE type for date concepts, with no zone information, and let the client display the day as-is. If you need an "occurred at" instant for ordering or analytics, store it separately as a UTC timestamp. Mixing the two into one column is a bug factory.

Webhook timestamps are part of the contract

When you send a webhook, the timestamp in the body and the signature header (Stripe's Stripe-Signature, GitHub's X-Hub-Signature-256 with date) tell the receiver when the event happened from your perspective. They should always be UTC instants, and the receiver should compare against their own UTC instant with a generous skew tolerance (five minutes is typical). If you are debugging a flaky webhook integration, the first thing to inspect is whether either party is silently treating timestamps as local time.

This is one of the easiest things to verify with WebhookVault: capture the raw incoming webhook, look at the timestamp header, and compare it to the body. If they disagree by an hour or more, you have a time-zone bug somewhere in the producer.

The summary as a checklist

Store everything in UTC. Use IANA zone names, never offsets. Always emit ISO 8601 with explicit Z. Treat date-only concepts as dates. Resolve cron expressions in the user's declared zone. Validate inputs strictly and reject the local-time form with a clear error. Test for daylight saving boundaries explicitly, because they are where the bugs hide. Done with discipline, time-zone bugs become a rare class of issue instead of the recurring source of weekly incidents.

Read more