← Home 中文

AstroChart API DocumentationBETA

Astrology chart computation API — natal charts, synastry, transits, progressions, solar returns, composite charts

Overview

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 keys are optional: you may attach a key to attribute your usage to it and inspect your own consumption via GET /api/v1/usage. In the current open mode calls without a key work fine; the server may later switch to 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).

Birth-data object (shared format)

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:

NameTypeRequiredDescriptionExample
birth_datestringyesBirth date, YYYY-MM-DD (zero-padded) and must be a valid calendar date"1990-01-01"
birth_timestringyesBirth time (local time), H:MM or HH:MM, 24-hour clock (hour 0–23, minute 0–59)"12:00"
latitudenumberyesBirthplace latitude, −90 to 90 (must be a JSON number; strings are rejected)25.033
longitudenumberyesBirthplace longitude, −180 to 180 (east positive)121.5654
timezonestringyesIANA timezone identifier"Asia/Taipei"
house_systemstringnoHouse system. "P" / "placidus" = Placidus (default); "W" / "whole_sign" = Whole Sign. Case-insensitive; any other value returns 400 invalid_house_system"W"

Languages (the lang parameter)

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.

Accepted values

Value (case-insensitive)LanguageNotes
zh-TW, zh-tw, zh_tw, zhTraditional ChineseDefault: omitting lang or sending null is identical to zh-TW
enEnglishPlanet / zodiac / aspect vocabulary translated to English
jaJapaneseVocabulary translated to Japanese (some terms share the same characters as Chinese)
koKoreanVocabulary translated to Korean

Any other value (including the empty string "") returns 400 invalid_lang, and that error message is always rendered in zh-TW.

What gets translated

lang translates vocabulary values only — it never changes the JSON structure:

Unreadable bodies are always zh-TW: when the request body is not valid JSON (invalid_json), the server cannot read lang, so that error message is always zh-TW.
Vocabulary notes: in Japanese (ja) the Earth element「土」is rendered as (火/地/風/水) and the modalities are 活動不動柔軟; in Korean (ko) the elements are 공기 and the modalities are 활동궁고정궁변통궁.

Example: lang: "en"

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 }
      }
    }
  }
}
The JSON examples elsewhere on this page show the default zh-TW vocabulary (e.g. 太陽 = 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.

Authentication & Usage (API keys)

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_MODEBehavior
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.
requiredKey is mandatory. Anonymous calls to any billable endpoint return 401 key_required.
Currently 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).

Passing a key

Keys look like ak_XXXXXXXX…. Use any one of the following (precedence: Authorization > X-API-Key > ?api_key=):

MethodExample
Request header Authorization: BearerAuthorization: Bearer ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ
Request header X-API-KeyX-API-Key: ak_7hgEJAyQvsuO4zHntkpoMvnvrecJ_6QJ
Query parameter ?api_key=/api/v1/cities?q=tai&api_key=ak_7hgEJAyQ…
CORS: the preflight (OPTIONS /api/v1/*) allows the Content-Type, Authorization, X-API-Key headers, so you can send a key cross-origin straight from a browser.

Which endpoints authenticate and count usage (billable)

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:

Not 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).

Key-related errors

HTTPcodeTriggerExample response
401key_requiredA billable endpoint is called anonymously in required mode; or /usage is called with no key in any mode{"error":{"code":"key_required","message":"此端點需要 API 金鑰"}}
401invalid_api_keyThe key does not exist or has been revoked{"error":{"code":"invalid_api_key","message":"API 金鑰無效或已撤銷"}}
429quota_exceededThe key's usage this month has reached its monthly_limit (the message includes the limit){"error":{"code":"quota_exceeded","message":"已超過本月用量上限(1 次)"}}
These three key errors carry HTTP statuses that are not 400 (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)".)

Inspecting usage

GET/api/v1/usage

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
    }
  }
}
FieldTypeDescription
key.prefixstringKey prefix (ak_ + 8 chars, 11 total); safe to display. The full key is never returned
key.labelstringThe label given when the key was created
key.tierstringPlan tier (e.g. free / pro; a label only)
monthstringReporting month YYYY-MM (UTC)
totalintegerTotal billable calls this month
monthly_limitinteger | nullMonthly cap; null means unlimited
remaininginteger | nullCalls left this month (max(limit − total, 0)); null when unlimited
by_endpointobjectCalls per endpoint (keyed by the name without the /api/v1/ prefix, e.g. charts), sorted most-used first
by_dayobjectCalls per day (YYYY-MM-DD) this month, sorted ascending by date

Calling billable endpoints with a key

# 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"

Self-serve signup (request an API key)

Anyone can self-serve a free API key — no admin token required. The flow is double opt-in and has two steps:

  1. Request: 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.
  2. Confirm: click the link in the email (it points to 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).

Email delivery during open beta: the confirmation email is currently sent via Resend. While a custom sending domain has not yet been verified and the shared test sender is in use, the email may only be delivered to the operator's own address until a custom domain is verified. When self-hosting without 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.

Step 1: Request (send the confirmation email)

POST/api/v1/signup

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.

Request fields

NameTypeRequiredDescriptionExample
emailstringyesThe 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"
langstringnoLanguage 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"
  }
}
The response is 202 Accepted, meaning "accepted, confirmation email sent" — not that a key was issued. This step returns no key. The confirmation link (GET /verify?token=…) is valid for 24 hours; after that you must sign up again.

Errors

HTTPcodeTriggerExample response
400invalid_emailThe email format is invalid, or its length exceeds 254 chars{"error":{"code":"invalid_email","message":"電子郵件格式無效"}}
400disposable_emailThe email belongs to a disposable/throwaway domain (e.g. mailinator.com){"error":{"code":"disposable_email","message":"拋棄式(暫時)信箱不予受理,請改用常用信箱"}}
429signup_rate_limitedThe daily request count from this source IP has hit the cap{"error":{"code":"signup_rate_limited","message":"今日申請次數過多,請明日再試"}}

Step 2: Confirm and receive the key

POST/api/v1/verify

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.

Request fields

NameTypeRequiredDescriptionExample
tokenstringyesThe one-time token from the confirmation email link (/verify?token=…)"3f9c…"
langstringnoLanguage 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"
  }
}
The plaintext 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.

Errors

HTTPcodeTriggerExample response
400verification_invalidThe token is unknown or has already been used{"error":{"code":"verification_invalid","message":"驗證連結無效或已使用過"}}
400verification_expiredThe link is older than 24 hours{"error":{"code":"verification_expired","message":"驗證連結已過期,請重新申請"}}

Health Check

GET/api/v1/health

Check service status and the underlying astro_chart gem version.

Response 200

{"status":"ok","gem_version":"0.3.0"}

Natal Chart

POST/api/v1/charts

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.

Request fields

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 }
      }
    }
  }
}

Whole Sign houses

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)
  }
}

Aspect pattern example

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
]

Synastry

POST/api/v1/synastry

Computes both natal charts, the cross-chart aspects between them, and the "person A's planets in person B's houses" overlay.

Request fields

NameTypeRequiredDescriptionExample
aobjectyesPerson A's birth-data object (may include house_system){"birth_date": …}
bobjectyesPerson B's birth-data object{"birth_date": …}
orb_limitnumber | nullnoMaximum 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_param6.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
      }
    }
  }
}
In each 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).

Transits

POST/api/v1/transits

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.

Request fields

NameTypeRequiredDescriptionExample
natalobjectyesNatal birth-data object (may include house_system, which affects which house each transiting planet falls in){"birth_date": …}
atobjectnoTransit moment. When omitted, the server's current UTC time is used. If provided, all three subfields are required{"date": …}
at.datestringyes*Date YYYY-MM-DD (validated like birth_date)"2026-07-24"
at.timestringyes*Time HH:MM (validated like birth_time)"12:00"
at.timezonestringyes*IANA timezone identifier (at.date/at.time are read as local time in this zone)"Asia/Taipei"
orb_limitnumbernoMaximum 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.

Secondary Progressions

POST/api/v1/progressions

"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.

Request fields

NameTypeRequiredDescriptionExample
natalobjectyesNatal birth-data object (may include house_system){"birth_date": …}
target_datestringyesProgression target date YYYY-MM-DD. The target moment uses "the same birth time + birth timezone" so whole years convert cleanly"2026-07-24"
orb_limitnumbernoMaximum 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.

Solar Return

POST/api/v1/solar-return

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).

Request fields

NameTypeRequiredDescriptionExample
natalobjectyesNatal birth-data object (may include house_system; affects only natal_chart){"birth_date": …}
yearintegeryesReturn year. Must be a JSON integer (strings or decimals return 400 missing_param); outside 1885–2099 returns 400 date_out_of_range2026
latitudenumbernoReturn-location latitude (natal coordinates when omitted). Range −90 to 9035.6762
longitudenumbernoReturn-location longitude (natal coordinates when omitted). Range −180 to 180139.6503
timezonestringnoReturn-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 */ ]
      }
    }
  }
}
The return moment for "year 2026" can fall at the end of the previous year (as here: 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.
Note (structural difference): the return chart 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.

Relocated return

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
}

Composite

POST/api/v1/composite

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.

Request fields

NameTypeRequiredDescriptionExample
aobjectyesPerson A's birth-data object (may include house_system){"birth_date": …}
bobjectyesPerson 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
      ]
    }
  }
}
The composite chart has no houses and no ascendant: there is no agreed-upon practice for house systems in midpoint composite charts (cusp midpoints vs. recasting for a reference location), so they are deliberately not returned. Aspects use the natal default orbs (there is no orb_limit parameter).

City Search

GET/api/v1/cities?q=<keyword>

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.

Query parameters

NameTypeRequiredDescriptionExample
qstringyesKeyword, case-insensitive. Prefix matches rank before substring matches; at most 10 results. Missing or blank returns 400 missing_paramtai, 東京, tokyo
langstringnoResponse language (see Languages). With en / ja / ko, name / country are returned as English names (e.g. 台北市Taipei); matching is unaffecteden

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" }
  ]
}
Ordering: cities whose name or alias starts with 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. 台北市 / 台灣).

Response Field Reference

Planet object (chart.planets[])

FieldTypeDescription
planetstringPlanet / point name (zh-TW by default; localized via lang)
zodiacstringZodiac sign the body is in (one of the 12 signs, e.g. 摩羯座 = Capricorn)
houseintegerHouse the body is in (1–12, per the chosen house system)
degreenumberDegrees within the sign (0–30)
total_degreenumberAbsolute ecliptic longitude (0–360, from 0° Aries)
retrogradebooleanWhether 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)
aspectsarrayAspects 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_planetstringOnly on the three "ruler" points: the name of the planet actually serving as ruler

Planet list (17 entries in the planets array, in order)

The 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.

House systems (chart.house_system)

ValueHouse systemDescription
"P"PlacidusDefault. Cusps computed by semi-arc iteration; undefined in polar regions (roughly |latitude| ≥ 66°), which return 400 polar_latitude
"W"Whole SignHouse 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

Aspect patterns (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_typeCompositionExtra fields
大三角 Grand Trine3 planets, each pair in trineelement: "火"/"土"/"風"/"水" (Fire/Earth/Air/Water) when all three share an element, otherwise null
T三角 T-Square2 planets in opposition, both square a focal planetapex: name of the focal planet
大十字 Grand Cross4 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.

Element statistics (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 types and orbs

AspectAngleMax orb
合相 Conjunction15°
六分相 Sextile60°
四分相 Square90°
三分相 Trine120°
對分相 Opposition180°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.

Ascendant and houses

Error Codes

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.

HTTPcodeTriggerExample response
400invalid_jsonBody is not valid JSON or not a JSON object{"error":{"code":"invalid_json","message":"無效的 JSON 格式"}}
400missing_paramA 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"}}
400invalid_datebirth_date / at.date / target_date is malformed or not a valid date{"error":{"code":"invalid_date","message":"無效的出生日期,格式須為 YYYY-MM-DD"}}
400invalid_timebirth_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"}}
400invalid_coordinatesLatitude/longitude not a number or out of range (strings always rejected; includes solar-return location overrides){"error":{"code":"invalid_coordinates","message":"無效的座標:緯度須在 ±90、經度須在 ±180 之間"}}
400invalid_timezonetimezone / at.timezone is not a valid IANA identifier{"error":{"code":"invalid_timezone","message":"無效的時區識別碼:Asia/Tokio"}}
400invalid_house_systemhouse_system is not P/placidus/W/whole_sign (case-insensitive){"error":{"code":"invalid_house_system","message":"無效的宮位制式:K(支援 P / placidus 或 W / whole_sign)"}}
400invalid_langlang 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)"}}
400polar_latitudePolar latitude (roughly |latitude| ≥ 66°) makes Placidus houses unsolvable (Whole Sign W is unaffected){"error":{"code":"polar_latitude","message":"極區緯度無法計算 Placidus 宮位"}}
400date_out_of_rangeAny 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)"}}
401key_requiredAn 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 金鑰"}}
401invalid_api_keyThe supplied key does not exist or has been revoked{"error":{"code":"invalid_api_key","message":"API 金鑰無效或已撤銷"}}
429quota_exceededThe key's usage this month has reached its monthly_limit (message includes the limit){"error":{"code":"quota_exceeded","message":"已超過本月用量上限(1 次)"}}
400invalid_emailSelf-serve signup email is malformed or longer than 254 chars (POST /api/v1/signup){"error":{"code":"invalid_email","message":"電子郵件格式無效"}}
400disposable_emailSignup used a disposable/throwaway email domain (POST /api/v1/signup){"error":{"code":"disposable_email","message":"拋棄式(暫時)信箱不予受理,請改用常用信箱"}}
429signup_rate_limitedToo many self-serve signups from one source IP today (POST /api/v1/signup){"error":{"code":"signup_rate_limited","message":"今日申請次數過多,請明日再試"}}
400verification_invalidThe confirmation token is unknown or already used (POST /api/v1/verify){"error":{"code":"verification_invalid","message":"驗證連結無效或已使用過"}}
400verification_expiredThe confirmation link is older than 24 hours (POST /api/v1/verify){"error":{"code":"verification_expired","message":"驗證連結已過期,請重新申請"}}
404not_foundNonexistent /api path{"error":{"code":"not_found","message":"找不到此 API 路徑"}}
500internal_errorUnexpected server error (no stack traces leaked){"error":{"code":"internal_error","message":"伺服器內部錯誤,請稍後再試"}}
Message wording: transit 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"}}.

Limitations

Code Examples

curl — natal chart (Whole Sign, English vocabulary)

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 — synastry

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 — transits right now (omitting at)

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"}
  }'

JavaScript — fetch()

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"]

Ruby — Net::HTTP

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 (operators only)

This section is for operators only, not for ordinary API consumers. Every /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).

HTTPcodeTriggerExample response
503admin_disabledThe server has no ADMIN_TOKEN set (the admin API is entirely disabled){"error":{"code":"admin_disabled","message":"管理介面未啟用"}}
401admin_unauthorizedThe supplied ADMIN_TOKEN is wrong{"error":{"code":"admin_unauthorized","message":"管理權杖無效"}}

Create a key

POST/admin/api-keys

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"
  }
}
The plaintext 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.
Implementation note: if 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.

List keys

GET/admin/api-keys

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.

Revoke a key

DELETE/admin/api-keys/:id

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 } }
Implementation note: when the id is unknown (or already revoked) the API returns 404 with a JSON error object {"error":{"code":"not_found",…}}.

Site-wide usage

GET/admin/usage?month=YYYY-MM

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 }
  }
}

Browser admin page

GET/admin

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.

OpenAPI Specification

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.