Astrology chart computation API — natal charts, synastry, transits, progressions, solar returns, composite charts
The base URL is same-origin: all paths are relative (e.g. /api/v1/charts) — send requests directly to this site.
The API is currently in Beta, open-beta mode:
API_KEY_MODE=open)required mode, at which point anonymous calls return 401 key_required. See Authentication & Usage for how to obtain and pass keys and for the usage endpoint.
All requests and responses are JSON (Content-Type: application/json). Every /api response carries Access-Control-Allow-Origin: *, so the API can be called cross-origin straight from a browser; OPTIONS /api/v1/* preflight requests return 204 with Access-Control-Allow-Methods: POST,GET,OPTIONS and Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key.
Response vocabulary (planet / zodiac / aspect names, etc.) defaults to Traditional Chinese; pass lang to get English, Japanese, or Korean — see Languages.
The full machine-readable specification lives at /openapi.json (OpenAPI 3.1).
Every endpoint that takes birth data (/charts itself, a/b for /synastry, natal for /transits, /progressions and /solar-return, and a/b for /composite) uses the same fields and validation rules:
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
birth_date | string | yes | Birth date, YYYY-MM-DD (zero-padded) and must be a valid calendar date | "1990-01-01" |
birth_time | string | yes | Birth time (local time), H:MM or HH:MM, 24-hour clock (hour 0–23, minute 0–59) | "12:00" |
latitude | number | yes | Birthplace latitude, −90 to 90 (must be a JSON number; strings are rejected) | 25.033 |
longitude | number | yes | Birthplace longitude, −180 to 180 (east positive) | 121.5654 |
timezone | string | yes | IANA timezone identifier | "Asia/Taipei" |
house_system | string | no | House system. "P" / "placidus" = Placidus (default); "W" / "whole_sign" = Whole Sign. Case-insensitive; any other value returns 400 invalid_house_system | "W" |
Response vocabulary defaults to Traditional Chinese (zh-TW). Every chart endpoint (/charts, /synastry, /transits, /progressions, /composite, /solar-return) accepts an optional request-body field lang; GET /api/v1/cities accepts a ?lang= query parameter. /api/v1/health takes no language parameter.
| Value (case-insensitive) | Language | Notes |
|---|---|---|
zh-TW, zh-tw, zh_tw, zh | Traditional Chinese | Default: omitting lang or sending null is identical to zh-TW |
en | English | Planet / zodiac / aspect vocabulary translated to English |
ja | Japanese | Vocabulary translated to Japanese (some terms share the same characters as Chinese) |
ko | Korean | Vocabulary translated to Korean |
Any other value (including the empty string "") returns 400 invalid_lang, and that error message is always rendered in zh-TW.
lang translates vocabulary values only — it never changes the JSON structure:
planet, a_planet, b_planet, transit_planet, natal_planet, progressed_planet, planet_a, planet_b, apex, ruling_planet, zodiac, aspect_type, pattern_type and element, plus the planet names inside patterns[].planets arraysa_planets_in_b_houses / b_planets_in_a_houses, and the element / modality keys of element_stats.elements / element_stats.modalities (the house numbers and counts — the values — are untouched)error.message is localized to the request langlang=en / ja / ko, /cities returns name and country as English names (no Japanese / Korean city names are provided)planet, zodiac, etc.), the input echo block (returned verbatim), all numbers, the house_system codes "P" / "W", and error code values (language-independent — always key program logic off code)invalid_json), the server cannot read lang, so that error message is always zh-TW.
地 (火/地/風/水) and the modalities are 活動/不動/柔軟; in Korean (ko) the elements are 불/흙/공기/물 and the modalities are 활동궁/고정궁/변통궁.
Request example
{
"birth_date": "1990-01-01",
"birth_time": "12:00",
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei",
"lang": "en"
}
Response 200 (actual output, excerpt)
{
"data": {
"input": { /* echoed verbatim — never translated */ },
"chart": {
"ascendant": { "zodiac": "Aries", "degree": 16.4422, "total_degree": 16.4422 },
"house_system": "P",
"planets": [
{ "planet": "Sun", "zodiac": "Capricorn", "house": 9, "degree": 10.4744, "total_degree": 280.4744, "retrograde": false,
"aspects": [
{ "planet": "Jupiter", "aspect_type": "Opposition", "orb": 5.28 },
{ "planet": "Saturn", "aspect_type": "Conjunction", "orb": 5.14 }
// …2 more omitted
] },
{ "planet": "Moon", "zodiac": "Aquarius", "house": 11, "degree": 28.7886, "total_degree": 328.7886, "retrograde": false },
{ "planet": "Mercury", "zodiac": "Capricorn", "house": 10, "degree": 25.76, "total_degree": 295.76, "retrograde": true },
{ "planet": "North Node", "zodiac": "Aquarius", "house": 11, "degree": 16.863, "total_degree": 316.863, "retrograde": false }
// …13 more omitted (structure unchanged, 17 entries in total)
],
"houses": [
{ "house_number": 1, "degree": 16.4422, "zodiac": "Aries" },
{ "house_number": 2, "degree": 50.317, "zodiac": "Taurus" }
// …10 more omitted
],
"patterns": [],
"element_stats": {
"elements": { "Fire": 1, "Earth": 5, "Air": 2, "Water": 2 },
"modalities": { "Cardinal": 6, "Fixed": 3, "Mutable": 1 }
}
}
}
}
太陽 = Sun, 摩羯座 = Capricorn) exactly as the API returns them without lang. Add "lang": "en" to any request to receive the English vocabulary instead — the structure is identical.
API keys let you attribute your calls, set a monthly usage cap, and inspect your own usage. Whether a key is mandatory is controlled by the server environment variable API_KEY_MODE:
API_KEY_MODE | Behavior |
|---|---|
open (default, this server's current state) | Key is optional. No key = an anonymous call, everything works; with a key, the call is counted against that key. |
required | Key is mandatory. Anonymous calls to any billable endpoint return 401 key_required. |
open mode: you can call every endpoint on this server with no key at all — the one exception is GET /api/v1/usage, which needs a key even in open mode because anonymous usage cannot be attributed. The key details below are so you can wire keys up when you need them (or when the server later switches to required).
Keys look like ak_XXXXXXXX…. Use any one of the following (precedence: Authorization > X-API-Key > ?api_key=):
| Method | Example |
|---|---|
Request header Authorization: Bearer | Authorization: Bearer ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ |
Request header X-API-Key | X-API-Key: ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ |
Query parameter ?api_key= | /api/v1/cities?q=tai&api_key=ak_7hgEJAyQ… |
OPTIONS /api/v1/*) allows the Content-Type, Authorization, X-API-Key headers, so you can send a key cross-origin straight from a browser.
The following endpoints allow anonymous calls in open mode and count one unit of usage on success (HTTP 200); in required mode they require a key:
POST /api/v1/charts, /synastry, /transits, /progressions, /composite, /solar-returnGET /api/v1/citiesNot counted: GET /api/v1/health (always public, no key) and GET /api/v1/usage (requires a key, but inspecting your own usage is itself free).
| HTTP | code | Trigger | Example response |
|---|---|---|---|
| 401 | key_required | A billable endpoint is called anonymously in required mode; or /usage is called with no key in any mode | {"error":{"code":"key_required","message":"此端點需要 API 金鑰"}} |
| 401 | invalid_api_key | The key does not exist or has been revoked | {"error":{"code":"invalid_api_key","message":"API 金鑰無效或已撤銷"}} |
| 429 | quota_exceeded | The key's usage this month has reached its monthly_limit (the message includes the limit) | {"error":{"code":"quota_exceeded","message":"已超過本月用量上限(1 次)"}} |
key_required / invalid_api_key are 401, quota_exceeded is 429), unlike the 400-class parameter errors in the Error Codes section — always branch on code. The 429 only ever fires for a key that has a non-null monthly_limit. (Example messages above are shown in the default zh-TW; with lang=en they read e.g. "This endpoint requires an API key", "API key is invalid or has been revoked", "Monthly usage limit exceeded (1 calls)".)
Returns a usage summary for the current key in the current month. This endpoint always requires a key (even in open mode — anonymous usage cannot be attributed) and is itself not billed and not translated (the response has no vocabulary fields). It also accepts ?lang=, but that only affects the language of error messages (such as key_required / invalid_api_key), never the data.
Request example
curl -s https://your-host/api/v1/usage \
-H "Authorization: Bearer ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ"
Response 200 (actual output)
{
"data": {
"key": {
"prefix": "ak_7hgEJAyQ",
"label": "Acme Web App",
"tier": "free"
},
"month": "2026-07",
"total": 5,
"monthly_limit": 1000,
"remaining": 995,
"by_endpoint": {
"charts": 2,
"cities": 2,
"synastry": 1
},
"by_day": {
"2026-07-25": 5
}
}
}
| Field | Type | Description |
|---|---|---|
key.prefix | string | Key prefix (ak_ + 8 chars, 11 total); safe to display. The full key is never returned |
key.label | string | The label given when the key was created |
key.tier | string | Plan tier (e.g. free / pro; a label only) |
month | string | Reporting month YYYY-MM (UTC) |
total | integer | Total billable calls this month |
monthly_limit | integer | null | Monthly cap; null means unlimited |
remaining | integer | null | Calls left this month (max(limit − total, 0)); null when unlimited |
by_endpoint | object | Calls per endpoint (keyed by the name without the /api/v1/ prefix, e.g. charts), sorted most-used first |
by_day | object | Calls per day (YYYY-MM-DD) this month, sorted ascending by date |
# Authorization: Bearer (natal chart)
curl -s -X POST https://your-host/api/v1/charts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ" \
-d '{"birth_date":"1990-01-01","birth_time":"12:00","latitude":25.033,"longitude":121.5654,"timezone":"Asia/Taipei"}'
# X-API-Key header (city search)
curl -s "https://your-host/api/v1/cities?q=tai" \
-H "X-API-Key: ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ"
# ?api_key= query parameter (city search)
curl -s "https://your-host/api/v1/cities?q=tokyo&api_key=ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ"
Anyone can self-serve a free API key — no admin token required. The flow is double opt-in and has two steps:
POST /api/v1/signup with your email. The server stages a pending signup and emails a confirmation link. No key is returned at this step.GET /verify?token=…) and press the confirm button; the page then calls POST /api/v1/verify with that token, which is when the plaintext key is issued and shown exactly once.The whole flow is public, free, not billable, and requires no key. Keys themselves are free too; the email step only confirms that you own the address. For how to pass the key and inspect usage once you have it, see Authentication & Usage. Two public HTML pages back this flow: GET /signup (the signup page) and GET /verify (the email-link landing page with the confirm button).
RESEND_API_KEY, no email is actually sent — the link is written to the server log ("logged mode") instead, so the entire double-opt-in flow still works locally and in tests.
Stages a pending signup and emails a confirmation link. Public, not billable, no key needed. Abuse throttle: when the daily request count from one source IP (the first segment of X-Forwarded-For, which Fly sets) reaches SIGNUP_DAILY_IP_LIMIT (default 5), it returns 429 signup_rate_limited.
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
email | string | yes | The developer's email. An invalid format or length > 254 chars returns 400 invalid_email; a disposable/throwaway domain (e.g. mailinator.com) returns 400 disposable_email | "dev@example.com" |
lang | string | no | Language of error messages and the confirmation email (see Languages) | "en" |
Request example
{
"email": "dev@example.com"
}
Response 202 (actual output)
{
"data": {
"status": "verification_sent",
"email": "dev@example.com"
}
}
GET /verify?token=…) is valid for 24 hours; after that you must sign up again.
| HTTP | code | Trigger | Example response |
|---|---|---|---|
| 400 | invalid_email | The email format is invalid, or its length exceeds 254 chars | {"error":{"code":"invalid_email","message":"電子郵件格式無效"}} |
| 400 | disposable_email | The email belongs to a disposable/throwaway domain (e.g. mailinator.com) | {"error":{"code":"disposable_email","message":"拋棄式(暫時)信箱不予受理,請改用常用信箱"}} |
| 429 | signup_rate_limited | The daily request count from this source IP has hit the cap | {"error":{"code":"signup_rate_limited","message":"今日申請次數過多,請明日再試"}} |
Confirms ownership of the address using the token from the confirmation email link, and on success issues and returns the plaintext key (returned only this once). Normally an end user reaches this via the GET /verify?token=… page and presses the confirm button, whose front-end POSTs here; you can also call it programmatically. Public, not billable, no key needed.
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
token | string | yes | The one-time token from the confirmation email link (/verify?token=…) | "3f9c…" |
lang | string | no | Language of error messages (see Languages) | "en" |
Request example
{
"token": "3f9c1e7a8b2d4f60a1c3e5d7b9f0a2c4"
}
Response 201 (actual output; the plaintext token is returned only once)
{
"data": {
"token": "ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ",
"prefix": "ak_7hgEJAyQ",
"monthly_limit": null,
"tier": "free"
}
}
token (the API key) is returned only this once (the server stores only its SHA-256 hash); no endpoint ever returns the full key again — capture it now. New keys have tier free and a monthly_limit of null (unlimited, unless the operator sets SIGNUP_MONTHLY_LIMIT). The one-time token is invalidated as soon as it is used successfully.
| HTTP | code | Trigger | Example response |
|---|---|---|---|
| 400 | verification_invalid | The token is unknown or has already been used | {"error":{"code":"verification_invalid","message":"驗證連結無效或已使用過"}} |
| 400 | verification_expired | The link is older than 24 hours | {"error":{"code":"verification_expired","message":"驗證連結已過期,請重新申請"}} |
Check service status and the underlying astro_chart gem version.
Response 200
{"status":"ok","gem_version":"0.3.0"}
Computes a complete natal chart from birth data: ascendant, planet positions (with retrograde flags, Part of Fortune, Lilith), houses (Placidus or Whole Sign), aspects, aspect patterns (Grand Trine / T-Square / Grand Cross), and element statistics.
The body is simply one birth-data object (5 required fields + optional house_system), plus the optional lang (see Languages).
Request example
{
"birth_date": "1990-01-01",
"birth_time": "12:00",
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei",
"house_system": "P"
}
Response 200 (actual output, planets / houses excerpted; default zh-TW vocabulary)
{
"data": {
"input": {
"birth_date": "1990-01-01",
"birth_time": "12:00",
"coordinates": { "latitude": 25.033, "longitude": 121.5654 },
"timezone": "Asia/Taipei"
},
"chart": {
"ascendant": { "zodiac": "牡羊座", "degree": 16.4422, "total_degree": 16.4422 },
"house_system": "P",
"planets": [
{
"planet": "太陽",
"zodiac": "摩羯座",
"house": 9,
"degree": 10.4744,
"total_degree": 280.4744,
"retrograde": false,
"aspects": [
{ "planet": "木星", "aspect_type": "對分相", "orb": 5.28 },
{ "planet": "土星", "aspect_type": "合相", "orb": 5.14 },
{ "planet": "天王星", "aspect_type": "合相", "orb": 4.71 },
{ "planet": "海王星", "aspect_type": "合相", "orb": 1.55 }
]
},
{
"planet": "水星",
"zodiac": "摩羯座",
"house": 10,
"degree": 25.76,
"total_degree": 295.76,
"retrograde": true
},
// …Moon, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto,
// North Node, South Node omitted (12 physical bodies / nodes)
{
"planet": "福點",
"zodiac": "雙子座",
"house": 2,
"degree": 4.7564,
"total_degree": 64.7564,
"retrograde": false
},
{
"planet": "莉莉絲",
"zodiac": "天蠍座",
"house": 7,
"degree": 6.4703,
"total_degree": 216.4703,
"retrograde": false
},
{
"planet": "北交點定位星",
"ruling_planet": "天王星",
"zodiac": "摩羯座",
"house": 9,
"degree": 5.7656,
"total_degree": 275.7656,
"aspects": [
{ "planet": "太陽", "aspect_type": "合相", "orb": 4.71 },
{ "planet": "木星", "aspect_type": "對分相", "orb": 0.57 }
// …2 more omitted
],
"retrograde": false
}
// …South Node Ruler and Ascendant Ruler omitted
// (17 planets entries in total, see "Response Field Reference")
],
"houses": [
{ "house_number": 1, "degree": 16.4422, "zodiac": "牡羊座" },
{ "house_number": 2, "degree": 50.317, "zodiac": "金牛座" }
// …10 more omitted, 12 houses in total
],
"patterns": [],
"element_stats": {
"elements": { "火": 1, "土": 5, "風": 2, "水": 2 },
"modalities": { "基本": 6, "固定": 3, "變動": 1 }
}
}
}
}
With "house_system": "W", each house cusp sits at 0° of a sign (house 1 = 0° of the ascendant's sign), while ascendant itself is exactly the same as under Placidus. Actual output excerpt (same birth data):
{
"chart": {
"ascendant": { "zodiac": "牡羊座", "degree": 16.4422, "total_degree": 16.4422 },
"house_system": "W",
"houses": [
{ "house_number": 1, "degree": 0.0, "zodiac": "牡羊座" },
{ "house_number": 2, "degree": 30.0, "zodiac": "金牛座" },
{ "house_number": 3, "degree": 60.0, "zodiac": "雙子座" }
// …9 more omitted, +30° per house
]
// …planets / patterns / element_stats keep the same structure
// (planet house placement is re-derived under Whole Sign)
}
}
patterns is an empty array for most charts; for 1999-08-11 (the famous solar-eclipse Grand Cross), the actual output is:
"patterns": [
{ "pattern_type": "T三角", "planets": ["水星", "海王星", "木星"], "apex": "木星" },
{ "pattern_type": "大十字", "planets": ["天王星", "太陽", "土星", "火星"] },
{ "pattern_type": "大十字", "planets": ["南交點", "太陽", "土星", "火星"] }
// …5 Grand Crosses in total, 3 omitted here
]
Computes both natal charts, the cross-chart aspects between them, and the "person A's planets in person B's houses" overlay.
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
a | object | yes | Person A's birth-data object (may include house_system) | {"birth_date": …} |
b | object | yes | Person B's birth-data object | {"birth_date": …} |
orb_limit | number | null | no | Maximum orb (degrees) for cross-chart aspects. When omitted or null, the natal default orbs apply (15° for conjunctions, etc. — see below); synastry practice commonly uses a tighter 6.0. Must be a JSON number or null; any other type returns 400 missing_param | 6.0 |
Request example
{
"a": {
"birth_date": "1990-01-01",
"birth_time": "12:00",
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei"
},
"b": {
"birth_date": "1992-06-15",
"birth_time": "08:30",
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei"
},
"orb_limit": 6.0
}
Response 200 (actual output, excerpt; default zh-TW vocabulary)
{
"data": {
"a_chart": { /* person A's full natal chart, same structure as /api/v1/charts data */ },
"b_chart": { /* person B's full natal chart */ },
"synastry": {
"aspects": [
{ "a_planet": "冥王星", "b_planet": "天王星", "aspect_type": "六分相", "orb": 0.16 },
{ "a_planet": "太陽", "b_planet": "水星", "aspect_type": "對分相", "orb": 0.19 },
{ "a_planet": "冥王星", "b_planet": "海王星", "aspect_type": "六分相", "orb": 1.13 }
// …remaining entries omitted; always sorted by orb ascending
],
"a_planets_in_b_houses": {
"太陽": 6, "月亮": 7, "水星": 6, "金星": 6, "火星": 5, "木星": 11,
"土星": 6, "天王星": 5, "海王星": 6, "冥王星": 4, "北交點": 7
},
"b_planets_in_a_houses": {
"太陽": 3, "月亮": 9, "水星": 3, "金星": 3, "火星": 1, "木星": 5,
"土星": 11, "天王星": 10, "海王星": 10, "冥王星": 8, "北交點": 9
}
}
}
}
aspects entry, a_planet belongs to person A and b_planet to person B; a_planets_in_b_houses maps A's planets to the house of B's chart they fall in (and vice versa). Cross-chart comparison always uses 11 bodies (Sun through Pluto + North Node); the South Node is excluded (it is always exactly opposite the North Node and would only mirror its aspects). Part of Fortune, Lilith, and the ruler points are also excluded from cross-chart comparison. With lang, the planet-name keys of both house-overlay maps are translated too (e.g. "Sun": 6).
Computes the positions of the transiting planets at a given moment (default: "now"), the natal houses they fall in, and the aspects of transiting planets to natal planets.
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
natal | object | yes | Natal birth-data object (may include house_system, which affects which house each transiting planet falls in) | {"birth_date": …} |
at | object | no | Transit moment. When omitted, the server's current UTC time is used. If provided, all three subfields are required | {"date": …} |
at.date | string | yes* | Date YYYY-MM-DD (validated like birth_date) | "2026-07-24" |
at.time | string | yes* | Time HH:MM (validated like birth_time) | "12:00" |
at.timezone | string | yes* | IANA timezone identifier (at.date/at.time are read as local time in this zone) | "Asia/Taipei" |
orb_limit | number | no | Maximum orb (degrees) for transit aspects, default 3.0 (transit practice uses tight orbs) | 3 |
* Required only when the at object is provided.
Request example
{
"natal": {
"birth_date": "1990-01-01",
"birth_time": "12:00",
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei"
},
"at": { "date": "2026-07-24", "time": "12:00", "timezone": "Asia/Taipei" },
"orb_limit": 3
}
Response 200 (actual output, excerpt; default zh-TW vocabulary)
{
"data": {
"natal_chart": { /* full natal chart, same structure as /api/v1/charts data */ },
"transit_time_utc": "2026-07-24T04:00:00Z",
"planets": [
{
"planet": "太陽",
"zodiac": "獅子座",
"degree": 1.3043,
"total_degree": 121.3043,
"natal_house": 4
},
{
"planet": "月亮",
"zodiac": "射手座",
"degree": 1.4324,
"total_degree": 241.4324,
"natal_house": 8
}
// …10 more omitted, 12 entries in total
// (Sun through Pluto + North Node + South Node)
],
"aspects": [
{ "transit_planet": "金星", "natal_planet": "土星", "aspect_type": "三分相", "orb": 0.18 },
{ "transit_planet": "水星", "natal_planet": "土星", "aspect_type": "對分相", "orb": 0.7 },
{ "transit_planet": "水星", "natal_planet": "冥王星", "aspect_type": "三分相", "orb": 0.77 },
{ "transit_planet": "海王星", "natal_planet": "木星", "aspect_type": "四分相", "orb": 0.85 }
// …remaining entries omitted; sorted by orb ascending
]
}
}
transit_time_utc is the transit moment converted to UTC as an ISO 8601 string. planets[].natal_house is the natal house the transiting planet falls in. Aspect comparison always uses 11 bodies (the South Node is excluded, same reasoning as synastry); transit aspects ignore the natal chart's Part of Fortune / Lilith / ruler points. If the at moment falls inside a daylight-saving transition gap, the API returns 400 invalid_time.
"A day for a year": for each full year between birth and the target date, the progressed chart advances one day past the birth moment. Returns progressed planet positions (in natal houses) and aspects of progressed planets to natal planets.
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
natal | object | yes | Natal birth-data object (may include house_system) | {"birth_date": …} |
target_date | string | yes | Progression target date YYYY-MM-DD. The target moment uses "the same birth time + birth timezone" so whole years convert cleanly | "2026-07-24" |
orb_limit | number | no | Maximum orb (degrees) for progressed aspects, default 1.0 (progressions move very slowly, so only very exact aspects matter) | 1 |
Request example
{
"natal": {
"birth_date": "1990-01-01",
"birth_time": "12:00",
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei"
},
"target_date": "2026-07-24",
"orb_limit": 1
}
Response 200 (actual output, excerpt; default zh-TW vocabulary)
{
"data": {
"natal_chart": { /* full natal chart, same structure as /api/v1/charts data */ },
"progression": {
"progressed_jd": 2447929.2259389306,
"years_elapsed": 36.56,
"planets": [
{
"planet": "太陽",
"zodiac": "水瓶座",
"degree": 17.6684,
"total_degree": 317.6684,
"natal_house": 11
}
// …11 more omitted, 12 entries in total (same structure as transit planets)
],
"aspects_to_natal": [
{ "progressed_planet": "火星", "natal_planet": "天王星", "aspect_type": "合相", "orb": 0.11 },
{ "progressed_planet": "北交點", "natal_planet": "北交點", "aspect_type": "合相", "orb": 0.37 },
{ "progressed_planet": "太陽", "natal_planet": "冥王星", "aspect_type": "四分相", "orb": 0.58 }
// …remaining entries omitted; sorted by orb ascending
]
}
}
}
progressed_jd is the Julian Day of the progressed moment; years_elapsed is the number of years from birth to the target date (rounded to 2 decimals). If the birth time does not exist or is ambiguous in that timezone on the target date (daylight-saving transition), the API returns 400 invalid_time.
Finds the exact moment in a given year when the transiting Sun returns to its natal longitude (Newton iteration, accurate to about 10 seconds) and casts a full return chart for that moment. The location can optionally be overridden (relocated return).
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
natal | object | yes | Natal birth-data object (may include house_system; affects only natal_chart) | {"birth_date": …} |
year | integer | yes | Return year. Must be a JSON integer (strings or decimals return 400 missing_param); outside 1885–2099 returns 400 date_out_of_range | 2026 |
latitude | number | no | Return-location latitude (natal coordinates when omitted). Range −90 to 90 | 35.6762 |
longitude | number | no | Return-location longitude (natal coordinates when omitted). Range −180 to 180 | 139.6503 |
timezone | string | no | Return-location timezone (IANA). Display only (echoed in location), does not affect the computation — the return moment is defined in UTC | "Asia/Tokyo" |
Request example
{
"natal": {
"birth_date": "1990-01-01",
"birth_time": "12:00",
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei"
},
"year": 2026
}
Response 200 (actual output, excerpt; default zh-TW vocabulary)
{
"data": {
"natal_chart": { /* full natal chart, same structure as /api/v1/charts data */ },
"solar_return": {
"return_jd": 2461041.407478858,
"return_time_utc": "2025-12-31T21:46:46Z",
"location": {
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei"
},
"chart": {
"ascendant": { "zodiac": "射手座", "degree": 27.3962, "total_degree": 267.3962 },
"planets": [
{
"planet": "太陽",
"zodiac": "摩羯座",
"house": 1,
"degree": 10.4744,
"total_degree": 280.4744,
"aspects": [
{ "planet": "金星", "aspect_type": "合相", "orb": 1.38 },
{ "planet": "北交點", "aspect_type": "六分相", "orb": 0.5 }
// …remaining entries omitted
]
}
// …14 more omitted (return-chart planets: 15 entries, see note below)
],
"houses": [ /* 12 house cusps, same structure as natal houses */ ]
}
}
}
}
2025-12-31T21:46:46Z): the iteration starts from noon on the birthday of that year and finds the nearest solar-return instant, which is normal when the birthday is near the start or end of a year.
solar_return.chart uses the legacy chart structure — planets has 15 entries (12 bodies / nodes + 3 ruler points), without retrograde, 福點 (Part of Fortune), 莉莉絲 (Lilith), house_system, patterns, or element_stats, and houses are always Placidus. The natal_chart in the same response is the new 17-entry full structure.
When overriding with latitude / longitude / timezone, the return moment does not change (solar longitude is location-independent), but the return chart's ascendant and houses are recomputed for the new location. Actual output excerpt:
"solar_return": {
"return_time_utc": "2025-12-31T21:46:46Z",
"location": { "latitude": 35.6762, "longitude": 139.6503, "timezone": "Asia/Tokyo" }
// …chart is cast for the Tokyo coordinates
}
The midpoint chart of two natal charts: each body takes the short-arc midpoint of the two longitudes (the midpoint of 350° and 10° is 0°, not 180°), then aspects are computed between the midpoint positions.
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
a | object | yes | Person A's birth-data object (may include house_system) | {"birth_date": …} |
b | object | yes | Person B's birth-data object | {"birth_date": …} |
Request example
{
"a": {
"birth_date": "1990-01-01", "birth_time": "12:00",
"latitude": 25.033, "longitude": 121.5654, "timezone": "Asia/Taipei"
},
"b": {
"birth_date": "1992-06-15", "birth_time": "08:30",
"latitude": 25.033, "longitude": 121.5654, "timezone": "Asia/Taipei"
}
}
Response 200 (actual output, excerpt; default zh-TW vocabulary)
{
"data": {
"a_chart": { /* person A's full natal chart, same structure as /api/v1/charts data */ },
"b_chart": { /* person B's full natal chart */ },
"composite": {
"planets": [
{ "planet": "太陽", "zodiac": "牡羊座", "degree": 2.3199, "total_degree": 2.3199 },
{ "planet": "月亮", "zodiac": "摩羯座", "degree": 25.4433, "total_degree": 295.4433 }
// …10 more omitted, 12 entries in total
// (Sun through Pluto + North Node + South Node)
],
"aspects": [
{ "planet_a": "北交點", "planet_b": "南交點", "aspect_type": "對分相", "orb": 0.0 },
{ "planet_a": "金星", "planet_b": "海王星", "aspect_type": "四分相", "orb": 0.28 },
{ "planet_a": "太陽", "planet_b": "土星", "aspect_type": "六分相", "orb": 0.39 }
// …remaining entries omitted; sorted by orb ascending
]
}
}
}
orb_limit parameter).
City coordinate / timezone lookup for birthplace input. About 160 built-in cities: all 22 Taiwanese counties and cities (county-seat coordinates) plus major world cities, matching Chinese names and English / pinyin aliases.
| Name | Type | Required | Description | Example |
|---|---|---|---|---|
q | string | yes | Keyword, case-insensitive. Prefix matches rank before substring matches; at most 10 results. Missing or blank returns 400 missing_param | tai, 東京, tokyo |
lang | string | no | Response language (see Languages). With en / ja / ko, name / country are returned as English names (e.g. 台北市 → Taipei); matching is unaffected | en |
Response 200 (actual output of GET /api/v1/cities?q=tai&lang=en)
{
"data": [
{ "name": "Taipei", "country": "Taiwan", "latitude": 25.033, "longitude": 121.5654, "timezone": "Asia/Taipei" },
{ "name": "Taichung", "country": "Taiwan", "latitude": 24.1477, "longitude": 120.6736, "timezone": "Asia/Taipei" },
{ "name": "Tainan", "country": "Taiwan", "latitude": 22.9999, "longitude": 120.2269, "timezone": "Asia/Taipei" },
{ "name": "Chiayi County", "country": "Taiwan", "latitude": 23.4518, "longitude": 120.2555, "timezone": "Asia/Taipei" },
{ "name": "Taitung", "country": "Taiwan", "latitude": 22.7583, "longitude": 121.1444, "timezone": "Asia/Taipei" },
{ "name": "New Taipei", "country": "Taiwan", "latitude": 25.012, "longitude": 121.4657, "timezone": "Asia/Taipei" }
]
}
q come first, followed by cities whose name or alias merely contains q (above, Chiayi County ranks before New Taipei because its alias "Taibao" is a prefix match). No matches returns {"data":[]} (HTTP 200). Without lang, names are returned in Chinese (e.g. 台北市 / 台灣).
chart.planets[])| Field | Type | Description |
|---|---|---|
planet | string | Planet / point name (zh-TW by default; localized via lang) |
zodiac | string | Zodiac sign the body is in (one of the 12 signs, e.g. 摩羯座 = Capricorn) |
house | integer | House the body is in (1–12, per the chosen house system) |
degree | number | Degrees within the sign (0–30) |
total_degree | number | Absolute ecliptic longitude (0–360, from 0° Aries) |
retrograde | boolean | Whether the body is retrograde. Sun / Moon are always false; South Node matches North Node; Part of Fortune / Lilith / ruler points are always false (not physical bodies) |
aspects | array | Aspects formed with other planets (only present on certain planets, see below). Each entry has planet (the partner), aspect_type, and orb (deviation in degrees) |
ruling_planet | string | Only on the three "ruler" points: the name of the planet actually serving as ruler |
太陽 Sun, 月亮 Moon, 水星 Mercury, 金星 Venus, 火星 Mars, 木星 Jupiter, 土星 Saturn, 天王星 Uranus, 海王星 Neptune, 冥王星 Pluto, 北交點 North Node, 南交點 South Node福點 Part of Fortune (day charts ASC + Moon − Sun, night charts ASC − Moon + Sun; day/night decided by whether the Sun is above the horizon), 莉莉絲 Lilith (mean Black Moon Lilith = mean lunar apogee)北交點定位星 North Node Ruler, 南交點定位星 South Node Ruler, 上升星座定位星 Ascendant Ruler (each carries a ruling_planet field; the position is that of the ruling planet itself)aspects field only appears on: 太陽 Sun, 月亮 Moon, 金星 Venus, 土星 Saturn, 北交點 North Node, 南交點 South Node, and the three ruler points. The rest (Mercury, Mars, Jupiter, Uranus, Neptune, Pluto, Part of Fortune, Lilith) carry no aspects.
chart.house_system)| Value | House system | Description |
|---|---|---|
"P" | Placidus | Default. Cusps computed by semi-arc iteration; undefined in polar regions (roughly |latitude| ≥ 66°), which return 400 polar_latitude |
"W" | Whole Sign | House 1 = the entire ascendant sign (cusp = 0° of that sign), each following house +30°. The ascendant computation is the same as Placidus; works at polar latitudes too |
chart.patterns[])Three classic figures are detected from the positions of the 12 bodies / nodes; an empty array [] when none are present. (pattern_type values shown are the zh-TW defaults; with lang=en they become Grand Trine / T-Square / Grand Cross.)
pattern_type | Composition | Extra fields |
|---|---|---|
大三角 Grand Trine | 3 planets, each pair in trine | element: "火"/"土"/"風"/"水" (Fire/Earth/Air/Water) when all three share an element, otherwise null |
T三角 T-Square | 2 planets in opposition, both square a focal planet | apex: name of the focal planet |
大十字 Grand Cross | 4 planets forming 2 oppositions, adjacent pairs all square | — |
Every entry includes planets (array of the participating body names). Deduplication rules: a Grand Cross absorbs the T-Squares formed by its own 4 planets (they are not reported separately); the North Node–South Node "opposition by definition" is not used as the opposition axis of a T-Square / Grand Cross (though nodes can still play other roles such as the apex); and because the South Node is always exactly opposite the North Node, an opposition squared by the nodal axis is reported as a single T-Square (apex = North Node) rather than mirrored North/South duplicates.
chart.element_stats)Counts the element and modality distribution of the 10 classical planets (Sun through Pluto, excluding nodes and derived points) across the signs; each group sums to 10:
"element_stats": {
"elements": { "火": 1, "土": 5, "風": 2, "水": 2 },
"modalities": { "基本": 6, "固定": 3, "變動": 1 }
}
Key meanings (zh-TW defaults): elements 火 Fire / 土 Earth / 風 Air / 水 Water; modalities 基本 Cardinal / 固定 Fixed / 變動 Mutable. With lang=en the keys become Fire/Earth/Air/Water and Cardinal/Fixed/Mutable. Mapping: Aries = Fire/Cardinal, Taurus = Earth/Fixed, Gemini = Air/Mutable… cycling onward (elements repeat every 4 signs, modalities every 3).
| Aspect | Angle | Max orb |
|---|---|---|
合相 Conjunction | 0° | 15° |
六分相 Sextile | 60° | 6° |
四分相 Square | 90° | 8° |
三分相 Trine | 120° | 8° |
對分相 Opposition | 180° | 10° |
orb in responses is the deviation between the actual angle and the exact aspect angle (degrees, rounded to 2 decimals); smaller means tighter. Transits (default 3°) and progressions (default 1°) additionally filter via orb_limit.
chart.ascendant: the ascendant, with zodiac, degree (degrees within the sign), and total_degree (absolute ecliptic longitude). Identical under P and W.chart.houses: 12 house-cusp entries, each with house_number (1–12), degree (cusp's absolute ecliptic longitude 0–360), and zodiac (cusp sign).All errors return JSON in the form:
{ "error": { "code": "<code>", "message": "<message (zh-TW by default, localized via lang)>" } }
message is localized to the request lang (see Languages; always zh-TW when the body is unreadable or lang itself is invalid); code is language-independent. The examples below show the default zh-TW messages. Most parameter errors are HTTP 400, but the key-related errors carry different statuses (key_required / invalid_api_key are 401, quota_exceeded is 429) — always identify the error by code, not by HTTP status or message text.
| HTTP | code | Trigger | Example response |
|---|---|---|---|
| 400 | invalid_json | Body is not valid JSON or not a JSON object | {"error":{"code":"invalid_json","message":"無效的 JSON 格式"}} |
| 400 | missing_param | A required field is missing (including the a/b/natal objects, target_date, at.* subfields, q), orb_limit is not a number/null, or year is not an integer | {"error":{"code":"missing_param","message":"缺少參數:birth_date"}} |
| 400 | invalid_date | birth_date / at.date / target_date is malformed or not a valid date | {"error":{"code":"invalid_date","message":"無效的出生日期,格式須為 YYYY-MM-DD"}} |
| 400 | invalid_time | birth_time / at.time is malformed, out of 0–23 / 0–59, or the moment (including the birth time on a progression target date) falls in an invalid / ambiguous daylight-saving transition window | {"error":{"code":"invalid_time","message":"無效的出生時間,格式須為 HH:MM"}} |
| 400 | invalid_coordinates | Latitude/longitude not a number or out of range (strings always rejected; includes solar-return location overrides) | {"error":{"code":"invalid_coordinates","message":"無效的座標:緯度須在 ±90、經度須在 ±180 之間"}} |
| 400 | invalid_timezone | timezone / at.timezone is not a valid IANA identifier | {"error":{"code":"invalid_timezone","message":"無效的時區識別碼:Asia/Tokio"}} |
| 400 | invalid_house_system | house_system is not P/placidus/W/whole_sign (case-insensitive) | {"error":{"code":"invalid_house_system","message":"無效的宮位制式:K(支援 P / placidus 或 W / whole_sign)"}} |
| 400 | invalid_lang | lang is not a supported language (zh-TW/zh-tw/zh_tw/zh/en/ja/ko, case-insensitive; the empty string is also invalid). This error message is always zh-TW | {"error":{"code":"invalid_lang","message":"不支援的語言:fr(支援 zh-TW / en / ja / ko)"}} |
| 400 | polar_latitude | Polar latitude (roughly |latitude| ≥ 66°) makes Placidus houses unsolvable (Whole Sign W is unaffected) | {"error":{"code":"polar_latitude","message":"極區緯度無法計算 Placidus 宮位"}} |
| 400 | date_out_of_range | Any computed moment (birth date, transit at, progression target, return year) is outside Pluto's computable range (1885–2099) | {"error":{"code":"date_out_of_range","message":"日期超出可計算範圍(1885–2099)"}} |
| 401 | key_required | An API key is required but was not supplied (a billable endpoint in required mode, or /usage in any mode). See Authentication & Usage | {"error":{"code":"key_required","message":"此端點需要 API 金鑰"}} |
| 401 | invalid_api_key | The supplied key does not exist or has been revoked | {"error":{"code":"invalid_api_key","message":"API 金鑰無效或已撤銷"}} |
| 429 | quota_exceeded | The key's usage this month has reached its monthly_limit (message includes the limit) | {"error":{"code":"quota_exceeded","message":"已超過本月用量上限(1 次)"}} |
| 400 | invalid_email | Self-serve signup email is malformed or longer than 254 chars (POST /api/v1/signup) | {"error":{"code":"invalid_email","message":"電子郵件格式無效"}} |
| 400 | disposable_email | Signup used a disposable/throwaway email domain (POST /api/v1/signup) | {"error":{"code":"disposable_email","message":"拋棄式(暫時)信箱不予受理,請改用常用信箱"}} |
| 429 | signup_rate_limited | Too many self-serve signups from one source IP today (POST /api/v1/signup) | {"error":{"code":"signup_rate_limited","message":"今日申請次數過多,請明日再試"}} |
| 400 | verification_invalid | The confirmation token is unknown or already used (POST /api/v1/verify) | {"error":{"code":"verification_invalid","message":"驗證連結無效或已使用過"}} |
| 400 | verification_expired | The confirmation link is older than 24 hours (POST /api/v1/verify) | {"error":{"code":"verification_expired","message":"驗證連結已過期,請重新申請"}} |
| 404 | not_found | Nonexistent /api path | {"error":{"code":"not_found","message":"找不到此 API 路徑"}} |
| 500 | internal_error | Unexpected server error (no stack traces leaked) | {"error":{"code":"internal_error","message":"伺服器內部錯誤,請稍後再試"}} |
at.* and progression target_date validation reuses the birth-field validators, so their messages read "birth date / birth time" — always identify the error kind by code, never by the message text. With lang=en, a missing field reads e.g. {"error":{"code":"missing_param","message":"Missing parameter: birth_date"}}.
P) and Whole Sign (W) only; no other house systems are available.polar_latitude; switch to house_system: "W" to compute normally.date_out_of_range.Asia/Taipei, America/New_York); abbreviations or offsets such as UTC+8 or CST are not accepted.curl -s -X POST https://your-host/api/v1/charts \
-H "Content-Type: application/json" \
-d '{
"birth_date": "1990-01-01",
"birth_time": "12:00",
"latitude": 25.033,
"longitude": 121.5654,
"timezone": "Asia/Taipei",
"house_system": "W",
"lang": "en"
}'
curl -s -X POST https://your-host/api/v1/synastry \
-H "Content-Type: application/json" \
-d '{
"a": {"birth_date": "1990-01-01", "birth_time": "12:00",
"latitude": 25.033, "longitude": 121.5654, "timezone": "Asia/Taipei"},
"b": {"birth_date": "1992-06-15", "birth_time": "08:30",
"latitude": 25.033, "longitude": 121.5654, "timezone": "Asia/Taipei"},
"orb_limit": 6.0,
"lang": "en"
}'
curl -s -X POST https://your-host/api/v1/transits \
-H "Content-Type: application/json" \
-d '{
"natal": {"birth_date": "1990-01-01", "birth_time": "12:00",
"latitude": 25.033, "longitude": 121.5654, "timezone": "Asia/Taipei"}
}'
const res = await fetch("/api/v1/charts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
birth_date: "1990-01-01",
birth_time: "12:00",
latitude: 25.033,
longitude: 121.5654,
timezone: "Asia/Taipei",
lang: "en",
}),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.error.code}: ${json.error.message}`);
console.log(json.data.chart.ascendant); // { zodiac: "Aries", degree: 16.4422, … }
const retro = json.data.chart.planets.filter(p => p.retrograde);
console.log(retro.map(p => p.planet)); // ["Mercury", "Venus", "Jupiter"]
require "net/http"
require "json"
uri = URI("https://your-host/api/v1/charts")
res = Net::HTTP.post(
uri,
{
birth_date: "1990-01-01",
birth_time: "12:00",
latitude: 25.033,
longitude: 121.5654,
timezone: "Asia/Taipei",
lang: "en",
}.to_json,
"Content-Type" => "application/json"
)
json = JSON.parse(res.body)
if res.is_a?(Net::HTTPSuccess)
sun = json.dig("data", "chart", "planets").find { |p| p["planet"] == "Sun" }
puts "Sun: #{sun["zodiac"]} #{sun["degree"]}° (house #{sun["house"]})"
puts "Elements: #{json.dig("data", "chart", "element_stats", "elements")}"
else
warn "#{json.dig("error", "code")}: #{json.dig("error", "message")}"
end
/admin/api-keys and /admin/usage endpoint is protected by the server secret ADMIN_TOKEN and is used to issue / revoke API keys and inspect site-wide usage.
Authentication: pass the server-configured secret as Authorization: Bearer <ADMIN_TOKEN> (this is a separate operator secret, unrelated to API keys).
| HTTP | code | Trigger | Example response |
|---|---|---|---|
| 503 | admin_disabled | The server has no ADMIN_TOKEN set (the admin API is entirely disabled) | {"error":{"code":"admin_disabled","message":"管理介面未啟用"}} |
| 401 | admin_unauthorized | The supplied ADMIN_TOKEN is wrong | {"error":{"code":"admin_unauthorized","message":"管理權杖無效"}} |
Request body: {"label":"...","tier":"free","monthly_limit":1000|null} (monthly_limit of null means unlimited; it must be a non-negative integer or null).
Response 201 (actual output)
{
"data": {
"id": 1,
"token": "ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ",
"prefix": "ak_7hgEJAyQ",
"label": "Acme Web App",
"tier": "free",
"monthly_limit": 1000,
"created_at": "2026-07-25T04:55:45Z"
}
}
token is returned only once, at creation (the server stores only its SHA-256 hash); no endpoint ever returns the full key again — capture it then and hand it to the user.
monthly_limit is negative or not an integer (e.g. a string), the API returns 400 but with code = missing_param (message: "monthly_limit must be a non-negative integer or omitted"), not a dedicated error code. label defaults to an empty string and tier defaults to "free" when omitted.
Lists all keys newest-id-first, never the token/hash.
Response 200 (actual output, excerpt)
{
"data": [
{
"id": 3, "prefix": "ak_IVRZsXlz", "label": "tiny", "tier": "free",
"monthly_limit": 1, "created_at": "2026-07-25T04:55:45Z", "revoked_at": null
},
{
"id": 1, "prefix": "ak_7hgEJAyQ", "label": "Acme Web App", "tier": "free",
"monthly_limit": 1000, "created_at": "2026-07-25T04:55:45Z", "revoked_at": null
}
// …more omitted
]
}
revoked_at is null while the key is live; a revoked key carries a UTC timestamp.
Revokes the given key; it is invalid immediately afterwards (subsequent calls return 401 invalid_api_key).
Response 200 (actual output)
{ "data": { "id": 1, "revoked": true } }
id is unknown (or already revoked) the API returns 404 with a JSON error object {"error":{"code":"not_found",…}}.
Site-wide usage for a month (month defaults to the current UTC month). by_key maps a key id to its call count, with anonymous calls under "anonymous".
Response 200 (actual output)
{
"data": {
"month": "2026-07",
"total": 7,
"by_endpoint": { "cities": 3, "charts": 3, "synastry": 1 },
"by_key": { "1": 5, "anonymous": 1, "3": 1 }
}
}
There is also a browser admin page at /admin: it is a public shell page (the page itself loads without authentication) that prompts for the ADMIN_TOKEN client-side and then calls the admin API above with it. The actual operations remain protected by ADMIN_TOKEN.
The full machine-readable API specification (OpenAPI 3.1, with request/response schemas and examples for every endpoint): GET /openapi.json. It can be imported directly into Swagger UI, Postman, Insomnia, and similar tools.