Satellite passes, as an API
One HTTP call returns every pass of a satellite over a point on Earth: rise, peak and set, the sampled track at 10 s, range rate and Doppler shift, sunlight and visibility - and the age of the orbital elements behind it, so your users know what the times are worth.
First call
The ISS over Lyon, the next 48 hours, passes above 10°curl -H "X-API-Key: $NEXTPASS_API_KEY" \
'https://sat-pass-predictor-api.onrender.com/v1/passes?noradId=25544&lat=45.7578&lon=4.832&alt=170'{
"satellite": { "noradId": 25544, "name": "ISS (ZARYA)" },
"tle": {
"epoch": "2026-09-30T06:12:44.123Z",
"ageSeconds": 21304,
"source": "CelesTrak",
"fetchedAt": "2026-09-30T09:40:02Z",
"line1": "1 25544U 98067A ...",
"line2": "2 25544 51.63 ..."
},
"observer": { "latitudeDeg": 45.7578, "longitudeDeg": 4.832, "altitudeM": 170 },
"minElevationDeg": 10,
"frequencyMhz": null,
"computedAt": "2026-09-30T12:07:48Z",
"passes": [
{
"aos": { "instant": "2026-09-30T19:42:10.412Z", "azimuthDeg": 247.1, "elevationDeg": 10.0, ... },
"culmination": { "instant": "2026-09-30T19:45:21.870Z", "azimuthDeg": 172.4, "elevationDeg": 48.6, ... },
"los": { "instant": "2026-09-30T19:48:33.095Z", "azimuthDeg": 97.9, "elevationDeg": 10.0, ... },
"durationSeconds": 382,
"track": [ { "instant": "...", "azimuthDeg": 247.1, "elevationDeg": 10.0, "rangeKm": 1362.4,
"rangeRateKmS": -6.41, "dopplerHz": null, "subPoint": { ... },
"illuminated": true, "visible": true }, ... ]
}
]
} Every instant is ISO-8601 UTC: time zones are a display concern and the API refuses to have an opinion. aos, culmination and los are points of track itself, so a labelled instant always sits on the curve drawn from it. An empty passes list is a result, not an error.
tle.ageSeconds is computed by the server, once, from the epoch of the elements. Show it: SGP4's error grows with it, on the order of kilometres per day in low orbit.
Endpoints
https://sat-pass-predictor-api.onrender.com| Route | Returns | Access |
|---|---|---|
| GET /v1/passes | The passes of one satellite over one site. | X-API-Key, one request |
| GET /v1/passes/batch | Every satellite over every site: up to 10 satellites, 10 sites, 25 predictions. | X-API-Key, one request per prediction |
| GET /api/satellites?q= | Active satellites whose CelesTrak name or number matches, for a search box. | Public |
Parameters
/v1/passes| Name | Format | Default | Meaning |
|---|---|---|---|
| noradId | integer, 1–99999 | required | NORAD catalogue number of the satellite. |
| lat | degrees, −90 to 90 | required | Latitude of the observer (WGS84). |
| lon | degrees, −180 to 180 | required | Longitude of the observer. |
| alt | metres, −500 to 9000 | 0 | Altitude above the WGS84 ellipsoid, not above sea level. |
| hours | integer, 1–240 | 48 | Length of the window, starting at the request. |
| minElevation | degrees, 0–89 | 10 | Elevation a pass must exceed to count; AOS and LOS sit on it. |
| frequencyMhz | MHz, 1–300000 | none | Downlink carrier; adds dopplerHz to every point and phase. |
Doppler for radio amateurs
Range rate on every point; the shift when you name a frequencycurl -H "X-API-Key: $NEXTPASS_API_KEY" \
'https://sat-pass-predictor-api.onrender.com/v1/passes?noradId=25544&lat=45.7578&lon=4.832&frequencyMhz=145.8' Every point and phase carries rangeRateKmS, from Orekit's velocity rather than differenced ranges - negative while the satellite approaches. With frequencyMhz, each one also carries dopplerHz, the first-order shift to apply to the receiver, −f · rangeRate / c. For an uplink, apply the opposite sign. Asking for several frequencies costs one propagation: the shift is scaled from the cached prediction.
Batch predictions
A fleet of satellites over a network of ground stations, in one callcurl -H "X-API-Key: $NEXTPASS_API_KEY" \
'https://sat-pass-predictor-api.onrender.com/v1/passes/batch?noradId=25544,20580&site=45.7578,4.8320,170&site=-33.92,18.42&track=false'siteislat,lonorlat,lon,alt; repeat it for each site. Duplicates are dropped before counting.resultsis satellite-major. Each entry holds eitherprediction, exactly the/v1/passesbody, orerror, exactly its Problem Details. One failed satellite does not fail the batch.- A batch is admitted whole or refused whole, and counts one request per prediction. Ask for
track=falseunless you need the polylines: 25 cached predictions return in milliseconds and half a megabyte instead of 13. - There is no
frequencyMhzin a batch; compute−rangeRateKmS / 299792.458 × fyourself.
From your code
No SDK to install: one GET and one headerconst url = new URL('https://sat-pass-predictor-api.onrender.com/v1/passes');
url.search = new URLSearchParams({ noradId: '25544', lat: '45.7578', lon: '4.832' }).toString();
const response = await fetch(url, { headers: { 'X-API-Key': process.env.NEXTPASS_API_KEY } });
if (!response.ok) {
const problem = await response.json(); // RFC 9457: branch on problem.type
throw new Error(`${problem.title}: ${problem.detail}`);
}
const { passes } = await response.json();
for (const pass of passes) console.log(pass.aos.instant, pass.culmination.elevationDeg);import os, requests
response = requests.get(
"https://sat-pass-predictor-api.onrender.com/v1/passes",
params={"noradId": 25544, "lat": 45.7578, "lon": 4.832},
headers={"X-API-Key": os.environ["NEXTPASS_API_KEY"]},
timeout=60,
)
if not response.ok:
problem = response.json() # RFC 9457: branch on problem["type"]
raise RuntimeError(f"{problem['title']}: {problem.get('detail')}")
for p in response.json()["passes"]:
print(p["aos"]["instant"], p["culmination"]["elevationDeg"])Keep the key on a server. A key shipped in a web page is a key anyone can read and spend. Cross-origin browser calls are refused unless the operator lists your origin, and even then CORS is not authentication.
Errors
RFC 9457 Problem Details, one format everywhere Branch on type, never on the sentence: two different failures share status 503. Every type is https://github.com/warlaxx/sat-pass-predictor/errors/ followed by the slug below.
| Status | Type | What to do |
|---|---|---|
| 400 | invalid-request | A parameter is missing, malformed or out of bounds. Not retryable as is. |
| 400 | batch-exceeds-rate-limit | A batch larger than the key’s per-minute limit: it could never be admitted. |
| 401 | invalid-api-key | Missing, unknown or revoked X-API-Key. |
| 404 | unknown-satellite | The number is absent from CelesTrak: most likely a re-entered object. |
| 429 | rate-limit-exceeded · daily-quota-exceeded · monthly-quota-exceeded | Limit reached. Retry-After and resetsAt say when; limit says which. |
| 503 | tle-unavailable | No source of elements answered and none is held. Retry in about 15 s. |
| 503 | tle-stale | The only elements held are older than 7 days: refused rather than shown. |
| 503 | api-access-unavailable | Quota accounting is unavailable. The API fails closed; retry later. |
Limits worth knowing
All plans →- The free preview allows 100 requests per UTC day and 10 per minute, with one key.
- Minute limits are fixed UTC minute windows, not a sliding window. A cached answer counts like a computed one: you pay for the call, not the CPU.
- Every admitted request counts, including one that later fails validation. Invalid keys, quota refusals and CORS preflights do not.
- Responses carry
Cache-Control: no-store. Do not put them behind a shared cache. - The service currently runs on a free instance that sleeps: the first call after a quiet period can take up to half a minute. Use a 60 s timeout. Check the status page.