🏠 Vastu API and ✋ Palmistry API are now live. Ship them in your app today.
Blog/Engineering

Building a Transit Alert System

A practical engineering guide to selecting meaningful astrology transits, scheduling precise notifications, and avoiding alert fatigue.

October 16, 2026·10 min read·AstrologyAPI Team
In brief
  • Use global transits for shared broadcast moments and natal transits for personal alerts tied to a user’s birth chart.
  • Poll personal transits daily, schedule notifications from each relation’s exact time, and convert every timestamp from UTC to the user’s current timezone.
  • Score and filter relations before queueing them, then let a unique database key prevent the same transit alert from being sent twice.

Transit alerts are one of the stickiest features an astrology app can ship, because they give users a reason to open the app on a day they otherwise wouldn't. They're also one of the easiest to get wrong. Send too many and people mute you in the first week. Send them at the wrong hour and you're the app that woke someone at 3 AM about a Venus square. This post covers how to build the pipeline so it stays quiet by default and loud only when it counts.

Two sources, two kinds of alert

global_transits natal_transits_daily
Input start_date, end_date (dd-mm-yyyy) Birth details: day, month, year, hour, min, lat, lon, tzone
Scope The sky, same for everyone One person's natal chart
Returns Sign ingresses, retrograde/direct stations, moon phases Transit planet to natal planet aspects, with start, exact, and end times
Good for Broadcast alerts: "Mercury goes retrograde on 24 October" Personal alerts: "Jupiter is conjunct your Ascendant"
Calls needed One per refresh, regardless of user count One per user per poll

Build both. The global feed is nearly free and gives you a steady cadence of shareable moments. The personal feed is what makes the app feel like it knows the user.

All calls are POST requests to https://json.astrologyapi.com/v1/ with HTTP Basic auth, made from your server.

Global alerts: one call for your whole audience

global_transits takes a date range and returns a flat list of events. Three types matter:

{ "text": "mercury becomes retrograde", "type": "MOTION", "time": "24-10-2026 7:12", "planet": "mercury", "planet_id": 3, "direction": "RETRO" }
{ "text": "Full Moon", "type": "MOON_PHASE", "moon_phase_id": 1, "time": "26-10-2026 4:11" }
{ "text": "sun in libra transits to scorpio", "type": "SIGN_TRANSIT", "time": "23-10-2026 9:37", "planet": "sun", "planet_id": 0, "transit_from": "libra", "transit_to": "scorpio" }

A few things to know before you build on it:

  • The list is not sorted. Events come back in no particular order, so sort by time on ingest.
  • The Moon dominates. The Moon changes sign roughly every two and a half days, so a request for a 76-day window is mostly Moon ingresses. Drop them unless you're building a Moon-specific feature.
  • Key on planet, not planet_id. In our sample, Pluto's station came back with a planet_id of -1, while the other planets carried real IDs. The planet name is reliable.

Read this before you parse a timestamp

The global endpoint returns time as dd-mm-yyyy H:M with no zero-padding on hours or minutes. "28-10-2026 1:1" is 01:01, "12-12-2026 16:5" is 16:05, and "9-11-2026 7:2" is 07:02. If you split on : and treat the pieces as strings, 1:1 will look like 1:10. Parse both as integers. The values are UTC.


function parseGlobalTime(s) {
  const [date, time] = s.split(' ')
  const [dd, mm, yyyy] = date.split('-').map(Number)
  const [h, m] = time.split(':').map(Number)
  return new Date(Date.UTC(yyyy, mm - 1, dd, h, m))
}

function broadcastEvents(events) {
  return events
    .filter((e) => !(e.type === 'SIGN_TRANSIT' && e.planet === 'moon'))
    .map((e) => ({ ...e, at: parseGlobalTime(e.time) }))
    .sort((a, b) => a.at - b.at)
}

For 16 October to 31 December 2026, that filter turns a list dominated by Moon ingresses into 19 events: 7 stations, 5 moon phases, and 7 non-Moon ingresses. The first few, in UTC:

Event Type Time (UTC)
Pluto becomes direct Station 16 Oct, 02:40
Sun enters Scorpio Ingress 23 Oct, 09:37
Mercury becomes retrograde Station 24 Oct, 07:12
Full Moon Moon phase 26 Oct, 04:11
New Moon Moon phase 9 Nov, 07:02
Mercury becomes direct Station 13 Nov, 15:54

Run this as a weekly job that fetches the next 60 to 90 days, stores the filtered events, and schedules broadcast pushes from the stored rows. Since the window is fixed once computed, there's no reason to call the endpoint more often.

Personal alerts: what natal_transits_daily returns

This endpoint takes the same birth data as a natal chart and returns the transits aspecting that chart on the current date. There's no date parameter, so every call is a snapshot of "now".

{
  "transit_date": "10-16-2026",
  "ascendant": "Leo",
  "transit_relation": [
    {
      "transit_planet": "Jupiter",
      "natal_planet": "Ascendant",
      "aspect_type": "Conjunction",
      "start_time": "2026-10-16 03:02:13",
      "exact_time": "2026-10-30 07:47:44",
      "end_time": "2026-11-19 00:16:13",
      "is_retrograde": false,
      "transit_sign": "Leo",
      "natal_house": 12,
      "house_of_natal_planet": 1
    }
  ]
}

Three properties of this response shape the whole design:

  • Each relation is a window, not an event. start_time is when the aspect enters orb, exact_time is when it perfects, and end_time is when it leaves. For slow planets those windows are long. In the sample, Pluto's conjunction to natal Uranus ran from December 2025 to February 2027.
  • exact_time can be "-". Some aspects enter and leave orb without ever perfecting, typically when a planet turns retrograde first. There's no exact moment to notify about, so skip them.
  • Many relations have already perfected. A relation stays in the response until end_time, long after exact_time has passed.

Put those together and the sample looks like this: of 16 active relations, 6 had no exact time, 5 had already perfected by 08:00 UTC, and the remaining few had an exact time still ahead. That's the filtering problem in one number. If you alerted on everything in the response, a user would get a notification about Pluto every single day for fourteen months.

Read this before you pick a timezone

Timestamps from this endpoint come back as YYYY-MM-DD HH:MM:SS with no offset, and they're UTC. In our sample, the Moon's exact opposition to a natal Sun at 07:04 lines up with the Moon leaving Sagittarius at 12:57 in the global feed, about six hours later, which is the Moon's travel time across the last 5.6° of the sign. Convert to the user's local time when you schedule a send. And don't reuse the tzone from their birth data for that. It describes where they were born, not where their phone is now.

Poll daily, schedule precisely

Because each relation arrives with its own exact time, you don't need to poll continuously. Poll once a day, extract the exact times that fall inside your alert horizon, and let a separate dispatcher send them when they're due.

Job Cadence API calls Output
Global sync Weekly 1 Broadcast events stored with a send time
Personal poll Daily, per opted-in user 1 per user Upcoming exact-time alerts inserted into the alerts table
Dispatcher Every 5 to 15 minutes 0 Due, unsent alerts pushed to the provider

The dispatcher never touches the astrology API. It only reads your own table, which keeps push delivery fast and independent of anything upstream.

Turning relations into alerts

Filtering comes down to scoring each relation by how much it should matter. A slow planet hitting the Sun, Moon, or Ascendant is worth a notification. A fast planet hitting a minor point is not.


const TRANSIT_WEIGHT = { Moon: 1, Sun: 2, Mercury: 2, Venus: 3, Mars: 3, Jupiter: 5, Saturn: 5, Uranus: 5, Neptune: 4, Pluto: 5 }
const NATAL_WEIGHT = { Sun: 5, Moon: 5, Ascendant: 5, Mercury: 3, Venus: 3, Mars: 3, Jupiter: 3, Saturn: 3, Uranus: 2, Neptune: 2, Pluto: 2 }
const ASPECT_WEIGHT = { Conjunction: 3, Opposition: 3, Square: 2, Trine: 2, Sextile: 1 }

const parseUtc = (s) => (s && s !== '-' ? new Date(s.replace(' ', 'T') + 'Z') : null)

function scoreRelation(r) {
  return (
    (TRANSIT_WEIGHT[r.transit_planet] ?? 0) *
    (NATAL_WEIGHT[r.natal_planet] ?? 0) *
    (ASPECT_WEIGHT[r.aspect_type] ?? 0)
  )
}

function exactAlerts(relations, now, { minScore = 18, horizonDays = 7 } = {}) {
  const horizon = new Date(now.getTime() + horizonDays * 86400000)
  const alerts = []

  for (const r of relations) {
    const exact = parseUtc(r.exact_time)
    if (!exact || exact <= now || exact > horizon) continue

    const score = scoreRelation(r)
    if (score < minScore) continue

    alerts.push({
      dedupeKey: [r.transit_planet, r.natal_planet, r.aspect_type, exact.toISOString()].join('|'),
      exact,
      score,
      relation: r,
    })
  }

  return alerts.sort((a, b) => a.exact - b.exact)
}

The weights are a starting point. Tune them for your audience, and expose a "Moon and fast-planet alerts" toggle for users who want more.

Run against the 16 relations from our sample on 16 October, with a threshold of 18 and a seven-day horizon, this returns one alert: the Sun trine natal Sun, exact at 23:15 UTC on 17 October. Run it a week later against the same data and the Jupiter conjunction to the natal Ascendant, exact on 30 October, enters the horizon. Two alerts in two weeks is roughly the cadence you want from a system that's meant to feel meaningful.

Note what the dedupeKey is built from: the planet pair, the aspect, and the exact_time. Exact times matched across both transit endpoints in our sample, which makes them a stable identifier for a given pass. Retrograde loops can bring the same pair and aspect back two or three times, and each pass has a different exact time, so each gets its own key.


function alertText(r) {
  const retro = r.is_retrograde ? ' (retrograde)' : ''
  const verb = {
    Conjunction: 'conjunct',
    Opposition: 'opposite',
    Square: 'square',
    Trine: 'trine',
    Sextile: 'sextile',
  }[r.aspect_type]

  return `${r.transit_planet}${retro} is ${verb} your natal ${r.natal_planet}`
}

natal_house (where the transiting planet sits in the user's chart) and house_of_natal_planet are also in each relation, so a richer message like "Jupiter, in your 12th house, is conjunct your Ascendant" is one template change away.

Storage and dispatch

Insert alerts with a unique key and let the database ignore duplicates. Every daily poll will return the same upcoming transit, and this is what stops it being sent twice.

CREATE TABLE transit_alerts (
  user_id     TEXT        NOT NULL,
  dedupe_key  TEXT        NOT NULL,
  notify_at   TIMESTAMPTZ NOT NULL,
  payload     JSONB       NOT NULL,
  sent_at     TIMESTAMPTZ,
  PRIMARY KEY (user_id, dedupe_key)
);

-- Daily poll: safe to run repeatedly.
INSERT INTO transit_alerts (user_id, dedupe_key, notify_at, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, dedupe_key) DO NOTHING;

-- Dispatcher: claim a batch without two workers sending the same row.
SELECT user_id, dedupe_key, payload
FROM transit_alerts
WHERE sent_at IS NULL AND notify_at <= now()
ORDER BY notify_at
LIMIT 500
FOR UPDATE SKIP LOCKED;

For notify_at, a push at the moment of the exact time is rarely the right choice, since that moment might be 3 AM for the user. Send at a sensible local hour on the day of the exact time:

// Production code should use an IANA timezone library (Luxon, date-fns-tz)
// so DST is handled. A fixed offset keeps the example readable.
function localMorningUtc(exactUtc, offsetMinutes, hour = 8) {
  const local = new Date(exactUtc.getTime() + offsetMinutes * 60000)
  local.setUTCHours(hour, 0, 0, 0)

  let at = new Date(local.getTime() - offsetMinutes * 60000)
  if (at > exactUtc) at = new Date(at.getTime() - 86400000) // exact falls before 8 AM local: send the morning before
  return at
}

For the Sun trine example, the exact time is 23:15 UTC on 17 October. A user in New York would get the notification at 08:00 local on the 17th, and a user in Mumbai, where the exact time falls at 04:45 on the 18th, would get it at 08:00 local on the 17th. In both cases they hear about it before it happens.

Writing alerts people don't mute

Most alert systems fail on product decisions rather than engineering ones.

  • Cap the frequency. One personal alert per day is a sensible ceiling, with a lower weekly default. If several qualify, send the highest score and fold the rest into the in-app feed.
  • Respect quiet hours. Use the user's current timezone, not their birth timezone, and never send overnight.
  • Let users choose the tier. "Major transits only", "include fast planets", and "broadcast alerts only" cover most preferences with three options.
  • Keep the tone informational. "Saturn is square your natal Sun" gives people something to look into. Anything that reads like a warning will get your notifications turned off.
  • Link to the explanation. Every alert should open a screen that says what the transit is, how long it lasts (you have start_time and end_time for exactly this), and what the user can do with the information.

Cost and scale

The personal poll is the only part that scales with your user count, so it's worth managing:

  • Poll opted-in users only. If someone has alerts off, there's nothing to compute.
  • Stagger the schedule. Spread polls across the day with random jitter rather than firing every user at midnight UTC. It keeps you well inside your rate limits and avoids a daily traffic spike.
  • Tier by importance. Users who only want slow-planet alerts don't need a daily poll, since those windows stay valid for weeks. A poll every few days costs you only a delay on the "entering orb" moment, not on the exact-time alert.
  • Cache the natal chart separately. Birth data never changes, so there's no reason to recompute anything about the chart itself, only the transits against it.

Frequently asked questions

How often should I poll natal_transits_daily?

Once a day per opted-in user is enough. Each relation carries its own exact time, so you can schedule notifications days ahead rather than polling for them as they happen. Users who only want slow-planet alerts can be polled less often.

What's the difference between global_transits and natal_transits_daily?

global_transits returns sky-wide events for a date range, such as sign ingresses, stations, and moon phases, and it's identical for every user. natal_transits_daily returns the transits aspecting one specific birth chart on the current date, so it needs that person's birth data.

Why is exact_time sometimes "-"?

Some aspects enter and leave orb without perfecting, usually because the transiting planet changes direction first. There's no exact moment to anchor a notification to, so skip those relations when scheduling exact-time alerts.

What timezone are the timestamps in?

UTC, with no offset in the string. Convert to the user's current local time when scheduling or displaying. Use their device timezone, not the tzone from their birth data.

How do I avoid sending the same alert twice?

Build a deduplication key from the transit planet, natal planet, aspect type, and exact time, and insert it with a unique constraint so repeated polls can't create duplicates. Exact times were identical across the transit endpoints in our sample, which makes them a stable part of the key.

Further reading
Start building with real ephemeris data
150 free credits. No card required.
Related