🌍 Astrocartography API and ✋ Palmistry API are now live. Ship them in your app today.Get Started

Guides

Western birth chart API integration

This guide chains five of our endpoints into one flow: a user types a city name and gets back a full Western natal chart. That means planets, houses, aspects, a wheel chart image, and text reports.

We wrote it for human developers and for AI coding agents. The pipeline table below is the whole integration in one view, and the app section further down is a complete two-screen Next.js app you can copy and run. All calls are POST with HTTP Basic auth (user ID as username, API key as password) against https://json.astrologyapi.com/v1. We do not send CORS headers, so run every call server-side. Full endpoint list: Western API reference.

The pipeline

StepEndpointReturnsFeeds the next step
1geo_detailsgeonames[] with latitude, longitudelatitude/longitude into step 2; lat/lon into steps 3 to 5
2timezone_with_dsttimezone (float UTC offset)tzone in the birth payload
3western_horoscopeplanets, houses, aspects, ascendant, midheavendisplay data; planet names for step 5 path params
4natal_wheel_chartchart_url (hosted image)render as-is
5general_ascendant_report/tropical, general_sign_report/tropical/:planetName, general_house_report/tropical/:planetName, house_cusps_report/tropicalplain-text interpretation reportsdisplay text

Steps 3, 4, and 5 all take the same birth payload. Build it once after step 2 and reuse it.

1. Place name to coordinates

geo_details takes a place string and returns candidate locations. Both params are required: place (string) and maxRows (int, number of results). Partial strings work, so the endpoint fits an autocomplete input.

curl -s https://json.astrologyapi.com/v1/geo_details \
  -u "$USER_ID:$API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"place": "mum", "maxRows": 2}'
{
  "geonames": [
    {
      "place_name": "Mumbai",
      "latitude": 19.07283,
      "longitude": "72.88261",
      "timezone_id": "Asia/Kolkata",
      "country_code": "IN"
    }
  ]
}

One gotcha: we return latitude as a number but longitude as a string. Convert it with parseFloat() before you use it anywhere.

const auth =
  'Basic ' + Buffer.from(USER_ID + ':' + API_KEY).toString('base64')

async function post(endpoint, body) {
  const res = await fetch('https://json.astrologyapi.com/v1/' + endpoint, {
    method: 'POST',
    headers: { Authorization: auth, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  if (!res.ok) throw new Error(endpoint + ' failed: ' + res.status)
  return res.json()
}

const geo = await post('geo_details', { place: 'mum', maxRows: 2 })
const match = geo.geonames[0]
const lat = match.latitude          // number
const lon = parseFloat(match.longitude) // string in the response. Parse it.

The post() helper above is reused in every later snippet. Carry lat and lon forward: they go into latitude/longitude in step 2, then into lat/lon of the birth payload.

2. Coordinates and birth date to tzone

timezone_with_dst returns the UTC offset in force at those coordinates on a given date, with daylight saving applied. Send the birth date, never today's date. Our date param is MM-DD-YYYY, not DD-MM or ISO. Why this step matters is covered in the timezones and DST guide.

curl -s https://json.astrologyapi.com/v1/timezone_with_dst \
  -u "$USER_ID:$API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"latitude": 19.07283, "longitude": 72.88261, "date": "06-27-2000"}'
{
  "status": true,
  "timezone": 5.5,
  "timezone_in_ms": 19800000,
  "date": "1992-12-01T00:00:00.000Z"
}
// post() from step 1. date is MM-DD-YYYY, not DD-MM or ISO.
const zone = await post('timezone_with_dst', {
  latitude: lat,
  longitude: lon,
  date: '06-27-2000',
})
const tzone = zone.timezone // float, e.g. 5.5

The timezone field is the float that becomes tzone in every remaining request.

3. The natal chart: western_horoscope

western_horoscope is the single big natal call. The body takes eight required birth fields: day, month, year, hour, min (ints) and lat, lon, tzone (floats). Two more are optional with defaults: house_type (default placidus; we also support koch, topocentric, poryphry, equal_house, and whole_sign) and is_asteroids (boolean, default false, adds asteroid positions).

curl -s https://json.astrologyapi.com/v1/western_horoscope \
  -u "$USER_ID:$API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "day": 10, "month": 5, "year": 1990,
    "hour": 19, "min": 55,
    "lat": 19.2056, "lon": 25.2056, "tzone": 5.5,
    "house_type": "placidus"
  }'
// post() from step 1. Same body shape for every step from here on.
const birth = {
  day: 10, month: 5, year: 1990,
  hour: 19, min: 55,
  lat, lon, tzone,
  house_type: 'placidus',
}
const chart = await post('western_horoscope', birth)
// chart.planets, chart.houses, chart.aspects,
// chart.ascendant, chart.midheaven

The response has five parts you will use:

  • planets[]: one entry per body, Sun through Pluto plus Node, Chiron, and Part of Fortune. Each has name, full_degree, norm_degree (degree within the sign), speed, is_retro (the string "true" or "false", not a boolean), sign_id, sign, and house.
  • houses[]: twelve cusps, each with house, sign, and degree.
  • ascendant, midheaven, and vertex: floats, absolute zodiac degrees.
  • lilith: one object in the same shape as a planet entry.
  • aspects[]: covered below.
{
  "planets": [
    {
      "name": "Sun",
      "full_degree": 275.6427,
      "norm_degree": 5.6427,
      "speed": 1.019,
      "is_retro": "false",
      "sign_id": 10,
      "sign": "Capricorn",
      "house": 2
    },
    {
      "name": "Node",
      "full_degree": 357.3824,
      "norm_degree": 27.3824,
      "speed": -0.053,
      "is_retro": "true",
      "sign_id": 12,
      "sign": "Pisces",
      "house": 4
    }
  ],
  "houses": [
    { "house": 1, "sign": "Sagittarius", "degree": 240.71431 },
    { "house": 2, "sign": "Capricorn", "degree": 270.69055 }
  ],
  "ascendant": 240.71431015862024,
  "midheaven": 156.92135925483103,
  "vertex": 118.53668227404134,
  "aspects": [ ... ]
}

Reading the aspects array

Each aspect entry names two bodies and the angle relationship between them. Points like the Midheaven appear as bodies too, so do not assume both names are planets.

"aspects": [
  {
    "aspecting_planet": "Sun",
    "aspected_planet": "Mercury",
    "aspecting_planet_id": 0,
    "aspected_planet_id": 3,
    "type": "Conjunction",
    "orb": 2.66,
    "diff": 2.66
  },
  {
    "aspecting_planet": "Sun",
    "aspected_planet": "Midheaven",
    "aspecting_planet_id": 0,
    "aspected_planet_id": 11,
    "type": "Trine",
    "orb": 1.28,
    "diff": 118.72
  },
  {
    "aspecting_planet": "Moon",
    "aspected_planet": "Mercury",
    "aspecting_planet_id": 1,
    "aspected_planet_id": 3,
    "type": "Square",
    "orb": 3.97,
    "diff": 93.97
  }
]
FieldMeaning
aspecting_planet / aspected_planetthe two bodies, by name
aspecting_planet_id / aspected_planet_idnumeric ids for the same two bodies
typeaspect name. This sample includes Conjunction, Sextile, Square, and Trine
diffmeasured angular separation in degrees
orbhow far diff sits from the exact aspect angle. Sun trine Midheaven above: diff 118.72, exact trine 120, so orb 1.28. Smaller orb means a tighter, stronger aspect

A common display pattern: keep the five classic major aspect types and sort by orb, tightest first.

const MAJOR = ['Conjunction', 'Sextile', 'Square', 'Trine', 'Opposition']

const majorAspects = chart.aspects
  .filter((a) => MAJOR.includes(a.type))
  .sort((a, b) => a.orb - b.orb) // tightest first

for (const a of majorAspects) {
  console.log(
    a.aspecting_planet + ' ' + a.type + ' ' + a.aspected_planet +
    ' (orb ' + a.orb + ')',
  )
}
// Sun Conjunction Mercury (orb 2.66)
// Sun Trine Midheaven (orb 1.28)

4. The wheel chart image

natal_wheel_chart renders the chart as a hosted image and returns its URL. It takes the same eight birth fields plus house_type, and six theming params: planet_icon_color, inner_circle_background, sign_icon_color, sign_background, chart_size (int, pixels), and image_type (for example png). Colors accept hex values or color names.

curl -s https://json.astrologyapi.com/v1/natal_wheel_chart \
  -u "$USER_ID:$API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "day": 10, "month": 5, "year": 1990,
    "hour": 19, "min": 55,
    "lat": 19.2056, "lon": 25.2056, "tzone": 5.5,
    "planet_icon_color": "#F57C00",
    "inner_circle_background": "#FFF8E1",
    "sign_icon_color": "red",
    "sign_background": "#ffffff",
    "chart_size": 500,
    "image_type": "png",
    "house_type": "placidus"
  }'
{
  "status": true,
  "chart_url": "https://s3.ap-south-1.amazonaws.com/western-chart/65a7ab90-....svg",
  "msg": "Chart created successfully!"
}
// post() from step 1.
const wheel = await post('natal_wheel_chart', {
  ...birth,
  planet_icon_color: '#F57C00',
  inner_circle_background: '#FFF8E1',
  sign_icon_color: 'red',
  sign_background: '#ffffff',
  chart_size: 500,
  image_type: 'png',
})
// wheel.chart_url is a hosted image. Store or proxy it.

5. Text reports

Four report endpoints turn the same birth payload into readable interpretation text. The sign and house report endpoints take the planet as a path param, and we accept seven values: sun, moon, mars, mercury, jupiter, venus, saturn.

  • general_ascendant_report/tropical returns { ascendant, report }: the rising sign and a personality report for it.
  • general_sign_report/tropical/:planetName returns { planet_name, sign_name, report }: what that planet's sign placement means.
  • general_house_report/tropical/:planetName returns { planet_name, house, report }: what that planet's house placement means.
  • house_cusps_report/tropical returns an array of { planet_name, house, report } entries, one per planet, so you get every house report in one call.
# Ascendant report
curl -s https://json.astrologyapi.com/v1/general_ascendant_report/tropical \
  -u "$USER_ID:$API_KEY" -H 'Content-Type: application/json' -d "$BIRTH_JSON"

# Sign report for one planet (path param: sun, moon, mars,
# mercury, jupiter, venus, saturn)
curl -s https://json.astrologyapi.com/v1/general_sign_report/tropical/moon \
  -u "$USER_ID:$API_KEY" -H 'Content-Type: application/json' -d "$BIRTH_JSON"

# House report for one planet (same seven planet names)
curl -s https://json.astrologyapi.com/v1/general_house_report/tropical/sun \
  -u "$USER_ID:$API_KEY" -H 'Content-Type: application/json' -d "$BIRTH_JSON"

# House reports for all planets in one call
curl -s https://json.astrologyapi.com/v1/house_cusps_report/tropical \
  -u "$USER_ID:$API_KEY" -H 'Content-Type: application/json' -d "$BIRTH_JSON"
// post() from step 1, birth from step 3.
const asc = await post('general_ascendant_report/tropical', birth)
// { ascendant: 'Sagittarius', report: '...' }

const moonSign = await post('general_sign_report/tropical/moon', birth)
// { planet_name: 'MOON', sign_name: 'Gemini', report: '...' }

const sunHouse = await post('general_house_report/tropical/sun', birth)
// { planet_name: 'SUN', house: 3, report: '...' }

const houseReports = await post('house_cusps_report/tropical', birth)
// Array: [{ planet_name: 'Sun', house: 5, report: '...' }, ...]

Build it: a two-screen chart app

The five steps above are the reference. This section is the product: a minimal Next.js app with two screens. Screen 1 collects the birth details. Screen 2 renders the chart. Run npx create-next-app, choose the pages router, replace the five files below, and put your credentials in .env.local as ASTROLOGY_USER_ID and ASTROLOGY_API_KEY. No other packages.

We do not send CORS headers, so the browser never calls us directly. Both screens talk to two small API routes, and the API routes talk to us.

pages/index.jsx

Screen 1, the form. A date input, a time input, and a place search. The Search button calls /api/place-search and lists candidates to pick from. Choose a house system if you want something other than placidus, then submit.

// pages/index.jsx
import { useState } from 'react'
import { useRouter } from 'next/router'

const HOUSE_SYSTEMS = [
  'placidus',
  'koch',
  'topocentric',
  'poryphry',
  'equal_house',
  'whole_sign',
]

export default function BirthForm() {
  const router = useRouter()
  const [date, setDate] = useState('')
  const [time, setTime] = useState('')
  const [place, setPlace] = useState('')
  const [candidates, setCandidates] = useState([])
  const [selected, setSelected] = useState(null)
  const [houseType, setHouseType] = useState('placidus')
  const [searching, setSearching] = useState(false)
  const [error, setError] = useState('')

  async function searchPlace() {
    if (!place.trim()) return
    setSearching(true)
    setError('')
    setSelected(null)
    try {
      const res = await fetch(
        '/api/place-search?place=' + encodeURIComponent(place),
      )
      const data = await res.json()
      if (!res.ok) throw new Error(data.error || 'Search failed')
      setCandidates(data.candidates)
      if (data.candidates.length === 0) {
        setError('No places found. Try a longer name.')
      }
    } catch (err) {
      setError(err.message)
      setCandidates([])
    } finally {
      setSearching(false)
    }
  }

  function submit(e) {
    e.preventDefault()
    if (!date || !time) {
      setError('Enter a birth date and time.')
      return
    }
    if (!selected) {
      setError('Search for a place and pick one from the list.')
      return
    }
    router.push({
      pathname: '/chart',
      query: {
        date,
        time,
        lat: selected.lat,
        lon: selected.lon,
        place: selected.name,
        house: houseType,
      },
    })
  }

  return (
    <main className="form-page">
      <h1>Birth chart</h1>
      <form onSubmit={submit} className="card">
        <label htmlFor="date">Birth date</label>
        <input id="date" type="date" value={date} required
          onChange={(e) => setDate(e.target.value)} />

        <label htmlFor="time">Birth time</label>
        <input id="time" type="time" value={time} required
          onChange={(e) => setTime(e.target.value)} />

        <label htmlFor="place">Birth place</label>
        <div className="row">
          <input id="place" type="text" value={place} placeholder="City name"
            onChange={(e) => setPlace(e.target.value)} />
          <button type="button" onClick={searchPlace} disabled={searching}>
            {searching ? 'Searching' : 'Search'}
          </button>
        </div>

        {candidates.length > 0 && (
          <ul className="candidates">
            {candidates.map((c) => (
              <li key={c.name + c.lat + c.lon}>
                <button
                  type="button"
                  onClick={() => setSelected(c)}
                  className={selected === c ? 'candidate selected' : 'candidate'}
                >
                  {c.name}, {c.country}
                </button>
              </li>
            ))}
          </ul>
        )}

        <label htmlFor="house">House system</label>
        <select id="house" value={houseType}
          onChange={(e) => setHouseType(e.target.value)}>
          {HOUSE_SYSTEMS.map((h) => (
            <option key={h} value={h}>{h}</option>
          ))}
        </select>

        {error && <p className="error">{error}</p>}
        <button type="submit">Get the chart</button>
      </form>
    </main>
  )
}

pages/api/place-search.js

The server side of the place search. It calls geo_details and returns clean candidates. The string longitude is parsed here, once, so nothing downstream has to think about it.

// pages/api/place-search.js
// We send no CORS headers, so the browser talks to this route
// and this route talks to us.
const BASE = 'https://json.astrologyapi.com/v1'
const AUTH =
  'Basic ' +
  Buffer.from(
    process.env.ASTROLOGY_USER_ID + ':' + process.env.ASTROLOGY_API_KEY,
  ).toString('base64')

export default async function handler(req, res) {
  const place = String(req.query.place || '').trim()
  if (!place) {
    return res.status(400).json({ error: 'Pass a place query param.' })
  }
  const upstream = await fetch(BASE + '/geo_details', {
    method: 'POST',
    headers: { Authorization: AUTH, 'Content-Type': 'application/json' },
    body: JSON.stringify({ place, maxRows: 6 }),
  })
  if (!upstream.ok) {
    return res
      .status(502)
      .json({ error: 'geo_details failed with status ' + upstream.status })
  }
  const data = await upstream.json()
  const candidates = (data.geonames || []).map((g) => ({
    name: g.place_name,
    country: g.country_code,
    lat: g.latitude,
    // We return longitude as a string. Convert it here, once,
    // so nothing downstream has to think about it.
    lon: parseFloat(g.longitude),
  }))
  res.status(200).json({ candidates })
}

pages/api/chart.js

The whole chain from the reference sections, in one route. It converts the date to MM-DD-YYYY for timezone_with_dst, builds the birth payload once, then runs western_horoscope, natal_wheel_chart (with three of the theming params in use), the ascendant report, and the house reports in parallel. It retries 5xx responses once and never retries 4xx, because the same payload will fail the same way again.

// pages/api/chart.js
const BASE = 'https://json.astrologyapi.com/v1'
const AUTH =
  'Basic ' +
  Buffer.from(
    process.env.ASTROLOGY_USER_ID + ':' + process.env.ASTROLOGY_API_KEY,
  ).toString('base64')

async function post(endpoint, body) {
  for (let attempt = 0; attempt < 2; attempt++) {
    const res = await fetch(BASE + '/' + endpoint, {
      method: 'POST',
      headers: { Authorization: AUTH, 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    })
    if (res.ok) return res.json()
    // Retry once on 5xx. Never retry a 4xx: it means the payload
    // is wrong, and the same payload will fail the same way again.
    if (res.status < 500 || attempt === 1) {
      throw new Error(endpoint + ' failed with status ' + res.status)
    }
  }
}

export default async function handler(req, res) {
  const { date, time, lat, lon, house } = req.query
  if (!date || !time || !lat || !lon) {
    return res
      .status(400)
      .json({ error: 'date, time, lat, and lon are required.' })
  }
  const [year, month, day] = date.split('-').map(Number)
  const [hour, min] = time.split(':').map(Number)

  try {
    // Our timezone endpoint takes dates as MM-DD-YYYY.
    const zone = await post('timezone_with_dst', {
      latitude: parseFloat(lat),
      longitude: parseFloat(lon),
      date:
        String(month).padStart(2, '0') +
        '-' +
        String(day).padStart(2, '0') +
        '-' +
        year,
    })

    // One birth payload feeds every remaining call.
    const birth = {
      day,
      month,
      year,
      hour,
      min,
      lat: parseFloat(lat),
      lon: parseFloat(lon),
      tzone: zone.timezone,
      house_type: house || 'placidus',
    }

    // Chart data, wheel image, and reports in parallel.
    const [horoscope, wheel, ascReport, houseReports] = await Promise.all([
      post('western_horoscope', birth),
      post('natal_wheel_chart', {
        ...birth,
        planet_icon_color: '#635BFF',
        inner_circle_background: '#FAFAF7',
        chart_size: 500,
      }),
      post('general_ascendant_report/tropical', birth),
      post('house_cusps_report/tropical', birth),
    ])

    res.status(200).json({
      birth,
      planets: horoscope.planets,
      houses: horoscope.houses,
      aspects: horoscope.aspects,
      ascendant: ascReport.ascendant,
      ascendantReport: ascReport.report,
      wheelUrl: wheel.chart_url,
      houseReports,
    })
  } catch (err) {
    res.status(502).json({ error: err.message })
  }
}

pages/chart.jsx

Screen 2, the result. It reads the birth details from the URL, calls /api/chart, and renders everything: the wheel image, a sun, moon, and ascendant summary, the planets table, the major aspects sorted tightest first, the ascendant report, and an expandable house report list. Loading and error states included.

// pages/chart.jsx
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import Link from 'next/link'

const MAJOR = ['Conjunction', 'Sextile', 'Square', 'Trine', 'Opposition']

export default function ChartPage() {
  const router = useRouter()
  const [data, setData] = useState(null)
  const [error, setError] = useState('')
  const [openHouse, setOpenHouse] = useState(null)

  useEffect(() => {
    if (!router.isReady) return
    const { date, time, lat, lon, house } = router.query
    if (!date || !time || !lat || !lon) {
      setError('Missing birth details. Start from the form.')
      return
    }
    const params = new URLSearchParams({
      date,
      time,
      lat,
      lon,
      house: house || 'placidus',
    })
    fetch('/api/chart?' + params.toString())
      .then(async (res) => {
        const body = await res.json()
        if (!res.ok) throw new Error(body.error || 'Chart request failed')
        setData(body)
      })
      .catch((err) => setError(err.message))
  }, [router.isReady])

  if (error) {
    return (
      <main className="result-page">
        <p className="error">{error}</p>
        <Link href="/">Back to the form</Link>
      </main>
    )
  }
  if (!data) {
    return <main className="result-page"><p>Calculating the chart...</p></main>
  }

  const { planets, aspects, houseReports } = data
  const majorAspects = aspects
    .filter((a) => MAJOR.includes(a.type))
    .sort((a, b) => a.orb - b.orb) // tightest first
  const sun = planets.find((p) => p.name === 'Sun')
  const moon = planets.find((p) => p.name === 'Moon')

  return (
    <main className="result-page">
      <header className="card">
        <h1>{router.query.place}</h1>
        <p className="muted">
          {router.query.date} at {router.query.time} &middot;{' '}
          {data.birth.house_type} houses &middot; UTC
          {data.birth.tzone >= 0 ? '+' : ''}
          {data.birth.tzone}
        </p>
      </header>

      <div className="summary">
        <div className="card">
          <p className="muted">Sun</p><p>{sun ? sun.sign : '-'}</p>
        </div>
        <div className="card">
          <p className="muted">Moon</p><p>{moon ? moon.sign : '-'}</p>
        </div>
        <div className="card">
          <p className="muted">Ascendant</p><p>{data.ascendant}</p>
        </div>
      </div>

      <div className="card">
        <img src={data.wheelUrl} alt="Natal wheel chart" className="wheel" />
      </div>

      <div className="card">
        <h2>Planets</h2>
        <table>
          <thead>
            <tr><th>Planet</th><th>Sign</th><th>House</th><th>Motion</th></tr>
          </thead>
          <tbody>
            {planets.map((p) => (
              <tr key={p.name}>
                <td>{p.name}</td>
                <td>{p.sign}</td>
                <td>{p.house}</td>
                <td>{p.is_retro === 'true' ? 'Retrograde' : 'Direct'}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="card">
        <h2>Major aspects</h2>
        <ul className="aspects">
          {majorAspects.map((a, i) => (
            <li key={i}>
              {a.aspecting_planet} {a.type} {a.aspected_planet}{' '}
              <span className="muted">orb {a.orb}</span>
            </li>
          ))}
        </ul>
      </div>

      <div className="card">
        <h2>Ascendant report</h2>
        <p>{data.ascendantReport}</p>
      </div>

      <div className="card">
        <h2>House reports</h2>
        {houseReports.map((r, i) => (
          <div key={r.planet_name} className="house-item">
            <button type="button"
              onClick={() => setOpenHouse(openHouse === i ? null : i)}>
              {r.planet_name} in house {r.house}
            </button>
            {openHouse === i && <p>{r.report}</p>}
          </div>
        ))}
      </div>

      <Link href="/">New chart</Link>
    </main>
  )
}

styles/globals.css

All of the styling. create-next-app already imports this file in pages/_app, so replacing its contents is enough.

/* styles/globals.css */
* { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
  background: #fafaf7;
  color: #1a1a1a;
  line-height: 1.5;
}

main { margin: 0 auto; padding: 32px 16px 64px; }
.form-page { max-width: 640px; }
.result-page { max-width: 960px; }

h1 { font-size: 24px; margin-bottom: 16px; }
h2 { font-size: 18px; margin-bottom: 12px; }

.card {
  background: #ffffff;
  border: 1px solid #e5e5e0;
  border-radius: 10px;
  padding: 20px;
  margin-bottom: 16px;
}

label { display: block; font-size: 14px; color: #6b6b6b; margin: 16px 0 6px; }

input,
select {
  width: 100%;
  font-size: 16px;
  padding: 10px 12px;
  border: 1px solid #e5e5e0;
  border-radius: 8px;
  background: #ffffff;
  color: #1a1a1a;
}

button {
  font-size: 16px;
  padding: 10px 20px;
  border: none;
  border-radius: 8px;
  background: #635bff;
  color: #ffffff;
  cursor: pointer;
}
button:disabled { opacity: 0.6; }
form button[type='submit'] { margin-top: 24px; width: 100%; }

.row { display: flex; gap: 8px; }
.row input { flex: 1; }

.candidates { list-style: none; margin-top: 8px; }
.candidates .candidate {
  width: 100%;
  text-align: left;
  background: #ffffff;
  color: #1a1a1a;
  border: 1px solid #e5e5e0;
  margin-bottom: 6px;
}
.candidates .candidate.selected { border-color: #635bff; color: #635bff; }

.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }

.wheel { display: block; max-width: 100%; margin: 0 auto; }

table { width: 100%; border-collapse: collapse; font-size: 14px; }
th,
td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #e5e5e0; }
th { color: #6b6b6b; font-weight: 600; }

.aspects { list-style: none; }
.aspects li { padding: 6px 0; border-bottom: 1px solid #e5e5e0; }
.aspects li:last-child { border-bottom: none; }

.house-item button {
  width: 100%;
  text-align: left;
  background: #ffffff;
  color: #1a1a1a;
  border: 1px solid #e5e5e0;
  margin-bottom: 6px;
}
.house-item p { padding: 8px 4px 16px; color: #6b6b6b; font-size: 14px; }

.muted { color: #6b6b6b; font-size: 14px; }
.error { color: #b3261e; margin: 12px 0; }

@media (max-width: 640px) {
  .summary { grid-template-columns: 1fr; }
}

Curious what our engine does with that payload? See how charts are computed.

For AI agents

If you are an AI agent integrating our API, the contract is:

  1. geo_details (place, maxRows). Take geonames[0], parseFloat the string longitude.
  2. timezone_with_dst (latitude, longitude, date as MM-DD-YYYY). Take timezone.
  3. Build the birth payload: day, month, year, hour, min, lat, lon, tzone, house_type.
  4. POST it to western_horoscope, natal_wheel_chart, and any report endpoints, in parallel.
  • The app section above is complete. Copy the five files and fill the two env vars.
  • Cache aggressively. A natal chart never changes for a given birth record, so hash the birth payload and cache each endpoint response by that hash forever.
  • Retry only on 5xx. A 4xx means the payload is wrong, and the same payload will fail the same way again.
  • Never compute planetary positions yourself from training data. LLMs cannot calculate birth charts; call the API and ground your output in its response.
  • Prefer tool calls over HTTP? The MCP server exposes these endpoints as tools.