Errors
Every error comes back in a single shape - ok: false plus an error object:
{ "ok": false, "error": { "code": "INVALID_API_KEY", "message": "Invalid API key" }}code: machine-readableUPPER_SNAKE_CASEcode, stable, never changes without a major bump. Switch on this, not onmessage.message: human-readable explanation in English. Wording may be refined without a bump.details: optional. ForINVALID_INPUTit’s an array of{ path, message }for each failed field. ForINVALID_FIELDeach entry also carriesexpected, the canonical field name.docs: optional, a link to the section of this page that explains the code in more depth.request_id: added only to5xxandBAD_JSONresponses; quote it when contacting support.
HTTP status codes
Section titled “HTTP status codes”| Status | Meaning |
|---|---|
200 OK | Success |
400 Bad Request | Malformed JSON, missing fields, or failed Zod validation |
401 Unauthorized | Missing or invalid X-Api-Key |
402 Payment Required | A higher plan / add-on pack is required, or the subscription is inactive |
403 Forbidden | Key is valid but lacks permission (scope, origin, suspended) |
404 Not Found | Resource, key, or job does not exist |
422 Unprocessable Entity | Input is valid but the engine could not compute it |
429 Too Many Requests | Rate limit exceeded, credits exhausted, or spend cap hit |
500 Internal Server Error | Our fault - quote request_id |
503 Service Unavailable | AI gateway / queue temporarily down |
Error codes
Section titled “Error codes”Authentication (401)
Section titled “Authentication (401)”| Code | Meaning |
|---|---|
MISSING_API_KEY | The X-Api-Key header was not sent |
INVALID_API_KEY | Key does not exist or was revoked |
MISSING_BEARER | Authorization: Bearer scheme selected but token missing |
Permissions (403)
Section titled “Permissions (403)”| Code | Meaning |
|---|---|
INSUFFICIENT_SCOPE | Key lacks permission for this action |
DOMAIN_MISMATCH / FORBIDDEN_ORIGIN | Request origin is not in the key’s allow-list |
ENDPOINT_NOT_IN_SCOPE | The key is limited to a list of endpoints (allowed_endpoints) and this path is not on it. details names the list |
KEY_SUSPENDED | Key has been suspended |
Billing (402)
Section titled “Billing (402)”| Code | Meaning |
|---|---|
PLAN_UPGRADE_REQUIRED | Endpoint requires a higher plan or an add-on pack |
PLAN_PACK_MISMATCH | Endpoint is in a pack the key doesn’t have |
SUBSCRIPTION_EXPIRED | The paid period ended more than 3 days ago. For the first 3 days after it ends, requests are still served on your plan and carry an X-Subscription-Grace-Ends header. details has expired_at, grace_ended_at and renew_url |
Rate limiting & credits (429)
Section titled “Rate limiting & credits (429)”| Code | Meaning |
|---|---|
RATE_LIMIT | RPM limit exceeded. Response carries a Retry-After: <sec> header |
CREDITS_EXHAUSTED | Monthly credit budget used up |
SPEND_CAP_REACHED | AI-endpoint spend cap reached |
KEY_BUDGET_EXHAUSTED | The key reached its own budget (credits_cap_cycle). The account may still have credits: this ceiling belongs to the one key |
Validation (400)
Section titled “Validation (400)”| Code | Meaning |
|---|---|
INVALID_INPUT | One or more fields failed Zod validation. details is an array of { path, message } |
INVALID_FIELD | The body uses a short field name instead of the canonical one. See below |
MISSING_FIELDS | A required field is missing |
BAD_JSON | Malformed JSON in the request body |
Example 400:
{ "ok": false, "error": { "code": "INVALID_INPUT", "message": "Validation failed: date: Invalid input: expected string, received undefined", "details": [ { "path": "date", "message": "Invalid input: expected string, received undefined" }, { "path": "time", "message": "Invalid input: expected string, received undefined" } ] }}INVALID_FIELD
Section titled “INVALID_FIELD”Chart endpoints accept full field names only. Short forms are refused with a 400 instead of being read silently:
| Sent | Canonical field | What it expects |
|---|---|---|
lat | latitude | decimal degrees, north positive |
lng, lon, long | longitude | decimal degrees, east positive |
tz with a number, tzOffset, utcOffset, timezone_offset | timezoneOffset | numeric hours from UTC, e.g. 5.75 |
tz with a name, timeZone, time_zone | timezone | an IANA zone name such as Europe/Kyiv, or auto |
The response lists every field it found at once, in request-body order, so the rename is a single pass:
{ "ok": false, "error": { "code": "INVALID_FIELD", "message": "Unsupported fields: \"tz\", \"lat\", \"lng\". Rename them to \"timezoneOffset\", \"latitude\", \"longitude\". See details for what each one expects.", "details": [ { "path": "tz", "expected": "timezoneOffset", "message": "numeric hours from UTC, e.g. 5.75; a zone name goes in timezone" }, { "path": "lat", "expected": "latitude", "message": "decimal degrees, north positive" }, { "path": "lng", "expected": "longitude", "message": "decimal degrees, east positive" } ], "docs": "https://api.astroway.info/en/errors/#invalid_field" }}path is the full path, so a synastry body shows which chart carries it: chart1.lat, not just lat.
The same code comes back when timezone cannot be resolved: an empty value, an abbreviation such as EST (a fixed -5 all year, so New York in July would come out an hour wrong), a name the tz database does not know, an offset written as text such as +03:00, or auto without latitude and longitude. The message says what to send instead, and path points at the object, for example chart2.timezone.
Two things worth knowing.
This is not a deprecation. The short names were never part of the contract and never appeared in /openapi.json as body fields. Before 2026-08-01 they did not work, they were silently ignored: the coordinates carry a zero default, so the chart was computed for 0°N 0°E at UTC. The ascendant, the houses and everything derived from them were wrong, and the response said nothing about it. That is why the short forms are not accepted back: it would mean two names for one field forever, and tz is ambiguous on top of that (a number or a zone name).
A nested point is a different thing. On /v1/acg-zones, /v1/acg/by-category, /v1/ccg-analysis and /v1/eclipse-analysis the object point: { lat, lng } uses the declared field names and passes.
Missing coordinates: a 400 from 2026-11-09
Section titled “Missing coordinates: a 400 from 2026-11-09”Getting the field name right is half of it. A body without latitude and longitude also answered 200, because the coordinates carried a zero default and the response described 0°N 0°E.
Until 2026-11-09 such a request still works, but the response carries Deprecation: true, Sunset, Warning and a _warning field in the body. From 2026-11-09 it is a 400 INVALID_INPUT with latitude is required, in decimal degrees.
An explicit latitude: 0, longitude: 0 stays valid: the problem is the missing field, not the value. timezoneOffset keeps its zero default, because 0 there means UTC. city does not stand in for coordinates: nothing geocodes it. The check applies only to objects carrying both date and time.
Calculation errors (422)
Section titled “Calculation errors (422)”| Code | Meaning |
|---|---|
CALCULATION_ERROR | Input is valid but the engine could not compute (e.g. a polar latitude for Placidus). Try Whole Sign / Equal or a different date. |
Server
Section titled “Server”| Code | HTTP | Meaning |
|---|---|---|
INTERNAL_ERROR | 500 | Unexpected error. Logged with a stack trace - quote request_id |
GATEWAY_UNAVAILABLE / AI_UNAVAILABLE / LLM_UNAVAILABLE | 503 | AI gateway down. Retry with exponential backoff |
QUEUE_UNAVAILABLE | 503 | Background-job queue down |
Handling in client code
Section titled “Handling in client code”type ApiError = { ok: false; error: { code: string; message: string; details?: unknown; request_id?: string; };};
async function chart(input: ChartInput) { const res = await fetch('https://api.astroway.info/v1/chart', { method: 'POST', headers: { 'X-Api-Key': process.env.ASTROWAY_API_KEY!, 'Content-Type': 'application/json', }, body: JSON.stringify(input), });
const body = await res.json(); if (!body.ok) { throw new AstrowayError(body.error.code, body.error.message, body.error.request_id); }
return body.data;}
class AstrowayError extends Error { constructor( public code: string, message: string, public requestId?: string, ) { super(`[${code}] ${message}${requestId ? ` (request_id=${requestId})` : ''}`); }}Switch on code, not on message. The official SDKs (@astroway/sdk, astroway) throw typed error subclasses by HTTP status (AuthenticationError, RateLimitError, BadRequestError, …), so you won’t need to write this yourself.
Contacting support
Section titled “Contacting support”To open a ticket, email support@astroway.info:
- The
request_idfrom the response (for5xx) - The endpoint and a trimmed JSON body (no secrets)
- Expected vs actual behavior
- Timezone and approximate request time (UTC)
Replies land within your plan’s SLA (48 h Starter, 24 h Pro, custom for Enterprise).