15 Keyless Transit APIs, and a Timestamp Their Own Docs Get Wrong
airplanes.live documents its timestamp field like this, verbatim, on their field description page:
now: the time this file was generated, in seconds since Jan 1 1970 00:00:00 GMT (the Unix epoch).
I asked their API. It sent me 1784230292010.
That is not seconds. That is milliseconds, and it has been milliseconds every time I have looked. Feed it to the freshness check every pipeline has, and here is what all three of the big keyless ADS-B providers tell you:
adsb.fi age = -1,782,447,356,414s ( -56,484 years) fresh -> use it
airplanes.live age = -1,782,447,358,410s ( -56,484 years) fresh -> use it
adsb.lol age = -1,782,447,359,908s ( -56,484 years) fresh -> use it
Real bodies, real stdout, one field both the code and the docs call now. The data appears to have been minted 56,484 years from now, which is extremely fresh. Nothing raised an exception.
The one idea in this post
A keyless transit API returns transport data with no API key, no signup, no card. Fifteen clear that bar and I re-verified every one with a live curl on July 17, 2026. The table is one screen down. Underneath it sits the actual lesson: a field name is not a contract, and the unit is the part nobody writes down.
This is the fourth stop on the same road. A 200 that means nothing. A timestamp that looks current on top of frozen data. A field name that promises more than it counts. This one is worse, because the wrong answer comes with documentation.
The 15, at a glance
Everything here returned HTTP 200 with no key from my host on July 17, 2026.
| # | API | What it returns | The gotcha |
|---|---|---|---|
| 1 | OpenSky Network | Live state vectors, global | State is an array, fields by index. baro_altitude null on 25 of 41, and all 25 were on_ground |
| 2 | adsb.fi | Live ADS-B, use the v3 endpoint | now is an int of milliseconds. Its own deprecated v2/lat/lon/dist sends float seconds under the same name |
| 3 | adsb.lol | Live ADS-B, OpenAPI spec | Spec declares now as {"type": "integer"}. Correct, and useless |
| 4 | airplanes.live | Live ADS-B, 1 req/s | Field docs say now is in seconds. The API sends milliseconds |
| 5 | hexdb.io | Hex to registration/owner | Keys are PascalCase: ModeS, not hex. Will not join to ADS-B JSON |
| 6 | Citybikes | 600+ bike networks | Timestamp ends +00:00Z. Both designators. fromisoformat dies on all 1516 |
| 7 | GBFS (Divvy) | 2016 live stations | One ghost station reports last_reported: 86400, i.e. Jan 2 1970 |
| 8 | MBTA | Boston transit, JSON:API | Keyless works, at a lower rate limit than keyed. data[].attributes |
| 9 | TfL | London tube status | .NET $type noise everywhere. created was year 0001 on all 13 live statuses |
| 10 | Amtraker | Live Amtrak positions | Unofficial re-wrapper. Lives exactly as long as its upstream does |
| 11 | transport.opendata.ch | Swiss connections | Nulls are normal, not errors. The terminus has a null departure |
| 12 | iRail | Belgian departures | Without -L it returns 303 and a zero-byte body. Not an error. Nothing |
| 13 | Wienerlinien | Vienna departures | stopId is an internal RBL code. You need their CSV first |
| 14 | OSRM demo | Routing, distances | Demo server, no SLA. Coordinates are lon,lat. Reversed |
| 15 | Transitous / MOTIS | Pan-European stop times | Numbers in scientific notation: "lat":5.252498E1 |
A sixteenth is worth a line. Entur (Norway) documents ET-Client-Name as a required header. I sent the query both ways. Both returned {"data":{"stopPlace":{"name":"Oslo S"}}}, HTTP 200. Required on paper, not in the socket.
The unit does not live in the field name
Three ADS-B providers, three domains, three separate projects. All three name the field now. All three send an integer.
# runnable, read-only: no key, no signup. what `now` actually is, on all three.
import json, urllib.request, datetime
SRC = {
"adsb.fi": "https://opendata.adsb.fi/api/v3/lat/50.03/lon/8.57/dist/40",
"airplanes.live": "https://api.airplanes.live/v2/point/50.03/8.57/40",
"adsb.lol": "https://api.adsb.lol/v2/lat/50.03/lon/8.57/dist/40",
}
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": "spinov-api-check/1.0"})
return json.load(urllib.request.urlopen(req, timeout=25))
for name, url in SRC.items():
now = fetch(url)["now"] # same field name on all three
try:
stamp = datetime.datetime.fromtimestamp(now, datetime.timezone.utc)
print(f"{name:16} {now!r:16} {type(now).__name__:5} -> {stamp}")
except (ValueError, OSError, OverflowError) as e:
print(f"{name:16} {now!r:16} {type(now).__name__:5} -> {type(e).__name__}: {e}")
adsb.fi 1784231570002 int -> ValueError: year 58510 is out of range
airplanes.live 1784231572001 int -> ValueError: year 58510 is out of range
adsb.lol 1784231573060 int -> ValueError: year 58510 is out of range
So they agree. Milliseconds, all three. Run it later than I did and you may get 58511, or 58512: the year in that error message advances by one every nine hours or so. Hold that thought.
If you read the airplanes.live field docs first, wrote fromtimestamp(now) because the docs told you the unit was seconds, and shipped it, you did everything right and it still broke.
Where does a mistake like that come from? Not from carelessness. It is inherited.
All three of these are downstream of readsb, the receiver software by wiedehopf that most of this ecosystem runs. Its README-json.md documents aircraft.json like this:
now: the time this file was generated, in seconds since Jan 1 1970 00:00:00 GMT (the Unix epoch).
That is the exact sentence airplanes.live publishes. Their page even opens with “This file contains readsb list of recently seen aircraft.” The docs were inherited along with the format.
But readsb has a second output mode, and its README documents that too, about 165 lines further down, under the &jv2 flag:
Change json syntax to be compatible with adsbexchange v2 API output
now: same as normal BUT in milliseconds
There it is. Same field, same name, unit flipped by a query flag. And adsb.fi’s README says, in the sentence introducing its endpoint table: “The endpoints and responses are compatible with ADSBexchange v2 API.” That is the mode they serve. The docs on the site describe the mode they do not.
One provider, one field name, two units
You do not need three providers to see it. adsb.fi contradicts itself, and its README tells you why:
Note: The v2/lat/lon/dist endpoint returned data in a different format than other v2 endpoints. It remains functional for backward compatibility but is considered deprecated. The corresponding v3 endpoint should be used for all new integrations.
Take that seriously and check what “a different format” means:
# runnable, read-only: one provider, three endpoints, one field name
import json, urllib.request, time
EP = {
"v2/lat/lon/dist (deprecated)": "https://opendata.adsb.fi/api/v2/lat/50.03/lon/8.57/dist/40",
"v3/lat/lon/dist (current)": "https://opendata.adsb.fi/api/v3/lat/50.03/lon/8.57/dist/40",
"v2/hex/461E1A": "https://opendata.adsb.fi/api/v2/hex/461E1A",
}
for label, url in EP.items():
req = urllib.request.Request(url, headers={"User-Agent": "spinov-api-check/1.0"})
body = json.load(urllib.request.urlopen(req, timeout=25))
now = body["now"]
key = "ac" if "ac" in body else "aircraft"
print(f"{label:30} now={now!r:18} {type(now).__name__:5} array key={key!r}")
time.sleep(1.1) # adsb.fi asks for max 1 request per second
v2/lat/lon/dist (deprecated) now=1784230282.001 float array key='aircraft'
v3/lat/lon/dist (current) now=1784230284001 int array key='ac'
v2/hex/461E1A now=1784230286001 int array key='ac'
Look at what the boundary actually is. It is not the version number: v2/hex and v3/lat/lon/dist agree with each other, and v2/lat/lon/dist is the odd one out inside v2. One provider, one field name, float seconds on one endpoint and int milliseconds on the next, and the thing that selects between them is which URL you typed.
I got this wrong the first time I wrote this post. I built the whole argument on adsb.fi sending seconds while its neighbours sent milliseconds, which made a lovely story about providers disagreeing.
It was an artifact of my own URL. I had pointed at the one endpoint adsb.fi marks deprecated, in a README I had already linked in my own table and had not read to the bottom. Two providers got their current contract, one got the contract it tells you not to use.
The real finding was smaller and much worse: the unit is a property of the endpoint, not the vendor.
And a schema will not rescue you, because the schema does not carry units. adsb.lol publishes a real OpenAPI spec, which is more than most keyless APIs manage. Here is what it says about now, verbatim from api.adsb.lol/api/openapi.json:
"now": {"type": "integer", "title": "Now"}
Type integer. Title Now. No unit, no description, no example. Every word of that is true. Generate a client from it and you get a correctly typed int and zero information about the thing that will break you.
Why the crash is the lucky outcome
That ValueError is loud. Loud is a gift. You see it, you fix it, you go to bed.
Now write the version nobody puts in a try block, because it is arithmetic and arithmetic does not throw:
# runnable, read-only: the SILENT version of the same bug
import json, urllib.request, time
SRC = {
"adsb.fi": "https://opendata.adsb.fi/api/v3/lat/50.03/lon/8.57/dist/40",
"airplanes.live": "https://api.airplanes.live/v2/point/50.03/8.57/40",
"adsb.lol": "https://api.adsb.lol/v2/lat/50.03/lon/8.57/dist/40",
}
MAX_AGE = 60 # seconds. the guard every pipeline has.
for name, url in SRC.items():
req = urllib.request.Request(url, headers={"User-Agent": "spinov-api-check/1.0"})
now = json.load(urllib.request.urlopen(req, timeout=25))["now"]
age = time.time() - now # no crash. no exception. just arithmetic.
verdict = "STALE -> failover" if age > MAX_AGE else "fresh -> use it"
print(f"{name:16} age = {age:>18,.0f}s ({age/31_556_952:>9,.0f} years) {verdict}")
adsb.fi age = -1,782,447,356,414s ( -56,484 years) fresh -> use it
airplanes.live age = -1,782,447,358,410s ( -56,484 years) fresh -> use it
adsb.lol age = -1,782,447,359,908s ( -56,484 years) fresh -> use it
Read the number slowly. The age is negative 56,484 years. The payload does not look stale. It looks like it was minted 56,484 years in the future, which sails through age > 60 and always will.
Run it tomorrow and the year count will have moved. It gains one every 8.8 hours, because a millisecond clock runs a thousand times faster than the seconds your code believes it is subtracting, so the gap widens by 999 seconds every second.
I watched it tick from 56,483 to 56,484 while I was editing this paragraph. The magnitude is noise. The sign is the bug.
Your staleness guard did not fire a false alarm. It stopped being able to fire at all. Not on the backup. On every provider in the chain at once, from the first line of code you ever wrote against them, quietly, while every test you thought to write went green.
The overlap is structural, not statistical
Second thing worth knowing before you wire three of these together as a failover chain. I hit all three in parallel, same 40nm box over Frankfurt, and measured the window instead of claiming it:
# runnable, read-only: overlap, all three in parallel, window measured not assumed
import json, urllib.request, itertools, time
from concurrent.futures import ThreadPoolExecutor
SRC = {
"adsb.fi": "https://opendata.adsb.fi/api/v3/lat/50.03/lon/8.57/dist/40",
"airplanes.live": "https://api.airplanes.live/v2/point/50.03/8.57/40",
"adsb.lol": "https://api.adsb.lol/v2/lat/50.03/lon/8.57/dist/40",
}
def fetch(item):
name, url = item
req = urllib.request.Request(url, headers={"User-Agent": "spinov-api-check/1.0"})
t0 = time.time()
body = json.load(urllib.request.urlopen(req, timeout=25))
return name, body, t0, time.time()
with ThreadPoolExecutor(max_workers=3) as ex:
res = list(ex.map(fetch, SRC.items()))
print(f"window, first request to last response: {max(r[3] for r in res) - min(r[2] for r in res):.2f}s")
seen = {}
for name, body, _, _ in res:
planes = body.get("ac") or body.get("aircraft") or [] # the key name differs
seen[name] = {p["hex"] for p in planes}
print(f"{name:16} {len(planes):3} aircraft")
for a, b in itertools.combinations(seen, 2):
inter, union = len(seen[a] & seen[b]), len(seen[a] | seen[b])
print(f"{a:16} x {b:16} common={inter:3} jaccard={inter/union:.3f}")
both, every = set.intersection(*seen.values()), set.union(*seen.values())
print(f"\nin ALL THREE: {len(both)} / {len(every)} = {len(both)/len(every):.3f}")
window, first request to last response: 2.22s
adsb.fi 38 aircraft
airplanes.live 38 aircraft
adsb.lol 37 aircraft
adsb.fi x airplanes.live common= 37 jaccard=0.949
adsb.fi x adsb.lol common= 37 jaccard=0.974
airplanes.live x adsb.lol common= 37 jaccard=0.974
in ALL THREE: 37 / 39 = 0.949
Across eight runs in the same box the all-three overlap ran from 0.949 to 1.000, and three of those eight were a flat 1.000: every aircraft, in every response. The output above is the worst run I got, not the best.
I am not going to dress that up as a statistical discovery, because the physics makes it inevitable.
The B in ADS-B stands for Broadcast. An aircraft has one transponder shouting its position on 1090MHz, and every antenna in range hears the same shout. There is one source. What the aggregators compete on is who has a receiver near enough to hear it.
And they largely do not compete, because they share the receivers. The standard feeder stack is ultrafeeder, one container that takes one antenna and, in its own words, will “forward the data to one or more aggregators”.
ULTRAFEEDER_CONFIG takes a semicolon-separated list, and its README hands you the copy-paste line for each of these three in the same table, one after another: feed.adsb.fi, in.adsb.lol, feed.airplanes.live. One hobbyist, one Raspberry Pi, one antenna on one roof, feeding all three at once, from the same config file.
So the overlap is not evidence about their infrastructure. It is what a broadcast medium plus a shared feeder population has to produce.
Which is the bad news, not the good news. Failover protects you from one provider going down. It does not buy you an independent read on reality. If a corner of your box has no feeder, it has no feeder in all three simultaneously, and your three-source consensus will agree, confidently, on a hole.
I had a proof here. The control killed it.
The first version of this post had a paragraph I was pleased with. It said: rssi (signal strength) matched on 0 of 42 aircraft across the three providers, therefore three different antennas heard those planes, therefore these are genuinely distinct physical networks.
That paragraph was worthless, and the way to see it is the control I had not run. Ask one provider the same question twice, 1.5 seconds apart:
# runnable, read-only: the control that killed my own argument
import json, urllib.request, time
URL = "https://api.airplanes.live/v2/point/50.03/8.57/40"
def snap():
req = urllib.request.Request(URL, headers={"User-Agent": "spinov-api-check/1.0"})
body = json.load(urllib.request.urlopen(req, timeout=25))
return {p["hex"]: p.get("rssi") for p in body["ac"]}
a = snap()
time.sleep(1.5)
b = snap() # same provider. same box. 1.5 seconds later.
common = set(a) & set(b)
same = sum(1 for h in common if a[h] == b[h])
print(f"airplanes.live vs ITSELF, 1.5s apart: rssi identical {same}/{len(common)} = {same/len(common):.1%}")
airplanes.live vs ITSELF, 1.5s apart: rssi identical 8/39 = 20.5%
One server disagrees with itself about rssi on roughly three quarters of the aircraft it saw 1.5 seconds ago. Self-agreement across my runs: 8/39, 9/39, 10/39, 10/36. Call it 20 to 28%.
adsb.fi and adsb.lol behave the same way. Of course they do: airplanes.live’s own docs say rssi is a “recent average RSSI (signal power), in dbFS”, a rolling average over a moving window, not a fingerprint of a receiver.
So if a provider agrees with itself only about a quarter of the time, three-way agreement by chance alone lands near a quarter squared: roughly 6%, call it 2 aircraft out of 39. My cross-provider run today: 1 of 39.
That is what one single server queried three times would print. My test could not tell the difference between three networks and one server wearing three domains. It never could. It just happened to produce a number that flattered the conclusion I already had.
I am leaving the wreckage in because the failure mode is the whole point of this post: a check that returns a plausible number is not the same as a check that measures the thing you named it after.
What 2,190 production runs taught me about fallbacks
I have run web scrapers in production: 2,190 runs across 32 published actors, 962 of them on a single Trustpilot review scraper. The lesson that transfers is not about aviation. It is about the shape of the failure.
The fallback is never tested on the same assertions as the primary. That is the whole bug, in one sentence. You write your asserts while you build the happy path, you point them at the source you developed against, and the backup gets a connectivity check. Can I reach it. Does it return 200. Does it parse.
All three pass here. That is exactly the problem. A backup source you never tested with your real assertions is not a backup. It is an incident with a delay on it, and the delay is set to “next outage”.
Fair objection: 2,190 runs of scrapers is not a controlled study of transit APIs, and I have not run an ADS-B pipeline in production. Nothing above rests on those runs. They are where the reflex comes from, not the evidence. The evidence is the stdout.
The other gotchas, briefly
Timestamps are where this list keeps bleeding, and the ADS-B three are not even the funniest case.
TfL returns live tube status, and today 3 of its 11 lines were genuinely disrupted: Minor Delays, Severe Delays, Part Suspended. Every one of the 13 status objects carried created: "0001-01-01T00:00:00" and modified: null. Year one. That is .NET DateTime.MinValue reaching the wire untouched, on a payload whose actual content was current. The status is fresh. The metadata swearing to its freshness is a default value.
Citybikes hands you 2026-07-16T19:38:37.752148+00:00Z. A UTC offset and a Z. Both. That is not valid ISO 8601, and datetime.fromisoformat refuses all 1516 Velib stations in one go. At least it is loud about it.
GBFS via Divvy is the opposite. 2016 stations, ttl: 60, everything current, except one station reporting last_reported: 86400 with is_installed: 0. January 2, 1970. One out of 2016, which is 0.05%, and I want to be careful here: this is a single ghost record, not a systemic staleness problem. It is enough to put a 56-year-old row in a typed timestamp column and enough to skew a min().
OpenSky does something I wish more APIs did: x-rate-limit-remaining: 388 on the response header, so your anonymous credit is visible while it drains. It also returns each aircraft as a bare array, so state[7] is the barometric altitude, and it was null on 25 of the 41 aircraft in my box.
I nearly filed that as a data-quality gotcha. It is not: all 25 had on_ground: true. My box is centred on Frankfurt airport, so most of what I caught was parked. That gotcha was mine, not theirs. Pick a box over an airport and you will measure the apron.
iRail without -L is my favourite. HTTP 303, zero-byte body. A client that does not follow redirects sees an empty response, not a failure, and empty responses have a way of becoming empty dashboards.
Why these are not on the list
- v6.db.transport.rest: HTTP 503, empty body. I tried across two sessions. Popular API, down when I looked. It does not go in a list called verified.
- BART: 403 from my IP behind a Cloudflare interstitial, and it wants a “public key” in the query string anyway. Publicly published, still a key.
- Digitransit (Finland): 401,
Access denied due to missing subscription key. It used to be keyless. It is not now. - ADSBexchange: 401 through RapidAPI. Key-gated.
- aviationapi: HTTP 000. TLS dies, and the hostname resolves to
198.18.0.163, an address in a reserved benchmarking range. That is my network intercepting the lookup, not their API failing. I could not test it from here, which is not the same as saying it is broken. Try it from your own host.
What I could not verify
Whether these three are physically distinct networks or share infrastructure, I do not know, and this post no longer claims to. I had a test, it was empty, you read the autopsy above. The honest position is that the question is open and my data does not touch it.
I also cannot tell you that these three literally run readsb behind --jv2. Readsb documents both units, the jv2 flag is described as making output “compatible with adsbexchange v2 API”, and adsb.fi says its responses are compatible with that same API. Economical explanation. Still an inference.
The doc contradiction itself is not an inference. Curl it.
The overlap numbers are one box over Frankfurt across eight runs on one evening. A quiet box at 04:00 will not give you these numbers, and I would not defend any figure as a constant. What I will defend is the direction, because broadcast physics and shared feeders both point at it.
And the honest counterpoint to my own thesis: three correlated sources still beat one. If adsb.fi returns 503, the other two return aircraft, and aircraft beat nothing. Failover is not worthless. It is solving availability while quietly presenting itself as redundancy, and those are different products.
If you run a fallback chain today, here is the question I actually want answered, because I do not have a clean method for it: how do you test that your backup source agrees with your primary about meaning, not just about shape?
Schema checks pass. Contract tests pass. Units, timezones, and what a field counts all sail straight through. And when the docs themselves carry the wrong unit, “read the docs” stops being advice.
What has worked for you? 👇
Every endpoint above was verified with a live curl on July 17, 2026, from my own host. Earlier in this series: free geocoding APIs, free government APIs, HTTP 200 is a lie. Drafted with an AI assistant, then fact-checked and edited by me against the raw responses.
More production scraping tips: t.me/scraping_ai