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.
Two ways to state the subject moment
Section titled “Two ways to state the subject moment”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.
The offset form: you resolve, we echo
Section titled “The offset form: you resolve, we echo”With datetime, the API converts to Universal Time for the ephemeris and echoes both facts back in meta.subject_resolved:
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_resolved14: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:
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 .codeA 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:
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 .codeThe 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:
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_resolvedSame 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.
The DST contract
Section titled “The DST contract”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.
Fall-back: the time that happened twice
Section titled “Fall-back: the time that happened twice”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:
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 .candidatesTo 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:
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 .suggestionA 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.
Choosing a form
Section titled “Choosing a form”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:
- 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. - Decide ambiguous times explicitly. For a fall-back time, choose the earlier or later instant deliberately, record the choice, and pass the resulting offset.
- 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