Building a Transit Alert System
A practical engineering guide to selecting meaningful astrology transits, scheduling precise notifications, and avoiding alert fatigue.
- 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, notplanet_id. In our sample, Pluto's station came back with aplanet_idof-1, while the other planets carried real IDs. Theplanetname 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_timeis when the aspect enters orb,exact_timeis when it perfects, andend_timeis 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_timecan 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 afterexact_timehas 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_timeandend_timefor 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.