On July 13, 2026 I asked two free ISS-tracking APIs where the space station was. Fourteen seconds apart. Both answered HTTP 200 with clean, valid JSON.
They were 9,303 km apart.
# runnable, read-only: no key needed for either
curl -s "http://api.open-notify.org/iss-now.json"
curl -s "https://api.wheretheiss.at/v1/satellites/25544"
{"iss_position": {"latitude": "-25.2996", "longitude": "154.1942"},
"message": "success", "timestamp": 1783969008}
{"name":"iss","id":25544,"latitude":37.5181,"longitude":95.3130,
"altitude":429.7,"velocity":27566.63,"visibility":"daylight",
"timestamp":1783969022}
The station moves at 27,566 km/h. That is 7.66 km/s, and the second API tells you so in its own body. Its ground track is slower than that, not faster, and two separate effects stack up to make it so. The station flies 430 km up, so the point underneath it sweeps the same angle across a smaller radius: that alone drops it to about 7.2 km/s, before anything else happens. Then the orbit is prograde, and the planet turns the same way underneath it, which shaves off a bit more. I got this backwards in my first draft, so I stopped reasoning and measured instead. Two wheretheiss samples 64 seconds apart put the ground track 439.9 km further along: 6.87 km/s. JPL's own ephemeris, two rows 19 seconds apart, says 6.86.
So round up hard. Give it a flat 8 km/s, more than any of those numbers, and fourteen seconds buys you at most 112 km of great-circle distance. Not 9,303.
So one of these is lying, at HTTP 200, with "message": "success" written right there in the payload.
The one idea in this post
My previous keyless-API posts kept walking around the same room. A 200 can carry an empty body. A 201 Created can be followed by a 404 on read-back. A 200 can parse, match your schema, and hand you null. A 200 can return a full, correctly shaped list whose top row is the wrong entity. This post moves the problem again, and it is the version that scares me most, because it is the one you can run for a year without noticing.
A 200 with a valid, well-shaped, non-empty, plausible-looking body is not a 200 with current data. Freshness is not in the status line. It is a field inside the body: EPOCH, time_tag, timestamp, lastRun, updated. A server that stopped updating in 2024 will keep telling you, cheerfully and successfully, that 2024 is right now.
Space is where this shows up naked, because space data is time-series by nature. A stale satellite position looks exactly like a satellite position. Nothing in resp.ok will ever tell you the difference.
A free space API here means a public endpoint returning space or astronomy data with no API key, no signup, no credit card. A URL you can paste into a terminal right now. Eleven clear that bar. I re-verified every response below with a live curl on July 13, 2026: real HTTP code, real body, trimmed but never reworded.
One scope note, so the numbers stay honest. I curl-verified all eleven APIs on July 13, 2026. I have not run a satellite-tracking service in production. My 2,190 production scraper runs (962 of them on one Trustpilot scraper) are a different domain. I cite them for exactly one reason: they are why I read a body's own timestamp instead of its status line. Do not read 2,190 as a claim about these eleven endpoints.
| # | API | What it returns | Example call | The gotcha |
|---|---|---|---|---|
| 1 | Where the ISS at | Live ISS lat/lon/alt/velocity | GET api.wheretheiss.at/v1/satellites/25544 |
timestamp is when you asked, not when it was measured |
| 2 | CelesTrak GP | Orbital elements (OMM/TLE) | GET celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=json |
A miss is a 404 with plain text, not JSON |
| 3 | TLE API | Two-line elements by NORAD ID | GET tle.ivanstanojevic.me/api/tle/25544 |
Same satellite, twice the epoch age of CelesTrak |
| 4 | JPL SSD/CNEOS | Asteroids, close approaches, fireballs | GET ssd-api.jpl.nasa.gov/sbdb.api?sstr=433 |
Errors ride inside a 200 body |
| 5 | JPL Horizons | Ephemerides for any solar-system body | GET ssd.jpl.nasa.gov/api/horizons.api?format=json&... |
format=json wraps a plain-text blob |
| 6 | NASA Exoplanet Archive | 6,319 planets in 39,978 rows, queryable by ADQL | GET exoplanetarchive.ipac.caltech.edu/TAP/sync?query=... |
A bad query returns XML, whatever format you asked for |
| 7 | NASA Images | 1,510 Apollo 11 images, and the rest | GET images-api.nasa.gov/search?q=apollo 11 |
Asset URLs need a second fetch, and they are http://
|
| 8 | NOAA SWPC | Planetary K-index (geomagnetic storms) | GET services.swpc.noaa.gov/products/noaa-planetary-k-index.json |
time_tag opens a 3-hour bin, so "now" is never now |
| 9 | Launch Library 2 | 364 upcoming launches, crew, agencies | GET ll.thespacedevs.com/2.2.0/launch/upcoming/ |
Anonymous calls are rate-limited, hard |
| 10 | SatNOGS DB | Open satellite + transmitter catalog | GET db.satnogs.org/api/satellites/?format=json |
Records carry an updated field for a reason |
| 11 | SIMBAD TAP | Astronomical object database (CDS) | POST simbad.cds.unistra.fr/simbad/sim-tap/sync |
Andromeda is not typed "Galaxy" |
Then a flagged near-miss, three APIs that used to be keyless and are not, and the gate that catches the frozen ones. Along the way I get an arbiter badly wrong and have to throw it out, which turned out to be the most useful hour of the whole thing.
1. Where the ISS at: the one that was right, and could not have told you
api.wheretheiss.at gives you the station's position, altitude, velocity, and footprint, keyless, no signup.
# runnable, read-only
curl -s "https://api.wheretheiss.at/v1/satellites/25544"
{"name":"iss","id":25544,"latitude":37.5181,"longitude":95.3130,
"altitude":429.73995965565,"velocity":27566.631304384,
"visibility":"daylight","footprint":4556.6877316229,
"timestamp":1783969022,"units":"kilometers"}
This one was correct. I checked, and I will show you how, and I will also show you the check I got wrong on the way there. But look at what its timestamp actually is: 1783969022 is the moment I sent the request. Not the moment anything was measured. It is a propagator: it takes a set of orbital elements and computes where the station is now, then stamps the answer with now.
That is the trap in miniature. The field named timestamp looks like a freshness signal and is not one. If the elements underneath went stale, this API would keep stamping fresh times on drifting positions, and the field called timestamp would never once flinch.
But here is the part I missed on my first pass, and it is the most useful thing in this section: wheretheiss will show you the elements it is propagating. You have to ask a different endpoint.
# runnable, read-only
curl -s "https://api.wheretheiss.at/v1/satellites/25544/tles"
{"requested_timestamp":1783971050,"tle_timestamp":1783883409,"id":"25544",
"name":"iss","header":"ISS (ZARYA)",
"line1":"1 25544U 98067A 26193.79871965 .00004831 00000+0 95802-4 0 9996",
"line2":"2 25544 51.6301 173.3406 0006695 288.1916 71.8344 15.48994114575722"}
tle_timestamp is the epoch of the element set underneath the answer. Subtract it from requested_timestamp and you get the number that actually matters: when I pulled this, the elements were 24.3 hours old. That is the field to gate on. Not the one named timestamp, which is just a clock. Keep those two lines of TLE in mind. They come back to embarrass me in a few sections.
Use it for: a live ISS map or an "is it overhead" check. Gate on /tles, not on timestamp.
Docs: wheretheiss.at/w/developer. I have left that unlinked on purpose, and the reason is too good to bury. The docs host's TLS certificate expired at 14:02:56 UTC on July 12, 2026, one day before I pulled all of this, so curl refuses it with exit 60 and your browser will throw a full-page warning. The certificate on the API host next door is fine until July 19. The endpoint works. The page telling you how to use the endpoint does not. Check it yourself before you click, because by the time you read this it has probably been renewed, which is the other half of the lesson: everything in this post has an expiry date, including this post.
2. CelesTrak: the orbital elements everything else is built from
CelesTrak is Dr. T.S. Kelso's satellite element catalog, and it is where most people get their elements. It is not where the elements come from. That distinction matters more than it sounds: the tracking data is produced by the US Space Force's 18th Space Defense Squadron and published through Space-Track, and CelesTrak curates it, formats it, and hands it to you without a login. When you build on CelesTrak you are depending on two organizations, not one. Keyless. It serves classic TLE, and OMM in JSON, which is nicer to parse.
# runnable, read-only
curl -s "https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=json"
[{"OBJECT_NAME":"ISS (ZARYA)","OBJECT_ID":"1998-067A",
"EPOCH":"2026-07-13T07:33:22.422240","MEAN_MOTION":15.48997295,
"ECCENTRICITY":0.0006687,"INCLINATION":51.6305,
"NORAD_CAT_ID":25544,"REV_AT_EPOCH":57580,"BSTAR":8.1266e-5}]
Note EPOCH. It is not the timestamp of an observation. It is the reference time the element set is valid for: the anchor of a fit made against days of accumulated tracking, not the moment somebody pointed a radar at the thing. When I pulled this it was 11.4 hours in the past. Normal, fine, and honest: the API is telling you, unprompted, how old its own truth is. Remember that field. It is the one that saves you later.
The gotcha is at the other end. Ask for a satellite that does not exist and you do not get JSON:
# runnable, read-only
curl -s -w " [HTTP %{http_code}]\n" \
"https://celestrak.org/NORAD/elements/gp.php?CATNR=99999&FORMAT=json"
No GP data found [HTTP 404]
Plain text, HTTP 404, despite FORMAT=json in the query. So resp.json() raises a decode error, not a clean empty list, and your error handling has to catch both. Also: CelesTrak asks you not to hammer it, so cache the elements and propagate locally instead of re-fetching every second.
Use it for: anything that needs real orbital elements. Docs: celestrak.org/NORAD/documentation/gp-data-formats.php.
3. TLE API: the same satellite, twice as stale
tle.ivanstanojevic.me is a friendly JSON wrapper over TLE data, keyless, no rate-limit drama.
# runnable, read-only
curl -s "https://tle.ivanstanojevic.me/api/tle/25544"
{"@type":"Tle","satelliteId":25544,"name":"ISS (ZARYA)",
"date":"2026-07-12T19:10:09+00:00",
"line1":"1 25544U 98067A 26193.79871965 .00004831 00000+0 95802-4 0 9996",
"line2":"2 25544 51.6301 173.3406 0006695 288.1916 71.8344 15.48994114575722"}
Now put it next to CelesTrak. Same satellite, same minute, both keyless, both HTTP 200, both returning a perfectly valid TLE:
| Feed | Epoch field | Age when I pulled it |
|---|---|---|
| CelesTrak | EPOCH: 2026-07-13T07:33:22 |
11.4 hours |
| TLE API | date: 2026-07-12T19:10:09 |
23.7 hours |
Twice the age. Neither response is wrong. Neither status code differs. And now the number, because "twice as stale" is the kind of phrase that sounds alarming until you measure it. I propagated both element sets to the same instant. They land 1.0 km apart, about 0.13 seconds of along-track lag.
One kilometre. That is the entire cost of twelve extra hours of TLE age for this satellite, on this day. Do not carry that number anywhere else, and I want to be specific about why, because it is the kind of result that gets quoted out of its cage. Both feeds trace back to the same producer. What I measured is how stable one organization's successive fits are, not how accurate they are, and I measured it on a fat, well-tracked, high-drag-but-boring object during a quiet geomagnetic week. A cubesat with a bad area-to-mass ratio, a Starlink in the middle of a station-keeping burn, a Kp-7 storm puffing up the thermosphere, or a prediction horizon of a full day instead of twelve hours will each give you a much bigger number. Read the date field so you know which set you got. Just do not tell yourself that half a day of TLE age is what breaks a tracker.
There is a punchline here I did not expect. Remember api.wheretheiss.at from section 1, the one that was right? Pull its /tles endpoint and compare: it is propagating this exact element set, line for line, the 23.7-hour one. The API I am about to hold up as correct is running on the feed I just called "twice as stale." Both things are true, and that is the point: for this satellite, at this timescale, TLE age costs about a kilometre. The thing that costs 9,300 km is something else entirely.
(Two footnotes. The ISS reboosts: a TLE fitted before a maneuver is wrong the moment the thrusters fire, at any age. And that same element set shows up as 23.7 hours here and 24.3 hours in section 1, because I fetched it twice, half an hour apart. Age is a stopwatch reading, not a property of the data.)
Use it for: quick TLE lookups when you do not want to parse CelesTrak's formats. Read the date. Docs: tle.ivanstanojevic.me.
4. JPL SSD/CNEOS: four keyless endpoints, and errors hidden inside 200s
NASA JPL's Solar System Dynamics group runs a set of APIs at ssd-api.jpl.nasa.gov that are genuinely, fully keyless. This is one provider with four endpoints worth knowing, so I am counting it once.
sbdb.api looks up any small body:
# runnable, read-only
curl -s "https://ssd-api.jpl.nasa.gov/sbdb.api?sstr=433"
{"object":{"fullname":"433 Eros (A898 PA)","neo":true,"pha":false,
"orbit_class":{"name":"Amor"}},
"orbit":{"orbit_id":"659","first_obs":"1893-10-29","last_obs":"2021-05-13",
"data_arc":"46582","n_obs_used":9130,"moid":"0.149"}}
first_obs: 1893-10-29. My first draft said Eros therefore has a 133-year observation arc, because 2026 minus 1893 is 133 and I can do arithmetic.
Look three fields to the right. data_arc: 46582. Days. That is 127 years, not 133, and the reason is sitting right next to it: last_obs: 2021-05-13. The arc ends where the observations end, not where my calendar does. JPL puts the answer in the body as an integer so that nobody has to subtract dates, and I subtracted dates anyway, in a post whose entire argument is read the field. Nine thousand one hundred and thirty observations went into that orbit and I could not be bothered to read one key.
Read the field. That is the whole post, hiding inside an asteroid.
cad.api lists close approaches. Everything passing within 0.05 au in the next month:
# runnable, read-only
curl -s "https://ssd-api.jpl.nasa.gov/cad.api?dist-max=0.05&date-min=2026-07-14&date-max=2026-08-14"
{"count":19,"fields":["des","orbit_id","jd","cd","dist",...],
"data":[["2026 MQ3","8","2461237.959674506","2026-Jul-16 11:02",
"0.0319436706577196",...,"21.95"], ...]}
Nineteen. fireball.api gives you atmospheric impact events detected by US government sensors, with energy and coordinates. scout.api covers newly-discovered objects not yet catalogued, and it carries lastRun and tEphem fields, which is a service admitting in the body how recently it thought about the problem.
The gotcha is the sibling endpoint, sentry.api:
# runnable, read-only
curl -s -w " [HTTP %{http_code}]\n" "https://ssd-api.jpl.nasa.gov/sentry.api?des=99942"
{"signature":{"version":"2.0","source":"NASA/JPL Sentry Data API"},
"removed":"2021-02-21 08:22:28","error":"specified object removed"} [HTTP 200]
HTTP 200. The body says error. It also says exactly when: removed: 2021-02-21. Apophis came off the impact-risk table in February 2021; NASA's public all-clear for the next hundred years came separately, in March, after the Goldstone radar pass refined the orbit. Two events, a few weeks apart, and I am flagging that because it would be very easy to fuse them into one tidy sentence and be wrong. Either way it is good news for Earth and bad news for raise_for_status(). Branch on the error key, not the status code.
Use it for: asteroid data, close approaches, planetary defense projects. Docs: ssd-api.jpl.nasa.gov.
5. JPL Horizons: professional ephemerides, wrapped in a lie about JSON
Horizons is the system planetary scientists actually use. It will compute the position of any body in the solar system, from any observer, at any time, to absurd precision. Keyless, over HTTP.
# runnable, read-only: Mars, seen from Earth's center
curl -s "https://ssd.jpl.nasa.gov/api/horizons.api?format=json&COMMAND='499'&OBJ_DATA='NO'&MAKE_EPHEM='YES'&EPHEM_TYPE='OBSERVER'&CENTER='500@399'&START_TIME='2026-07-15'&STOP_TIME='2026-07-16'&STEP_SIZE='1%20d'&QUANTITIES='1'"
{"result":"\n*****\nEphemeris / API_USER Mon Jul 13 11:51:55 2026 Pasadena, USA\n*****\nTarget body name: Mars (499) {source: mar099}\nCenter body name: Earth (399) {source: DE441}\n..."}
You asked for JSON. You got JSON: one key, result, holding the same fixed-width text table Horizons has emitted since the 1990s, newlines and asterisks and all. The JSON wrapper is transport, not structure. You still have to parse the text between the $$SOE and $$EOE markers by hand.
I say this with affection. It is a thirty-year-old scientific instrument with a REST veneer, and it is one of the most powerful free things on the internet. Just do not plan on body["result"]["mars"]["ra"] existing.
One more thing, and it matters later. Point Horizons at the ISS instead of Mars, with COMMAND='-125544', and its header volunteers where its own trajectory comes from:
Revised: Jul 13, 2026 International Space Station (ISS) / (Earth) -125544
Trajectory is TLE-based. Predicts run for 4 weeks into future, but are of
low accuracy for times more than a few days past the revision date above.
Trajectory is TLE-based. Hold on to that sentence. It is going to demolish something of mine about six sections from now.
Use it for: real ephemerides, planetary positions, spacecraft trajectories.
The docs are at ssd-api.jpl.nasa.gov/doc/horizons.html, and they are worth an hour of your life. Almost nobody reads them, which is why almost everybody reaches for a worse API.
6. NASA Exoplanet Archive: 6,319 planets, 39,978 rows, and a real query language
The Exoplanet Archive exposes a TAP service, which means you send it ADQL (a dialect of SQL for astronomy) and it returns rows. No key, no signup, and it is the actual planet catalog, not a summary of one.
# runnable, read-only
curl -s "https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+count(*)+from+ps&format=json"
[{"count(*)": 39978}]
Thirty-nine thousand, nine hundred seventy-eight rows, as of July 13, 2026. And that is the first trap, this whole post compressed into one query: that is not 39,978 planets. The ps table holds one row per published parameter set, so a well-studied planet appears in it many times over. Ask the question you actually meant:
# runnable, read-only
curl -s "https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+count(*)+from+pscomppars&format=json"
[{"count(*)": 6319}]
6,319 planets. Same archive, same keyless endpoint, same clean HTTP 200, and a number 6.3 times smaller. ps is the literature; pscomppars is one row per planet. I ran the wrong one first and wrote the wrong number into my own draft, which is exactly the failure mode this post is about: nothing in the status line tells you which of those two questions you asked.
You can slice it:
# runnable, read-only
curl -s "https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+top+2+pl_name,disc_year,pl_orbper+from+ps+where+disc_year>2024&format=json"
[{"pl_name": "KMT-2023-BLG-1896L b", "disc_year": 2025, "pl_orbper": null},
{"pl_name": "OGLE-2015-BLG-1609L b", "disc_year": 2025, "pl_orbper": null}]
Two gotchas, and the second is the fun one. First, pl_orbper is null on both rows: these are microlensing detections, and microlensing does not give you an orbital period. Nulls are everywhere in this table and they are honest, not broken.
Second, break the query on purpose:
# runnable, read-only
curl -s -w " [HTTP %{http_code}]\n" \
"https://exoplanetarchive.ipac.caltech.edu/TAP/sync?query=select+nope+from+ps&format=json"
<?xml version="1.0" encoding="UTF-8"?>
<VOTABLE version="1.4" xmlns="http://www.ivoa.net/xml/VOTable/v1.3">
<RESOURCE type="results">
<INFO name="QUERY_STATUS" value="ERROR">
ORA-00904: 'NOPE': invalid identifier
</INFO>
</RESOURCE>
</VOTABLE> [HTTP 400]
I asked for format=json. On error I got XML, plus an Oracle error code leaking through from the database underneath. The success path and the failure path do not speak the same language. Write your parser accordingly.
Use it for: exoplanet dashboards, statistics, anything where you want to run real queries instead of paging through someone's REST wrapper. Docs: exoplanetarchive.ipac.caltech.edu/docs/TAP/usingTAP.html.
7. NASA Image and Video Library: the NASA API everyone forgets is keyless
Everyone knows api.nasa.gov and everyone forgets it wants a key (more on that below). images-api.nasa.gov is a separate service and it wants nothing at all.
While I was checking that, I counted, because my draft claimed this was "one of the two NASA services that are truly keyless" and I had not actually counted anything. There are at least five. On July 13, 2026 all of these returned HTTP 200 with no key: images-api.nasa.gov, JPL's ssd-api.jpl.nasa.gov, Horizons at ssd.jpl.nasa.gov, the NASA Exoplanet Archive hosted at IPAC, and EONET, the natural-event tracker at eonet.gsfc.nasa.gov. The "NASA needs a key" folklore is wrong in five different places at once.
# runnable, read-only
curl -s "https://images-api.nasa.gov/search?q=apollo%2011&media_type=image"
{"collection":{"metadata":{"total_hits":1510},
"items":[{"href":"https://images-assets.nasa.gov/image/jsc2007e034221/collection.json",
"data":[{"nasa_id":"jsc2007e034221","title":"Apollo 11 spacecraft pre-launch",
"date_created":"1969-07-11T00:00:00Z"}]}]}}
1,510 Apollo 11 images. The gotcha is that the search does not give you image URLs. It gives you a href pointing at a second JSON document, and you have to fetch that:
# runnable, read-only
curl -s "https://images-assets.nasa.gov/image/jsc2007e034221/collection.json"
["http://images-assets.nasa.gov/image/jsc2007e034221/jsc2007e034221~orig.jpg",
"http://images-assets.nasa.gov/image/jsc2007e034221/jsc2007e034221~large.jpg", ...]
Two hops, always. And note the scheme: those asset URLs come back as http://, not https://, so a strict Content-Security-Policy on your page will block them unless you rewrite the scheme yourself. Small thing. Ruins an afternoon.
Use it for: free, high-resolution, public-domain space imagery. Docs: images.nasa.gov/docs/images.nasa.gov_api_docs.pdf.
8. NOAA SWPC: the "current" geomagnetic index that cannot be current
The Space Weather Prediction Center publishes solar and geomagnetic data as flat JSON files. No key, no auth, no rate limit worth mentioning. The planetary K-index tells you whether a geomagnetic storm is underway, which is what aurora apps and HF radio operators live on.
# runnable, read-only
curl -s "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"
[{"time_tag":"2026-07-06T00:00:00","Kp":2.67,"a_running":12,"station_count":7},
...,
{"time_tag":"2026-07-13T15:00:00","Kp":2.33,"a_running":9,"station_count":8}]
Sixty-two records at a three-hour cadence, which is just under eight days of history. I pulled this at 19:00 UTC. The last time_tag says 15:00 UTC, and the lazy read is "the current value is four hours old."
The lazy read is mine, and it is wrong in a way that matters. time_tag is not the moment of a measurement. It is the start of a three-hour bin. I checked every one of the 61 gaps in that array and they are all exactly 3.0 hours. So the newest label is at minimum three hours behind the wall clock by construction, before you add any processing lag at all, and the four hours I measured is the age of the label, not of the physics.
Two consequences, and I shipped the first one broken:
-
A
max_ageof three hours on this feed can never pass. Not "rarely." Never. My first draft gated it at exactly3 * 3600and called the resulting rejection "the gate working." It was a threshold that could only ever say no, which is not a gate, it is a wall. - Look at
station_count: 8. This is the estimated Kp, computed from eight ground magnetometers in near-real-time. The definitive index is published later by GFZ Potsdam. If you need the real number for science rather than for a pretty aurora badge, this is not it.
None of that is a bug. It is the correct behavior of a correct product. But if you wrote current_kp = data[-1]["Kp"] and put it on a dashboard labeled "now," you have already told your first lie, and the API never contradicted you. It gave you a 200 and a time_tag and trusted you to read it.
Use it for: aurora forecasts, geomagnetic storm alerts, radio propagation. Read the time_tag, and know what it is the timestamp of. Docs: swpc.noaa.gov/products-and-data.
9. Launch Library 2: 364 upcoming launches, and a rate limit that will find you
The Space Devs run Launch Library 2, a launch schedule that a lot of space apps are quietly built on. Anonymous access is keyless.
# runnable, read-only
curl -s "https://ll.thespacedevs.com/2.2.0/launch/upcoming/?limit=1&mode=list"
{"count":364,"results":[{"name":"Falcon 9 Block 5 | Starlink Group 15-14",
"status":{"name":"Go for Launch","abbrev":"Go"},
"net":"2026-07-14T01:17:14Z","last_updated":"2026-07-13T13:07:42Z"}]}
364 launches on the books. Note the two time fields: net is the projected launch time (No Earlier Than, and it moves constantly), and last_updated is when a human touched this record. Both matter, for different reasons.
The honest limitation: anonymous calls are rate-limited hard, and the API does not send you X-RateLimit-* headers to warn you. I checked; it exposes none. You find out by getting a 429. A free account raises the ceiling, and there is a lldev.thespacedevs.com mirror with cached data for development. If you are building something real, sign up. If you are hacking a weekend project, the anonymous tier is fine, but do not poll it in a loop.
Use it for: launch schedules, agencies, rockets, crewed missions. Docs: thespacedevs.com/llapi.
10. SatNOGS DB: the open satellite catalog with an honest updated field
SatNOGS is a global network of volunteer-run ground stations, and its database is an open catalog of satellites, transmitters, and telemetry decoders. Keyless for reads.
# runnable, read-only
curl -s "https://db.satnogs.org/api/satellites/?format=json&norad_cat_id=25544"
[{"sat_id":"XSKZ-5603-1870-9019-3066","norad_cat_id":25544,"name":"ISS",
"names":"ZARYA, RS0ISS, NA1SS","status":"alive","decayed":null,
"launched":"1998-11-20T00:00:00Z","countries":"RU,US",
"updated":"2025-08-01T05:17:04.759872Z","telemetries":[{"decoder":"iss"}]}]
The killer feature here is the transmitter database: frequencies, modes, and decoders for thousands of satellites, which is not information you can easily get anywhere else for free.
And look at updated. This record was last touched on 2025-08-01, roughly a year before I pulled it. That is completely correct, because the ISS has not changed its name or its status in that time. The point is that SatNOGS tells you. It puts the age of its own truth in the payload and lets you decide whether a year is too long for your use case. Every API in this post that I trust does exactly this.
Use it for: satellite metadata, radio frequencies, amateur ground-station work. Docs: db.satnogs.org/api/.
11. SIMBAD: the object database where Andromeda is not a "Galaxy"
SIMBAD, from the Centre de Donnees astronomiques de Strasbourg, is the reference database for astronomical objects outside the solar system. Its TAP endpoint takes ADQL, keyless.
# runnable, read-only: TAP wants request+lang, and it is picky
curl -s "https://simbad.cds.unistra.fr/simbad/sim-tap/sync" \
--data "request=doQuery&lang=ADQL&format=json" \
--data-urlencode "query=SELECT main_id, ra, dec, otype FROM basic WHERE main_id = 'M 31'"
{"metadata":[{"name":"main_id",...},{"name":"ra","unit":"deg",...}],
"data":[["M 31", 10.684708333333333, 41.268750000000004, "AGN"]]}
First gotcha: drop request=doQuery&lang=ADQL and you get an HTTP 400, no matter how good your query is. TAP is an IVOA standard, not a REST API someone designed for you.
Second gotcha, and it is the whole thesis of this post wearing a different hat. Look at otype for M 31. Andromeda, the galaxy, comes back typed AGN. Not "Galaxy". Let me check two more famous ones:
[["M 31", "AGN"], ["M 51", "Sy2"], ["M 87", "AGN"]]
Andromeda: AGN. The Whirlpool: Sy2 (a Seyfert 2). M87: AGN. If you wrote WHERE otype = 'Galaxy' you would get a clean HTTP 200 with a perfectly valid, non-empty result set and you would ship a galaxy browser that silently omits Andromeda, the Whirlpool, and M87. That is not a thought experiment. SIMBAD accepts the long label happily, and I counted what it hands back:
# runnable, read-only: the query that looks right and is not
curl -s "https://simbad.cds.unistra.fr/simbad/sim-tap/sync" \
--data "request=doQuery&lang=ADQL&format=json" \
--data-urlencode "query=SELECT COUNT(*) AS n FROM basic WHERE otype = 'Galaxy'"
{"metadata":[{"name":"n","datatype":"LONG"}],"data":[[4177456]]}
Four million rows, HTTP 200, no warning, no error, and three of the most famous galaxies in the sky are not in there.
To be precise, because SIMBAD is not being sloppy and I am not going to imply it is: Andromeda is a galaxy. otype holds the single most specific type, and SIMBAD's own hierarchy says so out loud. Query the otypedef table and the path for AGN is literally G > AGN; Sy2 is G > AGN > SyG > Sy2. The database was right. My query was wrong.
And the fix is two characters. TAP exposes that hierarchy with a .. suffix, which means "this type and everything under it":
# runnable, read-only: '..' walks the type tree
curl -s "https://simbad.cds.unistra.fr/simbad/sim-tap/sync" \
--data "request=doQuery&lang=ADQL&format=json" \
--data-urlencode "query=SELECT main_id, otype FROM basic WHERE otype = 'G..' AND main_id IN ('M 31','M 51','M 87')"
{"data":[["M 31","AGN"],["M 51","Sy2"],["M 87","AGN"]]}
Two dots. That is the whole difference between a galaxy browser that works and one that ships a 200 with a hole in it. One warning before you paste it elsewhere: .. is a SIMBAD extension to its own otype column, not portable ADQL. Try it against the Exoplanet Archive's TAP from section 6 and Oracle will tell you exactly what it thinks of you.
Which is the lesson again, wearing its third hat today: otype = 'Galaxy' returned a valid, non-empty, four-million-row answer, and nothing about that 200 was ever going to hint that I had asked the wrong question.
Use it for: star and deep-sky object coordinates, identifiers, classifications.
Before you write a single query, go read what otype actually means: simbad.cds.unistra.fr/simbad/sim-tap. I did not, and it cost me a galaxy browser with three famous holes in it.
Flagged: api.nasa.gov is not keyless, DEMO_KEY is not "no key"
APOD, NeoWs, EPIC, and DONKI all live behind api.nasa.gov, and every tutorial calls them free. They are free. They are not keyless.
# runnable, read-only
curl -s -w " [HTTP %{http_code}]\n" "https://api.nasa.gov/planetary/apod"
{"error":{"code":"API_KEY_MISSING",
"message":"No api_key was supplied. Get one at https://api.nasa.gov:443"}} [HTTP 403]
A hard 403. Add ?api_key=DEMO_KEY and you get a 200, which is why the myth persists.
Now, how strict is DEMO_KEY? The documentation says 30 requests per IP per hour and 50 per day, and my first draft printed those two numbers, sourced from the docs, in a post whose single rule is the docs are not the data. So I went and hit it four times in a row and watched the headers instead:
# runnable, read-only: watch the header, not the docs
for i in 1 2 3 4; do
curl -s -D - -o /dev/null "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY" \
| grep -i "^HTTP\|^x-ratelimit"
done
HTTP/2 200
x-ratelimit-limit: 10
x-ratelimit-remaining: 1
HTTP/2 200
x-ratelimit-limit: 10
x-ratelimit-remaining: 0
HTTP/2 429
x-ratelimit-limit: 10
x-ratelimit-remaining: 0
HTTP/2 429
x-ratelimit-limit: 10
x-ratelimit-remaining: 0
x-ratelimit-limit: 10, and a 429 on the third call. I am not going to tell you the docs are wrong, because I do not know what window that 10 belongs to or what else my IP had been doing that hour. I am telling you what the server said when I asked it, on July 13, 2026, and that the server ships the number in a header so you never have to guess. It is a key, and a small one, and building on it is how a demo rots into a support ticket. I am not padding my count with it.
Why not on the list: three APIs the tutorials still recommend
SpaceX API v4. Still the first result a lot of people land on. Dead:
curl -s -o /dev/null -w "%{http_code}\n" "https://api.spacexdata.com/v4/launches/latest"
525
525 is a Cloudflare origin SSL handshake failure: the edge is up, and the origin is not completing TLS with it. Note what that does and does not tell you. The backend might be perfectly alive behind an expired certificate, a closed 443, or a cipher mismatch. From out here you cannot tell, and it does not matter, because you cannot get launches out of it either way. Use Launch Library 2 instead.
Solar System OpenData (api.le-systeme-solaire.net), which was keyless for years, now returns 401 Unauthorized: API key is missing. Ask your API key... Add use it on a bearer token. Gated in 2026.
USNO Astronomical Applications (aa.usno.navy.mil/api/) did not complete a connection at all for me: curl exit, HTTP 000. Sunrise, sunset, moon phase, all unreachable from where I sat.
The gate: read the timestamp out of the body
Here is the wrapper. It does one thing: it pulls the time field out of the response body, compares it to now, and refuses an answer that is too old to be called data.
# runnable local: pip install requests
import requests
from datetime import datetime, timezone
class StaleData(Exception):
pass
def age_seconds(stamp):
"""Accept an ISO-8601 string or a unix timestamp. Return age in seconds."""
if isinstance(stamp, (int, float)):
ts = datetime.fromtimestamp(stamp, tz=timezone.utc)
else:
ts = datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
if ts.tzinfo is None: # SWPC and CelesTrak omit the zone
ts = ts.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - ts).total_seconds()
def fetch_fresh(url, pick_time, max_age):
"""GET url, read the timestamp OUT OF THE BODY, refuse the answer if it is old."""
r = requests.get(url, timeout=20)
r.raise_for_status() # a frozen server sails straight through this
body = r.json()
age = age_seconds(pick_time(body)) # this is the check that actually matters
if age > max_age:
raise StaleData(f"200 but {age/3600:.1f}h old (max_age {max_age/3600:.1f}h)")
return body, age
CASES = [
# gate wheretheiss on the ELEMENTS it propagates, not on its own clock
("wheretheiss", "https://api.wheretheiss.at/v1/satellites/25544/tles",
lambda b: b["tle_timestamp"], 6 * 3600),
("open-notify", "http://api.open-notify.org/iss-now.json",
lambda b: b["timestamp"], 60),
("celestrak", "https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=json",
lambda b: b[0]["EPOCH"], 6 * 3600),
("swpc-kp", "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json",
lambda b: b[-1]["time_tag"], 6 * 3600), # time_tag opens a 3h bin: 3h is unreachable
]
for name, url, pick, max_age in CASES:
try:
_, age = fetch_fresh(url, pick, max_age)
print(f"FRESH {name:12} age {age:8.0f}s")
except StaleData as e:
print(f"STALE {name:12} {e}")
Real output, July 13, 2026:
STALE wheretheiss 200 but 25.4h old (max_age 6.0h)
FRESH open-notify age 1s
STALE celestrak 200 but 13.0h old (max_age 6.0h)
FRESH swpc-kp age 19947s
Now read line one and line two together, because that pair is the most useful thing I found all day.
The gate flagged wheretheiss. The API that was right. It flagged it because I aimed the gate at /tles, where it read the age of the elements underneath the answer: 25.4 hours, well past my six-hour ceiling.
The gate passed open-notify. One second old. Perfect score. The API that is on the wrong side of the planet.
My timestamp gate got both of them exactly backwards.
It is still worth having, and I still ship it, because it catches honest staleness and honest staleness is most of what goes wrong: CelesTrak's 13-hour elements, SWPC's Kp sitting 5.5 hours behind the clock and squeaking under a ceiling I set at six on purpose. My max_age values are opinions, not physics. Six hours for orbital elements because I decided a kilometre of drift is my budget; six for Kp because three is arithmetically impossible against a three-hour bin. Pick your own and write down why.
But a gate that reads one response can only ever ask the server about itself. And this server lies about itself, fluently, with a fresh timestamp.
Where the gate fails, and the arbiter I got wrong
And then there is line two.
FRESH open-notify age 1s.
Open Notify is the API from the top of this post, the one that is about 9,300 km wrong. It passed the freshness gate with a perfect score, because its timestamp field is the server's clock at the moment you ask. It stamps now on every response.
It gets worse, and this is the part that changed how I think about liveness checks. The position changes between calls.
ts 1783974529 lat -22.9871 lon 128.4535
ts 1783974578 lat -25.3113 lon 130.6121
Forty-nine seconds, 339 km, which is 6.91 km/s: the same ground-track speed I measured at the top of this post. Smooth motion along something that has the exact shape of an orbit. I want to be careful here, because it would be easy and satisfying to tell you what that server is doing internally, and I cannot see inside it. What is observable is enough: it reports a position that no currently published element set produces, it moves, and its crew roster still lists the people who were aboard in mid-2024.
Run down the checklist a careful engineer would actually write:
- HTTP 200. Yes.
- Valid JSON, parses cleanly. Yes.
- Matches the schema, no nulls. Yes.
- Body says
"message": "success". Yes. - Timestamp field is current. Yes.
- Value changes between polls, so it is not a frozen cache. Yes.
Every single one passes. And the answer is on the wrong side of the planet.
The arbiter that proved nothing
So I built an arbiter. Pull the current elements, propagate them with SGP4, convert to lat/lon, ask both APIs the same question at the same instant, and see who lands where. It ran. It printed a beautiful result, which I wrote straight into this draft: wheretheiss off by 3 km, open-notify off by 9,300. Two independent methods agreeing to within 3 km. Case closed, ship it.
It is not two independent methods. It is one method, run twice.
Here is the shortest possible demonstration. Take the element set that wheretheiss serves from its own /tles endpoint, the one printed back in section 1, and propagate it with SGP4 to wheretheiss's own timestamp:
# runnable local: pip install sgp4 requests
# (sgp4_at and great_circle are defined in the full listing below)
from sgp4.api import Satrec
wi = requests.get("https://api.wheretheiss.at/v1/satellites/25544", timeout=20).json()
tles = requests.get("https://api.wheretheiss.at/v1/satellites/25544/tles", timeout=20).json()
sat = Satrec.twoline2rv(tles["line1"], tles["line2"]) # ITS elements
mine = sgp4_at(sat, wi["timestamp"]) # ITS timestamp
W = (wi["latitude"], wi["longitude"])
print("my sgp4 lat %9.4f lon %9.4f" % mine)
print("wheretheiss lat %9.4f lon %9.4f" % W)
print("distance %.2f km" % great_circle(mine, W))
tle_timestamp 1783883409 (the elements wheretheiss is propagating)
my sgp4 lat 26.5017 lon 84.7225
wheretheiss lat 26.5017 lon 84.7225
distance 0.00 km
Zero. Not "close." Identical to four decimal places, because api.wheretheiss.at is an SGP4 propagator running on that element set. Reproducing its output is a unit test of my pip install sgp4, not corroboration of anything about the sky.
My 3 km only existed because my arbiter happened to grab a different element set (CelesTrak's, twelve hours newer) and section 3 already told you, with a number, that those two sets sit 1.0 km apart. I wrote the word "independent" about thirty lines after admitting that both feeds carry the same elements. The code ran. The output was real. The comparison was rigged, and it did not survive review.
Which is the disease of this entire post, caught in my own draft: a green check that means nothing. Nothing I could have added to my test suite would have found it. Only someone asking "independent of what?" would.
The arbiter that is at least a different implementation
So go get a real second opinion. NASA runs one, keyless, and it has been sitting in section 5 of this post the whole time: JPL Horizons will compute the ISS for you if you hand it COMMAND='-125544'.
And Horizons, being a grown-up, tells you in its own header what it is: Trajectory is TLE-based.
There it is. There is no keyless oracle for where the ISS is. Horizons ingests the same public element sets everyone else does. So does wheretheiss. So does my script. The entire visible world of free ISS tracking hangs off one lineage of tracking data, produced by the 18th Space Defense Squadron, and if that lineage were wrong we would all be wrong together and in perfect agreement about it.
So I am not going to sell you an oracle. What Horizons gives you is weaker and still worth a great deal: an independent implementation. JPL's ingest, JPL's propagator, JPL's frame and rotation handling, none of my code, none of wheretheiss's server. I would rather hand you the weaker claim that is true.
The trick for getting a ground track out of it is nice: ask Horizons for Earth, observed from the ISS, and request quantity 14, the sub-observer point. The point on Earth directly under the observer is the ground track, computed by JPL, with all the frame rotation handled by people who do this for a living.
# runnable local: pip install sgp4 requests
import math, requests
from datetime import datetime, timezone
from sgp4.api import Satrec, jday
def gmst_deg(jd): # Vallado, GMST from a Julian date (UT1)
T = (jd - 2451545.0) / 36525.0
s = 67310.54841 + (876600*3600.0 + 8640184.812866)*T + 0.093104*T*T - 6.2e-6*T**3
return (s % 86400.0) * (360.0/86400.0) % 360.0
def teme_to_geodetic(r, jd): # TEME -> ECEF -> WGS84 lat/lon
g = math.radians(gmst_deg(jd))
x = math.cos(g)*r[0] + math.sin(g)*r[1]
y = -math.sin(g)*r[0] + math.cos(g)*r[1]
z = r[2]
a, f = 6378.137, 1/298.257223563
e2 = f*(2 - f)
p = math.hypot(x, y)
lat = math.atan2(z, p*(1 - e2))
for _ in range(12): # standard WGS84 latitude iteration
N = a / math.sqrt(1 - e2*math.sin(lat)**2)
alt = p/math.cos(lat) - N
lat = math.atan2(z, p*(1 - e2*N/(N + alt)))
lon = math.degrees(math.atan2(y, x))
return math.degrees(lat), (lon + 180) % 360 - 180
def great_circle(p, q):
la1, lo1, la2, lo2 = map(math.radians, [p[0], p[1], q[0], q[1]])
h = math.sin((la2-la1)/2)**2 + math.cos(la1)*math.cos(la2)*math.sin((lo2-lo1)/2)**2
return 2 * 6371.0 * math.asin(math.sqrt(h))
def sgp4_at(sat, unix_ts): # propagate to ONE API's OWN timestamp
d = datetime.fromtimestamp(unix_ts, tz=timezone.utc)
jd, fr = jday(d.year, d.month, d.day, d.hour, d.minute,
d.second + d.microsecond/1e6) # keep the sub-second part
err, r, _ = sat.sgp4(jd, fr)
assert err == 0
return teme_to_geodetic(r, jd + fr)
def horizons_subpoint(unix_ts):
"""Target = Earth, observer = the ISS, quantity 14 = sub-observer point.
That IS the ground track, and JPL does the frame work."""
jd = 2440587.5 + unix_ts/86400.0
r = requests.get("https://ssd.jpl.nasa.gov/api/horizons.api", timeout=40, params={
"format": "text", "COMMAND": "'399'", "CENTER": "'500@-125544'",
"MAKE_EPHEM": "YES", "EPHEM_TYPE": "OBSERVER", "QUANTITIES": "'14'",
"TLIST_TYPE": "JD", "TIME_TYPE": "UT", "TLIST": f"{jd:.9f}"})
rows = r.text.split("$$SOE")[1].split("$$EOE")[0].strip().splitlines()
lon, lat = map(float, rows[0].split()[-2:]) # E-lon, lat
return lat, lon
wi = requests.get("https://api.wheretheiss.at/v1/satellites/25544", timeout=20).json()
on = requests.get("http://api.open-notify.org/iss-now.json", timeout=20).json()
el = requests.get("https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=TLE",
timeout=20).text.strip().splitlines()
ts_w, ts_o = wi["timestamp"], on["timestamp"]
W = (wi["latitude"], wi["longitude"])
O = (float(on["iss_position"]["latitude"]), float(on["iss_position"]["longitude"]))
sat = Satrec.twoline2rv(el[1], el[2])
S_w, S_o = sgp4_at(sat, ts_w), sgp4_at(sat, ts_o) # each at its OWN timestamp
H_w, H_o = horizons_subpoint(ts_w), horizons_subpoint(ts_o)
print("wheretheiss vs JPL Horizons %7.1f km" % great_circle(W, H_w))
print("my sgp4 vs JPL Horizons %7.1f km" % great_circle(S_w, H_w))
print("wheretheiss vs my sgp4 %7.1f km" % great_circle(W, S_w))
print("open-notify vs JPL Horizons %7.0f km" % great_circle(O, H_o))
Note one small fix in there, which cost me most of my 3 km: each API is judged at the instant it stamped, with the microseconds kept. The first version rounded to whole seconds, and a whole second of ISS is 7.7 km. My arbiter's error bar was bigger than the disagreement I was using it to measure.
ts_w 1783974793 ts_o 1783974795 (each API judged at the instant IT stamped)
lat lon
JPL Horizons @ts_w 29.0128 82.1929
wheretheiss @ts_w 28.9979 82.1926
my sgp4 @ts_w 29.0052 82.1859
JPL Horizons @ts_o 28.9213 82.2889
open-notify @ts_o -35.0240 141.4149
wheretheiss vs JPL Horizons 1.7 km
my sgp4 vs JPL Horizons 1.1 km
wheretheiss vs my sgp4 1.0 km
open-notify vs JPL Horizons 9432 km
Three implementations, one element lineage, landing within 1.7 km of each other over the Himalayas. And Open Notify, judged at the instant it stamped its own answer, 9,432 km away over southern Australia.
Do not read the 1.7 km as a quality score. It is the noise floor of the method: TLE plus SGP4 is good to a couple of kilometres for the ISS on a calm day, my GMST rotation is an approximation, and the three of us are propagating fits anchored at different hours. Anything under a few kilometres here means "the same place." I am not selling you a precision I do not have.
What the run does establish is narrower than "Open Notify is wrong" and much harder to argue with: Open Notify is not tracking the satellite the rest of us are tracking. It is serving a position that no published element set produces, under HTTP 200, with a timestamp one second old.
The corroborating evidence is the crew roster. Open Notify's other endpoint tells you who is in space:
curl -s "http://api.open-notify.org/astros.json" # HTTP 200
curl -s "https://ll.thespacedevs.com/2.2.0/astronaut/?in_space=true" # HTTP 200
open-notify number = 12 Kononenko, Chub, Caldwell Dyson, Dominick,
Barratt, Epps, Grebenkin, Wilmore, Sunita Williams,
Li Guangsu, Li Cong, Ye Guangfu
thespacedevs count = 11 Meir, Starman, Kud-Sverchkov, Hathaway,
Christopher Williams, Fedyaev, Adenot, Zhu Yangzhu,
Mikayev, Zhang Zhiyuan, Lai Ka-ying
overlap = 0
Zero names in common. (The only shared surname is Williams, and they are two different people.) Both HTTP 200, both a valid list, both cheerfully certain.
Now, an honest caveat, because I am not going to pretend I have an oracle here either: The Space Devs is not ground truth. Its in_space=true list includes "Starman," the mannequin SpaceX put in a Tesla in 2018, typed Non-Human, and the API reports 3,078 days of flight time for him. I am not telling you that list is right. I am telling you these two 200s cannot both be right, and that is the entire lesson. Open Notify's roster happens to be internally consistent with a snapshot from 2024, which is a strong hint about which one froze. But I did not need to know who is actually aboard the ISS to know that something here is broken. Two disagreeing successes are all the evidence you need.
So what do you actually do?
Two layers, and they catch different diseases.
Layer one, cheap: read the body's own clock. EPOCH, time_tag, lastRun, updated. Set a max_age you can defend and reject anything older. This catches honest staleness: CelesTrak's 13-hour elements, SWPC's Kp label sitting behind a three-hour bin, the TLE feed twice as old as its neighbor. Most of your data problems are here, and thirty lines of fetch_fresh will find them.
Layer two, expensive: cross-check against a second implementation. This is the only thing that catches a server stamping now on garbage. It is not free, so do not do it on every call. Run it on a schedule, as a canary, and alert when two sources that should agree stop agreeing.
And when you build that canary, ask the question I failed to ask for a whole draft: independent of what? A second source that is downstream of the first one is not a second source. It is a mirror, and it will agree with anything.
If I had shipped an ISS tracker on Open Notify, no amount of schema validation, status checking, null-guarding, or timestamp reading would have saved me. Only a second opinion would, and only if I had checked that it was actually a second one.
I learned the first half of that in a different domain, expensively. Across 2,190 production scraper runs, the incidents that actually hurt were never the crashes. A 500 wakes you up and you fix it by lunch. The response that comes back valid, non-empty, correctly shaped, and quietly wrong is the one that sits in a column until somebody notices a report looks off, and by then you are backfilling.
Space just makes it visible, because a wrong satellite position is 9,000 km of visible. Your API is probably lying in a smaller, quieter way. Go read its timestamp. Then go check what your check is checking.
FAQ
What is the best free space API with no key?
It depends on the job, and several are keyless together. Where the ISS at gives live station position. CelesTrak gives the orbital elements everything else is built on. JPL SSD/CNEOS covers asteroids and close approaches, Launch Library 2 covers launches, and the NASA Exoplanet Archive lets you run real ADQL queries against 6,319 confirmed planets (the ps table has 39,978 rows, because it stores one row per published parameter set, not one per planet). All returned HTTP 200 with no key on July 13, 2026. Whichever you pick, read the timestamp field in the body before you trust the numbers.
Does the NASA API require an API key?
api.nasa.gov (APOD, NeoWs, EPIC, DONKI) does: without a key it returns HTTP 403 with API_KEY_MISSING. DEMO_KEY works but is a shared public credential (its live response carried x-ratelimit-limit: 10 and started returning 429 on my third call), so it is a key, not "no key." At least five other NASA-funded services are genuinely keyless and returned HTTP 200 with no credentials on July 13, 2026: the NASA Image and Video Library (images-api.nasa.gov), JPL's ssd-api.jpl.nasa.gov suite, JPL Horizons (ssd.jpl.nasa.gov), the NASA Exoplanet Archive at IPAC, and EONET (eonet.gsfc.nasa.gov).
Is the Open Notify ISS API still working in 2026?
It returns HTTP 200 and it is not working. On July 13, 2026 its iss-now.json position was 9,432 km away from where JPL Horizons put the ISS at the same instant, while api.wheretheiss.at and my own SGP4 propagation of CelesTrak's current elements both agreed with Horizons to within 1.7 km. Its astros.json crew roster still lists the people who were aboard in mid-2024, Wilmore and Williams among them. It also fails TLS, so it only answers over plain http://. Treat it as an exhibit, not a dependency.
How do I know if an API's data is stale when it returns 200?
Read the timestamp out of the response body, not the status line. Real APIs publish the age of their own truth: CelesTrak has EPOCH, NOAA SWPC has time_tag, JPL Scout has lastRun, SatNOGS has updated. Compare that field to now and reject anything older than a max_age you chose deliberately. That catches frozen feeds. It does not catch a server that stamps the current time on stale inputs, and for that you need a second source to cross-check against, one that is genuinely a different implementation rather than a mirror of the first.
What replaced the SpaceX API?
The SpaceX v4 API at api.spacexdata.com returned HTTP 525 (a Cloudflare origin SSL failure) when I tested it on July 13, 2026. Launch Library 2 from The Space Devs (ll.thespacedevs.com) is the practical replacement: it is keyless for anonymous use, it covers all launch providers rather than one, and it returned 364 upcoming launches. Anonymous access is rate-limited, so a free account helps for anything real.
Can I get real asteroid and close-approach data for free?
Yes, from JPL, with no key. ssd-api.jpl.nasa.gov/sbdb.api?sstr=433 returns the full orbit of 433 Eros, including first_obs: 1893-10-29, last_obs: 2021-05-13, and data_arc: 46582 days, which is a 127-year observation arc built from 9,130 observations. cad.api lists close approaches (19 objects within 0.05 au in the month I checked), fireball.api lists atmospheric impact events, and scout.api covers newly-discovered objects. One warning: sentry.api can return HTTP 200 with an error key in the body, so branch on the body, not the status.
Written by Aleksei Spinov. I build web-scraping and data-enrichment tool layers, currently 2,190 runs across 32 actors in production. Every endpoint above was re-verified with a live curl (real HTTP code, real body) on July 13, 2026 before publishing; responses are trimmed, never reworded. I have not run a satellite-tracking service in production; the 2,190 runs are a scraping-and-enrichment domain, cited only as the origin of the read-the-body habit. I wrote up the same pattern for free government data APIs, free fun-facts APIs, and free mock and fake-data APIs. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.
Follow for the next keyless layer I verify. And tell me: what is the longest a stale-but-successful API fooled you, and what finally caught it, a timestamp or a second opinion? I read every comment.
Top comments (0)