Skip to content

Timezones & ambiguity

A chart is only as correct as the instant you state it — an hour of timezone error is enough to move the Moon by half a degree and shift a rising sign. This page shows the two ways to state the subject moment, how each is resolved and echoed back, and what happens at daylight-saving edges.

subject accepts exactly one time form — sending both is subject.time.conflicting, and neither is subject.time.required:

{ "subject": { "datetime": "1990-05-15T14:30:00-04:00" } }
{ "subject": { "local_datetime": "1990-05-15T14:30:00", "timezone": "America/New_York" } }

The first form is self-contained: you state the UTC offset, and the API treats it as authoritative. The second is the convenient form: you state the wall-clock time and the IANA zone, and the API resolves the offset for you. Both describe the same instant and produce the same chart.

With datetime, the API converts to Universal Time for the ephemeris and echoes both facts back in meta.subject_resolved:

Terminal window
curl -s https://astral-external-api-iksoi6t3nq-ue.a.run.app/v1/western/positions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": { "datetime": "1990-05-15T14:30:00-04:00" }
}' | jq .meta.subject_resolved

14:30:00-04:00 becomes 18:30:00Z, and the response reports both the resolved utc_datetime and the utc_offset you supplied, so you can check the conversion on every response.

The offset is never optional. A datetime without one is rejected with a stable code rather than silently reinterpreted:

Terminal window
curl -s https://astral-external-api-iksoi6t3nq-ue.a.run.app/v1/western/positions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": { "datetime": "1990-05-15T14:30:00" }
}' | jq .code

A bare YYYY-MM-DDTHH:MM:SS is not treated as local time — that is what local_datetime is for. And because the offset already states the zone, adding timezone alongside datetime is rejected rather than reconciled:

Terminal window
curl -s https://astral-external-api-iksoi6t3nq-ue.a.run.app/v1/western/positions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": { "datetime": "1990-05-15T14:30:00-04:00", "timezone": "America/New_York" }
}' | jq .code

The local form: we resolve, and echo what we did

Section titled “The local form: we resolve, and echo what we did”

With local_datetime plus timezone, the API resolves the wall-clock time against the IANA time zone database — its full history of DST rules and zone changes included, which matters for birth data. local_datetime is the bare form YYYY-MM-DDTHH:MM[:SS]; an offset or Z suffix here is subject.time.invalid_format. timezone is required (subject.timezone.required without it) and must be an IANA name like America/New_York (subject.timezone.unknown otherwise — EST is not one).

The resolution is echoed, and on this path only subject_resolved also carries the timezone it ran against:

Terminal window
curl -s https://astral-external-api-iksoi6t3nq-ue.a.run.app/v1/western/positions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": { "local_datetime": "1990-05-15T14:30:00", "timezone": "America/New_York" }
}' | jq .meta.subject_resolved

Same instant, same chart as the offset example — the only difference is the timezone echo. If a response carries subject_resolved.timezone, the API resolved the zone; if not, you did.

Most local times resolve to exactly one instant; around DST transitions they do not. The rule for both edges is the same: ambiguity is a 422 with the answer choices inside it, never a guess.

On October 25, 1992, clocks in America/New_York fell back from daylight to standard time at 2:00 a.m., so the local time 1:30 a.m. occurred twice — once at offset -04:00 (1992-10-25T05:30:00Z) and again an hour later at -05:00 (1992-10-25T06:30:00Z). A birth certificate reading “1:30 a.m.” does not say which, and no API can resolve it from the wall clock alone. Submitted as a local time, it is rejected with subject.time.ambiguous_local_time, and the problem document lists both readings in candidates:

Terminal window
curl -s https://astral-external-api-iksoi6t3nq-ue.a.run.app/v1/western/positions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": { "local_datetime": "1992-10-25T01:30:00", "timezone": "America/New_York" }
}' | jq .candidates

To proceed, pick the candidate you mean — present the choice to your user, or apply a recorded policy such as “earlier instant” — and resubmit it as datetime. Either spelling works: its utc_datetime directly (1992-10-25T05:30:00Z), or the local time with the candidate’s offset attached (1992-10-25T01:30:00-04:00).

Spring-forward: the time that never happened

Section titled “Spring-forward: the time that never happened”

The mirror case: on March 8, 2026, clocks in America/New_York jump from 2:00 to 3:00 a.m., and wall-clock times inside the skipped hour never occur. There is nothing to choose between, so subject.time.nonexistent_local_time carries a suggestion — the first valid instant after the gap — instead of candidates:

Terminal window
curl -s https://astral-external-api-iksoi6t3nq-ue.a.run.app/v1/western/positions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": { "local_datetime": "2026-03-08T02:30:00", "timezone": "America/New_York" }
}' | jq .suggestion

A nonexistent time usually signals an upstream bug — a double-applied conversion, a data-entry slip — so fix the source when you can. If the record genuinely states a gap time, resubmit suggestion as datetime.

Both forms are first-class; the choice is only about where offset resolution lives.

Send local_datetime + timezone when your data is what people actually record: a wall-clock time and a known zone. The API resolves it against the IANA database — historical transitions included — and refuses loudly at the DST edges rather than guessing.

Keep sending datetime if your stack already resolves offsets well; it remains the authoritative form, unchanged. If you own the resolution:

  1. Resolve the offset from an IANA zone at the birth place and time, not from the place’s current offset. Historical DST rules, zone-boundary changes, and one-off political shifts live in the tz database and nowhere else. Use zoneinfo (Python), java.time (JVM), Luxon or date-fns-tz (JavaScript), chrono-tz (Rust), or your platform’s equivalent.
  2. Decide ambiguous times explicitly. For a fall-back time, choose the earlier or later instant deliberately, record the choice, and pass the resulting offset.
  3. Send the explicit offset in subject.datetime.

Whichever form you send, assert that meta.subject_resolved.utc_datetime is the instant you intended — one comparison that catches the whole class of double-conversion bugs. That check, and why every resolution is echoed, is the echo contract; the same-input-same-output guarantee behind it is determinism & calc_version.

API spec v0.1.0 · docs 1123b41