Public API
Three GET endpoints, no key, no sign-up, no rate-limit wall. They return the same numbers the site renders: radiant altitude, moon interference, hourly cloud cover and sky brightness, folded into one verdict. Built for integration authors — Home Assistant, club dashboards, camping sites, Discord bots.
Data that is not connected yet
All three endpoints are live. Two inputs are not, and the responses say so rather than filling the gap with a plausible number:
- Sky brightness.
location.bortleandlocation.sqmarenullandlocation.skyBrightnessKnownisfalseeverywhere. The light-pollution ingest sits behind an account wall, so no location on this site has a measured value. The rate model still needs a sky, so it applies a stated assumption internally — that assumption is deliberately not published as if it were your sky. - Cloud cover is only available where a forecast
provider covers the point and answers in time. When it does not,
cloudPctand every hourlycloudarenull.nullis not zero. -
peakon/api/v1/shower/{code}resolves for the 2026 reference year only; other years returnnullinstead of an extrapolation.
All 12 showers, their radiants, drift, velocity and activity windows are real data and are live now.
Conventions
- Base URL
https://meteorshowercal.com/api/v1- Versioning
-
Fields are only ever added inside
v1. A breaking change ships as/api/v2;v1keeps working. - Format
-
application/json; charset=utf-8, pretty-printed. Angles are degrees, fractions are 0–1, percentages are 0–100, instants are ISO-8601. Local times carry the observer's UTC offset (2026-08-13T03:00:00+08:00) — read the wall clock straight off the string; do not re-zone it. nullmeans unknown-
cloudPct: nullmeans no forecast was available for that point. It does not mean clear sky.location.bortle: nullmeans the sky brightness there was never measured — it does not mean a pristine sky. Render both as unknown; the site does. Nothing in this API is ever a placeholder number. - CORS
-
Access-Control-Allow-Origin: *on every response. Call it straight from a browser. - Location resolution
-
A coordinate inside about 25 km of one of the 300 city rows adopts that
city's name, timezone and elevation. Otherwise the label is the formatted
coordinate, elevation is 0, and the timezone comes from Cloudflare's own
geolocation — but only when the caller is standing within 50 km of the
point they asked about — then from a city within 150 km, then from a fixed
solar offset (
Etc/GMT±N).location.tzalways tells you which of those you got. A fixed solar offset has no DST and can sit up to an hour off civil time; if your integration needs civil time for a remote point, resolve the zone yourself and read the UTC fields. - Coordinate grid
- Coordinates are snapped to a 0.1° grid (about 11 km) before computation, and the response echoes the snapped point. None of the four verdict factors changes measurably inside one cell, and it keeps upstream weather requests polite.
- Caching
-
Verdicts:
Cache-Control: public, max-age=900(15 minutes). Shower elements:max-age=86400(24 hours). Errors areno-store. - Methods
-
GETandOPTIONS. Anything else is405.
Rate limits and etiquette
There is no enforced quota today, and keeping it that way is up to the people using it. The upstream weather providers (MET Norway, NOAA/NWS) have real limits and this site is bound by them, so:
-
Respect
Cache-Control. A verdict does not change more often than every 15 minutes — polling faster returns identical bytes. - One request per second, sustained, is plenty for any integration. Batch jobs should spread requests out rather than fan out at the top of the hour.
-
Send an identifying
User-Agentwith a contact URL or address if you are calling from a server. Anonymous high-volume traffic is what forces quotas to exist. - Building a website widget? Use embed.js instead — it is one request per page view and it caches at the edge.
Licence and attribution
Everything these endpoints return is licensed CC BY 4.0. You may use it commercially, modify it and redistribute it. In exchange, attribution is required and it must include a link back:
<a href="https://meteorshowercal.com/">Meteor data by MeteorShowerCal</a>
(<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>)
If you change the numbers (rounding, unit conversion, your own visibility
model on top), say so — CC BY requires indicating modifications. Underlying
sources have their own credits, listed in every response's
meta.sources and in full on
the methodology page.
GET /api/v1/showers
Every shower in the calendar, with the orbital elements used by the rest of
the API. No parameters, no location. Codes:
QUA LYR ETA SDA PER DRA ORI STA NTA LEO GEM URS.
| Status | Meaning |
|---|---|
200 | Shower list. |
405 | Method other than GET/OPTIONS. |
drift.raPerDeg / drift.decPerDeg — and the
driftRa/driftDec behind them — are degrees per
degree of solar longitude, not per day, and the radiant is already
reduced to solLongPeak. Apply
ra = ra0 + driftRa × (λ − λ_peak) directly; do not divide by
0.9856 again.
Real response, truncated to the first two showers:
{
"meta": {
"version": "v1",
"generatedAt": "2026-08-12T18:00:00.000Z",
"sources": [
"IAU Meteor Data Center — established shower list V.2 (radiant, drift, Vg, solar longitude)",
"Stellarium MeteorShowers.json — ZHR, population index, activity window (one-off seed import)",
"GeoNames cities15000 (CC BY 4.0) — city names, coordinates, elevation, timezone",
"astronomy-engine (MIT) — ephemeris"
],
"license": "CC-BY-4.0",
"licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
"attribution": "Data from meteorshowercal.com (CC BY 4.0). Please link back."
},
"showers": [
{
"code": "QUA",
"name": "Quadrantids",
"slug": "quadrantids",
"zhr": 110,
"popIndex": 2.1,
"url": "https://meteorshowercal.com/showers/quadrantids/",
"solLongPeak": 283.15,
"active": {
"start": "12-28",
"end": "01-12"
}
},
{
"code": "LYR",
"name": "April Lyrids",
"slug": "april-lyrids",
"zhr": 18,
"popIndex": 2.1,
"url": "https://meteorshowercal.com/showers/april-lyrids/",
"solLongPeak": 32.32,
"active": {
"start": "04-14",
"end": "04-30"
}
}
],
"license": {
"license": "CC-BY-4.0",
"licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
"attribution": "Data from meteorshowercal.com (CC BY 4.0). Please link back."
}
} GET /api/v1/tonight
The whole product in one object: tonight's verdict, the best observing window in the observer's local time, the hourly table and every other shower active tonight.
| Parameter | Type | Required | Notes |
|---|---|---|---|
lat | number | conditional | Decimal degrees, −90 to 90, north positive. |
lon | number | conditional | Decimal degrees, −180 to 180, east positive. |
Both or neither. With neither, the request is answered for the location
Cloudflare derives from the caller's IP; if there is no such hint the
response is 400. There is no default city — a confidently wrong
location is worse than an error.
| Status | Meaning |
|---|---|
200 | Verdict for the resolved location. |
400 | bad_request (only one of lat/lon, or no coordinates and no
IP hint) or invalid_coordinates (not a number, or out of
range).
|
503 | internal_error — the verdict could not be computed. The
message says why. Never a fabricated result.
|
curl "https://meteorshowercal.com/api/v1/tonight?lat=22.28&lon=114.17" Response shape — the numbers below are zeros and ellipses on purpose, so nothing here can be mistaken for a forecast:
{
"meta": {
"version": "v1",
"generatedAt": "2026-08-12T18:00:00.000Z",
"sources": [ "IAU Meteor Data Center — …", "…" ],
"license": "CC-BY-4.0",
"licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
"attribution": "Data from meteorshowercal.com (CC BY 4.0). Please link back."
},
"location": {
"lat": 22.3, "lon": 114.2, "label": "Hong Kong", "tz": "Asia/Hong_Kong",
"country": "HK",
"bortle": null, "sqm": null, "skyBrightnessKnown": false
},
"nightOf": "2026-08-12",
"verdict": { "tier": "go", "headline": "…", "detail": "…" },
"shower": { "code": "PER", "name": "Perseids", "slug": "perseids",
"zhr": 100, "popIndex": 2.2,
"url": "https://meteorshowercal.com/showers/perseids/" },
"bestWindow": { "start": "2026-08-13T03:00:00+08:00",
"end": "2026-08-13T05:00:00+08:00" },
"peakHr": 0,
"radiantAltMax": 0,
"moonIllum": 0,
"cloudPct": null,
"weather": { "source": "met.no", "available": true, "attribution": "…" },
"nextGoodNight": null,
"hourly": [ { "local": "2026-08-12T21:00:00+08:00",
"utc": "2026-08-12T13:00:00.000Z",
"radiantAlt": 0, "moonAlt": 0, "moonIllum": 0,
"cloud": null, "limitingMag": 0, "hr": 0 } ],
"alsoActive": [],
"license": { "license": "CC-BY-4.0", "licenseUrl": "…", "attribution": "…" }
} verdict.tier is one of go, maybe,
no, invisible. invisible means the
radiant never rises at that latitude tonight. Both no and
invisible carry a nextGoodNight — use it, it is
the most useful thing you can show someone whose sky is not cooperating.
GET /api/v1/shower/{code}
One shower in depth. code is the IAU three-letter code,
case-insensitive.
| Parameter | Type | Required | Notes |
|---|---|---|---|
lat, lon | number | no |
Together they add the local block. No IP fallback here —
local appears only when you asked for it explicitly.
|
date | string | no | YYYY-MM-DD, the local night to evaluate. Defaults to
tonight. Cloud cover only exists roughly 48 hours ahead; beyond that
cloud is null.
|
| Status | Meaning |
|---|---|
200 | Shower elements, peak, and local when coordinates were given. |
400 | bad_request / invalid_coordinates. |
404 | unknown_shower — the message lists the valid codes. |
503 | internal_error — local computation unavailable. |
curl "https://meteorshowercal.com/api/v1/shower/PER?lat=40.71&lon=-74.01&date=2026-08-12" peak currently resolves for the 2026 reference year only. Other
years need the solar-longitude inversion that ships with the calculation
module; until then the field is null instead of an
extrapolation.
Errors
Every error is JSON with the same shape and a machine-readable code:
{
"meta": { "version": "v1", "generatedAt": "2026-08-12T18:00:00.000Z" },
"error": {
"code": "invalid_coordinates",
"message": "`lat` must be between -90 and 90 and `lon` between -180 and 180."
},
"license": { … }
}
Codes: bad_request, invalid_coordinates,
unknown_shower, rate_limited,
internal_error.
Embed widget
For websites, the widget is better than calling the API yourself: one script tag, no build step, renders in a shadow root so it cannot collide with your CSS, and it removes itself silently if anything fails.
<script src="https://meteorshowercal.com/embed.js"
data-lat="22.28" data-lon="114.17"></script>
Leave data-lat/data-lon off and it answers for the
visitor's own approximate location. The card links back to
meteorshowercal.com — that link is the CC BY attribution, so please keep it.
See it running.
Known limits
- Meteor rates are the expectation for one observer with an unobstructed view. Real counts usually come in 30–50% lower.
- Outbursts and storms are not modelled. Neither is local terrain or buildings.
- Cloud cover comes from numerical forecasts and degrades quickly beyond 24 hours.
- Sky brightness is reported as the Bortle class a sky of that brightness corresponds to. It is a modelled zenith value, not a measurement at your doorstep, and the Bortle scale itself is a whole-sky visual judgement.
The full method, including the ZHR equation and every source, is on the methodology page.