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

Guides

Kundli API integration, step by step

This guide walks our full kundli chain: a place name string in, a complete Vedic birth chart data set out. It is written for human developers and for AI coding agents, and it ends with a complete two-screen app you can copy and run.

Two format quirks break most first integrations. We return longitude as a string in geo_details, and our timezone endpoint takes dates as MM-DD-YYYY. Both are handled in the code below.

Pipeline at a glance

Nine steps, seven of them API calls that share one request body. If you already have coordinates and a timezone offset, skip to step 3.

StepEndpointWhat it returnsWhat feeds the next step
1geo_detailsCandidate places with latitude, longitude, timezone_idlatitude and longitude feed step 2 and become lat / lon in step 3
2timezone_with_dstDST-correct offset as a float (timezone)timezone becomes tzone in step 3
3none (build payload)The 8-field birth payloadRequest body for every step below
4astro_detailsAscendant, moon sign, nakshatra, tithi, and core kundli factsDisplay
5planets10 entries (9 bodies + ascendant): sign, house, nakshatra, retro flagDisplay
6horo_chart_image/D1D1 birth chart as an SVG stringDisplay
7horo_chart/D9Navamsha placements: 12 signs with planets in eachDisplay
8current_vdashaRunning Vimshottari periods, major down to sub_sub_sub_minorDisplay. Time-dependent, do not cache forever
9shadbala + bhavabalaPlanet strength (7 planets) and house strength (12 houses)Display

Base URL, auth, and where to run this

Every endpoint is a POST on https://json.astrologyapi.com/v1. Auth is HTTP Basic: your user ID is the username, your API key is the password. We do not send CORS headers, so calls must run on a server, never in browser code. Keep the credentials in server-only environment variables.

Responses are plain JSON. On failure we return a non-2xx status, and the helper below throws with the endpoint name, status, and body text, so a bad request tells you which step broke. Treat a 4xx as a bug in your request. Treat a 5xx as transient and retry.

Every Node example below uses this helper. The curl examples work as-is with ASTROLOGY_USER_ID and ASTROLOGY_API_KEY exported in your shell, which makes curl the fastest way to sanity-check a payload before writing code. Full parameter tables for each endpoint are in the Vedic API reference.

// Node 18+. Built-in fetch, no HTTP library needed.
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 call(endpoint, body) {
  const res = await fetch(`${BASE}/${endpoint}`, {
    method: 'POST',
    headers: { Authorization: auth, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  if (!res.ok) {
    throw new Error(`${endpoint} failed (${res.status}): ${await res.text()}`)
  }
  return res.json()
}

Step 1: place name to coordinates (geo_details)

geo_details turns a place name into candidate locations. It takes place (string) and maxRows (int, how many candidates to return). Show the candidates to the user when the name is ambiguous.

curl -X POST https://json.astrologyapi.com/v1/geo_details \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"place":"mumbai","maxRows":2}'
const geo = await call('geo_details', { place: 'mumbai', maxRows: 2 })
const top = geo.geonames[0]

// We return longitude as a STRING ("72.88261") and latitude as a
// number. parseFloat both so the payload is always numeric.
const lat = parseFloat(top.latitude)
const lon = parseFloat(top.longitude)

Each entry in geonames has place_name, latitude (number), longitude (string, note the quotes below), timezone_id, and country_code. The coordinates feed step 2 and the lat / lon fields of step 3.

Note that timezone_id is a zone name like Asia/Kolkata. Our chart endpoints do not take a zone name. They take a numeric offset, and the offset for a historical birth date depends on DST rules at that time. That is what step 2 resolves.

{
  "geonames": [
    {
      "place_name": "Mumbai",
      "latitude": 19.07283,
      "longitude": "72.88261",
      "timezone_id": "Asia/Kolkata",
      "country_code": "IN"
    },
    {
      "place_name": "Navi Mumbai",
      "latitude": 19.03681,
      "longitude": "73.01582",
      "timezone_id": "Asia/Kolkata",
      "country_code": "IN"
    }
  ]
}

Step 2: DST-correct timezone offset (timezone_with_dst)

timezone_with_dst takes latitude, longitude, and a date string in MM-DD-YYYY format. The date is optional, but always pass the birth date. The whole point is the offset that applied on that day, including daylight saving. Why this matters is covered in the birth time and timezones guide.

curl -X POST https://json.astrologyapi.com/v1/timezone_with_dst \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"latitude":19.07283,"longitude":72.88261,"date":"05-10-1990"}'
// Our date parameter is MM-DD-YYYY, not DD-MM-YYYY and not ISO.
// 05-10-1990 means May 10, 1990.
const tz = await call('timezone_with_dst', {
  latitude: lat,
  longitude: lon,
  date: '05-10-1990',
})
const tzone = tz.timezone // float, e.g. 5.5

The timezone field is the float you need. It becomes tzone in step 3.

{
  "status": true,
  "timezone": 5.5,
  "timezone_in_ms": 19800000,
  "date": "1992-12-01T00:00:00.000Z"
}

Step 3: build the birth payload

Every chart endpoint in steps 4 through 9 takes the same eight fields: day, month, year, hour, min as integers, and lat, lon, tzone as floats. Build the object once and reuse it.

hour uses the 24-hour clock, so a 7:55 pm birth is hour: 19, min: 55. tzone is the numeric offset from step 2, not a zone name. Offsets east of UTC are positive, for example 5.5 for India.

// Node: the object every chart endpoint below takes.
const payload = {
  day: 10,      // int
  month: 5,     // int
  year: 1990,   // int
  hour: 19,     // int, 24-hour
  min: 55,      // int
  lat: 19.07283,   // float, from geo_details
  lon: 72.88261,   // float, parseFloat of geo_details longitude
  tzone: 5.5,      // float, from timezone_with_dst
}

// Shell: reuse the same body across the curl examples below.
// BODY='{"day":10,"month":5,"year":1990,"hour":19,"min":55,"lat":19.07283,"lon":72.88261,"tzone":5.5}'

Step 4: core kundli data (astro_details)

astro_details returns the headline kundli facts: ascendant, moon sign, nakshatra, tithi, yog, karan, and the matching attributes (varna, vashya, yoni, gan, nadi). One quirk: we spell the nakshatra fields Naksahtra and NaksahtraLord. The misspelling is old and production integrations depend on it, so it stays. Match it exactly.

curl -X POST https://json.astrologyapi.com/v1/astro_details \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY"
const astro = await call('astro_details', payload)
// astro.ascendant -> "Leo", astro.sign -> "Virgo" (moon sign)
// We spell the nakshatra field "Naksahtra". Use it as printed.
{
  "ascendant": "Leo",
  "Varna": "Vaishya",
  "Vashya": "Maanav",
  "Yoni": "Gau",
  "Gan": "Manushya",
  "Nadi": "Adi",
  "SignLord": "Mercury",
  "sign": "Virgo",
  "Naksahtra": "Uttra Phalguni",
  "NaksahtraLord": "Sun",
  "Charan": 3,
  "Yog": "Vaidhriti",
  "Karan": "Kaulav",
  "Tithi": "Krishna Dwadashi",
  "tatva": "Earth",
  "name_alphabet": "Pa",
  "paya": "Silver"
}

Step 5: planet positions (planets)

planets returns an array of ten entries: the Sun through Ketu (id 0 to 8), then the ascendant as id 9. Each entry carries fullDegree, normDegree, speed, sign, signLord, nakshatra, nakshatra_pad, house, and isRetro.

If you draw your own chart UI, fullDegree is the absolute zodiac longitude (0 to 360) and normDegree is the position within the sign. speed goes negative while a body moves retrograde, which is why the Mercury sample below shows a negative speed next to isRetro: "true".

curl -X POST https://json.astrologyapi.com/v1/planets \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY"
const planets = await call('planets', payload)

// We return isRetro as the STRING "true" or "false" on the nine bodies.
// The tenth entry (Ascendant, id 9) uses a literal boolean false.
// Compare against the string, never a truthy check.
const retro = planets.filter((p) => p.isRetro === 'true').map((p) => p.name)
[
  {
    "id": 3,
    "name": "Mercury",
    "fullDegree": 85.49171301406825,
    "normDegree": 25.491713014068253,
    "speed": -0.29467903633858694,
    "isRetro": "true",
    "sign": "Gemini",
    "signLord": "Mercury",
    "nakshatra": "Punarvasu",
    "nakshatraLord": "Jupiter",
    "nakshatra_pad": 2,
    "house": 9,
    "is_planet_set": false,
    "planet_awastha": "Mrit"
  }
]

Step 6: D1 chart image (horo_chart_image/D1)

horo_chart_image/:chartId renders a chart and returns { svg: "SVG CODE" }. The chart id goes in the URL path, so the D1 birth chart is horo_chart_image/D1. Optional body parameters: chartType ('north', 'south', or 'east' style), image_type, and planetColor / signColor / lineColor hex overrides.

The response is JSON with the SVG markup as a string, not an image binary. Render it inline in HTML or write it to a .svg file. Chart ids are uppercase for the divisional charts (D1, D9) and lowercase for chalit. Use them exactly as written here.

curl -X POST https://json.astrologyapi.com/v1/horo_chart_image/D1 \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"day":10,"month":5,"year":1990,"hour":19,"min":55,"lat":19.07283,"lon":72.88261,"tzone":5.5,"chartType":"north"}'
const d1 = await call('horo_chart_image/D1', {
  ...payload,
  chartType: 'north', // optional style parameter
})
// d1.svg is an SVG string. Render it inline or save it to a file.

Step 7: D9 navamsha chart (horo_chart/D9)

horo_chart/:chart_id returns chart placements as data: twelve sign objects, each listing the planets placed in that sign. Both chart endpoints accept 21 chart ids: chalit, SUN, MOON, and the divisional charts D1, D2, D3, D4, D5, D7, D8, D9, D10, D12, D16, D20, D24, D27, D30, D40, D45, and D60. D9 is the navamsha. For an image instead of data, use horo_chart_image/D9 from step 6. How divisional charts are derived is covered in how charts are computed.

curl -X POST https://json.astrologyapi.com/v1/horo_chart/D9 \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY"
const d9 = await call('horo_chart/D9', payload)
// Array of 12 sign objects. Each lists the planets placed in that sign.

Sample response, trimmed to two of the twelve sign objects:

[
  {
    "sign": 3,
    "sign_name": "Gemini",
    "planet": ["SUN", "MARS", "MERCURY", "VENUS"],
    "planet_small": ["Su ", "Ma ", "Me ", "Ve "],
    "planet_degree": []
  },
  {
    "sign": 4,
    "sign_name": "Cancer",
    "planet": ["RAHU"],
    "planet_small": ["Ra "],
    "planet_degree": []
  }
]

Step 8: current Vimshottari dasha (current_vdasha)

current_vdasha returns the running Vimshottari periods: major, minor, sub_minor, sub_sub_minor, and sub_sub_sub_minor. Each level has planet, planet_id, start, and end. The output depends on when you call it, so treat it differently from the natal data when caching.

The start and end values are day-month-year strings with an hour:minute suffix, for example 2-5-2021 8:7. They are not ISO dates and not zero padded. Parse them yourself before displaying or sorting.

curl -X POST https://json.astrologyapi.com/v1/current_vdasha \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY"
const dasha = await call('current_vdasha', payload)
// dasha.major is the running mahadasha. minor, sub_minor,
// sub_sub_minor, and sub_sub_sub_minor nest below it.

Sample response, trimmed to the top two levels:

{
  "major": {
    "planet": "Sun",
    "planet_id": 0,
    "start": "2-5-2021  8:7",
    "end": "2-5-2027  20:7"
  },
  "minor": {
    "planet": "Saturn",
    "planet_id": 6,
    "start": "8-3-2024  7:13",
    "end": "18-2-2025  6:55"
  }
}

Step 9: planet and house strength (shadbala and bhavabala)

shadbala scores the six-fold strength of the seven classical planets (Sun through Saturn; Rahu and Ketu are not scored). Each entry has is_strong, total_shadbala_virupa, total_shadbala_rupa, required_minimum_virupa, strength_percent_of_minimum, and a components breakdown (sthana, dig, kala, cheshta, drik, naisargika).

bhavabala scores the twelve houses. The summary object gives you strongest_house_id, weakest_house_id, and ranked_house_ids_desc without touching the per-house detail in houses.

curl -X POST https://json.astrologyapi.com/v1/shadbala \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY"

curl -X POST https://json.astrologyapi.com/v1/bhavabala \
  -u "$ASTROLOGY_USER_ID:$ASTROLOGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY"
const [shad, bhav] = await Promise.all([
  call('shadbala', payload),
  call('bhavabala', payload),
])

const strongPlanets = shad.filter((p) => p.is_strong).map((p) => p.name)
const strongestHouse = bhav.summary.strongest_house_id // 1-12

Sample shadbala entry (one of seven):

[
  {
    "id": "sun",
    "name": "Sun",
    "is_strong": false,
    "required_minimum_virupa": 390,
    "strength_percent_of_minimum": 97.714127,
    "total_shadbala_rupa": 6.351418,
    "total_shadbala_virupa": 381.085096,
    "components": {
      "cheshta_bala": 22.130978,
      "dig_bala": 42.130978,
      "drik_bala": -10.668116,
      "naisargika_bala": 60,
      "kala_bala": { "total": 127.955567 },
      "sthana_bala": { "total": 139.535688 }
    }
  }
]

Sample bhavabala response, trimmed to one house:

{
  "summary": {
    "strongest_house_id": 5,
    "weakest_house_id": 10,
    "ranked_house_ids_desc": [5, 3, 12, 9, 11, 2, 1, 4, 6, 8, 7, 10]
  },
  "houses": [
    {
      "id": 1,
      "name": "Lagna",
      "bhava_sign": "Sagittarius",
      "bhavamadhya_longitude": 255,
      "strength_percent_of_baseline": 49.379332,
      "total_bhavabala_rupa": 1.975173,
      "total_bhavabala_virupa": 118.510396
    }
  ]
}

Build it: a two-screen kundli app

Everything above, wired into a small Next.js app you can copy and run. Screen 1 collects the birth details and resolves the place. Screen 2 renders the full kundli. The calls live in two API routes because we do not send CORS headers and the credentials must stay on the server.

Scaffold with npx create-next-app (pick the pages router and plain JavaScript), then paste the five files below. Keep the generated pages/_app.js; it already imports the stylesheet. Put your credentials in .env.local as ASTROLOGY_USER_ID and ASTROLOGY_API_KEY, then run npm run dev. No other dependencies.

pages/index.jsx: the birth form

Date, time, and a place search. The Search button calls the /api/place-search route, and the user picks one candidate before submitting. The picked place and the birth fields go into sessionStorage, then the app navigates to /kundli.

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

export default function BirthForm() {
  const router = useRouter()
  const [name, setName] = useState('')
  const [date, setDate] = useState('')
  const [time, setTime] = useState('')
  const [place, setPlace] = useState('')
  const [candidates, setCandidates] = useState(null)
  const [picked, setPicked] = useState(null)
  const [searching, setSearching] = useState(false)
  const [error, setError] = useState('')

  async function search() {
    setSearching(true)
    setError('')
    setPicked(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 another spelling.')
    } catch (err) {
      setError(err.message)
    }
    setSearching(false)
  }

  function submit(e) {
    e.preventDefault()
    if (!picked) {
      setError('Search for the birth place and pick one from the list.')
      return
    }
    const [year, month, day] = date.split('-').map(Number)
    const [hour, min] = time.split(':').map(Number)
    sessionStorage.setItem(
      'birth',
      JSON.stringify({
        name, day, month, year, hour, min,
        lat: picked.lat, lon: picked.lon, place: picked.name,
      }),
    )
    router.push('/kundli')
  }

  return (
    <main className="form-page">
      <h1>Generate a kundli</h1>
      <form onSubmit={submit}>
        <label htmlFor="name">Full name</label>
        <input id="name" value={name} onChange={(e) => setName(e.target.value)} required />

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

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

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

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

        <button type="submit" className="primary">Generate kundli</button>
      </form>
    </main>
  )
}

pages/api/place-search.js: place lookup

A thin proxy for geo_details (step 1). It converts the string longitude here, so the browser only ever sees numeric coordinates.

// pages/api/place-search.js
const BASE = 'https://json.astrologyapi.com/v1'

export default async function handler(req, res) {
  const place = String(req.query.place || '').trim()
  if (!place) return res.status(400).json({ error: 'place is required' })

  const auth =
    'Basic ' +
    Buffer.from(
      `${process.env.ASTROLOGY_USER_ID}:${process.env.ASTROLOGY_API_KEY}`,
    ).toString('base64')

  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 (${upstream.status})` })
  }
  const data = await upstream.json()

  // We return longitude as a string in geo_details. parseFloat both
  // coordinates here so everything downstream is numeric.
  const candidates = (data.geonames || []).map((g) => ({
    name: g.place_name,
    country: g.country_code,
    lat: parseFloat(g.latitude),
    lon: parseFloat(g.longitude),
  }))
  res.status(200).json({ candidates })
}

pages/api/kundli.js: the whole chain in one route

Steps 2 through 9 in one place: timezone_with_dst first, then the seven data endpoints in parallel, returned as one JSON bundle. It retries a 5xx once and passes any other failure back with the endpoint name.

// pages/api/kundli.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 call(endpoint, body, attempt = 0) {
  const res = await fetch(`${BASE}/${endpoint}`, {
    method: 'POST',
    headers: { Authorization: auth, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  // A 5xx is transient on any API. Retry once, then give up.
  if (res.status >= 500 && attempt === 0) return call(endpoint, body, 1)
  if (!res.ok) {
    throw new Error(`${endpoint} failed (${res.status}): ${await res.text()}`)
  }
  return res.json()
}

export default async function handler(req, res) {
  if (req.method !== 'POST') return res.status(405).json({ error: 'POST only' })
  const { day, month, year, hour, min, lat, lon } = req.body || {}
  const fields = [day, month, year, hour, min, lat, lon]
  if (!fields.every((v) => typeof v === 'number' && Number.isFinite(v))) {
    return res.status(400).json({
      error: 'day, month, year, hour, min, lat, lon must all be numbers',
    })
  }

  try {
    // Our timezone endpoint takes the date as MM-DD-YYYY.
    const date = [
      String(month).padStart(2, '0'),
      String(day).padStart(2, '0'),
      year,
    ].join('-')
    const tz = await call('timezone_with_dst', {
      latitude: lat,
      longitude: lon,
      date,
    })

    // The eight-field payload every chart endpoint takes.
    const payload = { day, month, year, hour, min, lat, lon, tzone: tz.timezone }

    // All seven take the same payload, so fan out in parallel.
    const [astro, planets, d1, d9, dasha, shadbala, bhavabala] =
      await Promise.all([
        call('astro_details', payload),
        call('planets', payload),
        call('horo_chart_image/D1', { ...payload, chartType: 'north' }),
        call('horo_chart/D9', payload),
        call('current_vdasha', payload),
        call('shadbala', payload),
        call('bhavabala', payload),
      ])

    res.status(200).json({
      tzone: tz.timezone,
      astro, planets, d1, d9, dasha, shadbala, bhavabala,
    })
  } catch (err) {
    res.status(502).json({ error: err.message })
  }
}

pages/kundli.jsx: the result screen

One fetch, then the full render: core details, the D1 chart SVG, the planets table with the string isRetro compare, D9 placements, the dasha stack, and the two strength sections.

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

const pad = (n) => String(n).padStart(2, '0')
const DASHA_LEVELS = ['major', 'minor', 'sub_minor', 'sub_sub_minor', 'sub_sub_sub_minor']

export default function KundliResult() {
  const [birth, setBirth] = useState(null)
  const [data, setData] = useState(null)
  const [error, setError] = useState('')

  useEffect(() => {
    const stored = sessionStorage.getItem('birth')
    if (!stored) {
      setError('No birth details found. Start from the form.')
      return
    }
    const b = JSON.parse(stored)
    setBirth(b)
    fetch('/api/kundli', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        day: b.day, month: b.month, year: b.year,
        hour: b.hour, min: b.min, lat: b.lat, lon: b.lon,
      }),
    })
      .then(async (res) => {
        const json = await res.json()
        if (!res.ok) throw new Error(json.error || 'Request failed')
        setData(json)
      })
      .catch((err) => setError(err.message))
  }, [])

  if (error) {
    return (
      <main className="result-page">
        <p className="error">{error}</p>
        <Link href="/">Back to the form</Link>
      </main>
    )
  }
  if (!data || !birth) {
    return (
      <main className="result-page">
        <p className="loading">Computing the chart. This takes a few seconds.</p>
      </main>
    )
  }

  const { astro, planets, d1, d9, dasha, shadbala, bhavabala } = data
  const occupied = d9.filter((s) => s.planet.length > 0)

  return (
    <main className="result-page">
      <header className="card">
        <h1>{birth.name}</h1>
        <p className="muted">
          {pad(birth.day)}-{pad(birth.month)}-{birth.year} at {pad(birth.hour)}:{pad(birth.min)}
          {' · '}{birth.place}{' · '}UTC{data.tzone >= 0 ? '+' : ''}{data.tzone}
        </p>
      </header>

      <div className="grid">
        <section className="card">
          <h2>Core details</h2>
          <dl className="facts">
            <dt>Ascendant</dt><dd>{astro.ascendant}</dd>
            <dt>Moon sign</dt><dd>{astro.sign}</dd>
            <dt>Nakshatra</dt><dd>{astro.Naksahtra} (pada {astro.Charan})</dd>
            <dt>Tithi</dt><dd>{astro.Tithi}</dd>
          </dl>
        </section>

        <section className="card">
          <h2>Birth chart (D1)</h2>
          <div className="chart" dangerouslySetInnerHTML={{ __html: d1.svg }} />
        </section>
      </div>

      <section className="card">
        <h2>Planets</h2>
        <table>
          <thead>
            <tr><th>Planet</th><th>Sign</th><th>House</th><th>Retro</th></tr>
          </thead>
          <tbody>
            {planets.map((p) => (
              <tr key={p.id}>
                <td>{p.name}</td>
                <td>{p.sign}</td>
                <td>{p.house}</td>
                <td>{p.isRetro === 'true' ? 'Yes' : ''}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </section>

      <div className="grid">
        <section className="card">
          <h2>Navamsha (D9)</h2>
          <dl className="facts">
            {occupied.map((s) => (
              <div key={s.sign} className="fact-row">
                <dt>{s.sign_name}</dt>
                <dd>{s.planet.join(', ')}</dd>
              </div>
            ))}
          </dl>
        </section>

        <section className="card">
          <h2>Current dasha</h2>
          <ol className="dasha">
            {DASHA_LEVELS.map((level) =>
              dasha[level] ? (
                <li key={level}>
                  <strong>{dasha[level].planet}</strong>
                  <span className="muted"> {dasha[level].start} to {dasha[level].end}</span>
                </li>
              ) : null,
            )}
          </ol>
        </section>
      </div>

      <div className="grid">
        <section className="card">
          <h2>Planet strength (shadbala)</h2>
          {shadbala.map((p) => (
            <div key={p.id} className="bar-row">
              <span className="bar-label">{p.name}</span>
              <div className="bar">
                <div
                  className="bar-fill"
                  style={{ width: Math.min(100, p.strength_percent_of_minimum) + '%' }}
                />
              </div>
              <span className="bar-value">{Math.round(p.strength_percent_of_minimum)}%</span>
            </div>
          ))}
        </section>

        <section className="card">
          <h2>House strength (bhavabala)</h2>
          <p>
            Strongest house: <strong>{bhavabala.summary.strongest_house_id}</strong>.
            Weakest house: <strong>{bhavabala.summary.weakest_house_id}</strong>.
          </p>
        </section>
      </div>
    </main>
  )
}

styles/globals.css: the design

The whole stylesheet. Plain system font, warm off-white background, bordered white cards, one accent color. The result grids collapse to one column on narrow screens.

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

body {
  margin: 0;
  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  background: #FAFAF7;
  color: #1A1A1A;
  line-height: 1.5;
}

h1 { font-size: 24px; margin: 0 0 16px; }
h2 { font-size: 16px; margin: 0 0 12px; }
a { color: #635BFF; }

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

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

input {
  width: 100%;
  padding: 10px 12px;
  font-size: 16px;
  border: 1px solid #E5E5E0;
  border-radius: 10px;
  background: #FFFFFF;
  color: #1A1A1A;
}

button {
  padding: 10px 16px;
  font-size: 15px;
  border: none;
  border-radius: 8px;
  background: #635BFF;
  color: #FFFFFF;
  cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
button.primary { width: 100%; margin-top: 24px; }

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

.candidates { list-style: none; margin: 8px 0 0; padding: 0; }
.candidates li + li { margin-top: 4px; }
.candidate {
  width: 100%;
  text-align: left;
  background: #FFFFFF;
  color: #1A1A1A;
  border: 1px solid #E5E5E0;
  border-radius: 10px;
}
.candidate.picked { border-color: #635BFF; color: #635BFF; }

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

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

.facts {
  margin: 0;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 16px;
}
.facts dt { color: #6B6B6B; }
.facts dd { margin: 0; }
.fact-row { display: contents; }

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

.chart { max-width: 100%; }
.chart svg { max-width: 100%; height: auto; }

.dasha { margin: 0; padding-left: 20px; }
.dasha li { margin-bottom: 6px; }

.bar-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.bar-label { width: 64px; font-size: 14px; }
.bar { flex: 1; height: 8px; background: #E5E5E0; border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; background: #635BFF; }
.bar-value { width: 44px; font-size: 13px; color: #6B6B6B; text-align: right; }

.muted { color: #6B6B6B; }
.error { color: #B4231F; }
.loading { color: #6B6B6B; }

For AI agents

The chain, compressed. All calls are POST to https://json.astrologyapi.com/v1 with HTTP Basic auth, JSON bodies, server-side only. The app section above is complete. Copy the five files and fill the two env vars.

  1. geo_details with { place, maxRows }. Read geonames[0].latitude and geonames[0].longitude. Longitude is a string; parseFloat it.
  2. timezone_with_dst with { latitude, longitude, date }, date as MM-DD-YYYY. Read timezone.
  3. Build { day, month, year, hour, min, lat, lon, tzone }. Integers first five, floats last three.
  4. POST that payload to astro_details, planets, horo_chart_image/D1, horo_chart/D9, current_vdasha, shadbala, bhavabala in parallel.
  5. Parse gotchas: isRetro === 'true' string compare in planets; Naksahtra spelling in astro_details; chart image SVG is in the svg field.

Caching: natal outputs (steps 4 to 7 and 9) never change for a given birth record. Cache them forever, keyed by a hash of the eight payload fields. current_vdasha output changes as time passes, so give it a short TTL or recompute per request. Retry only on 5xx responses; a 4xx means the request itself is wrong and will fail again unchanged.

If your agent framework speaks MCP, the AstrologyAPI MCP server exposes these endpoints as tools and is the alternative to hand-written HTTP calls.

Where to go next

If users may not know their exact birth time, read the unknown birth time guide before designing the intake form. Some outputs above degrade without a reliable time and some do not.

The full endpoint catalog, with every parameter and sample response, is in the Vedic API reference.