The request usually arrives in one sentence. "We have a tail number and a date. Show us where the aircraft went." Insurance wants it after an incident, an operator wants it for a utilisation report, a dispatcher wants to see what actually happened versus what was filed.
There isn't a single call that does this, and the first hour with any historical flight API tends to go into working out why. A tail number is not a flight. G-XWBA might have operated four sectors on the day you care about, and "the track" is only meaningful once you've said which one. So the archive makes you name the flight first, then hands you its path.
This post covers both routes to an archived track — the search-then-track chain and the raw position timeline — when each is the right one, and the details that cost people an afternoon.
Two routes, and they answer different questions
Before any code, the fork in the road:
Search → flight_id → track. You get one flight, bounded automatically from takeoff to landing with a five-minute pad on each end. The response carries the route, the aircraft, and the times alongside the positions. This is what you want when the question is "show me this flight."
Positions by registration or hex. You get every position in a time window with no flight segmentation at all. It may span four sectors and the overnight sit in between. No search step needed. This is what you want when the question is "where has this airframe been this week," or when you don't know the flight boundaries well enough to pick one.
Picking the wrong one is the most common early mistake. If you call the position endpoint expecting one clean flight, you'll get a continuous ribbon of points that walks through several, and you'll end up writing the segmentation logic that the track endpoint would have done for you.
Route one: search, then track
Search is mandatory before track. There is no way to guess a flight_id — they're UUIDs, and inventing one returns a 404. At least one filter is required, from icao24, registration, callsign, departure_icao, or arrival_icao.
import requests
BASE = "https://skylink-api.p.rapidapi.com"
H = {"X-RapidAPI-Key": KEY, "X-RapidAPI-Host": "skylink-api.p.rapidapi.com"}
r = requests.get(f"{BASE}/ultra/history/flights", headers=H, params={
"registration": "G-XWBA",
"start": "2026-05-12T00:00:00Z",
"end": "2026-05-14T00:00:00Z",
})
r.raise_for_status()
data = r.json()
print(f"{data['count']} flights")
for f in data["flights"]:
print(f" {f['flight_id']} {f.get('callsign','?'):8} "
f"{f.get('departure_airport_icao','????')}->{f.get('arrival_airport_icao','????')} "
f"{f.get('takeoff_time')} {f.get('flight_duration_min')} min")The tail number gets resolved to a hex address server-side, and the response echoes what it resolved to in filters.resolved_icao24. Worth logging that: if a search comes back empty, checking whether the registration resolved at all separates "this aircraft didn't fly" from "this tail number isn't in the registry."
Each result is a flight summary carrying flight_id, the callsign and flight number, the aircraft type, both airports, and the timing. Pick the one you want and ask for its track:
flight_id = data["flights"][0]["flight_id"]
t = requests.get(f"{BASE}/ultra/history/flight/{flight_id}/track",
headers=H, params={"limit": 5000})
t.raise_for_status()
track = t.json()
print(f"{track['callsign']} {track['registration']} "
f"{track['departure_airport_icao']} -> {track['arrival_airport_icao']}")
print(f"{track['count']} positions between "
f"{track['takeoff_time']} and {track['landing_time']}")Each position carries timestamp, latitude, longitude, altitude_baro, ground_speed, track, vertical_rate, is_on_ground, and a gps_spoofed flag.
The positions come back newest-first
This is the one that gets everybody, because nothing looks broken. You draw the line, it renders, and the aircraft appears to fly backwards from destination to origin. Altitude profiles come out mirrored — a descent where the climb should be.
Reverse before you do anything else, and sort by timestamp rather than trusting order:
positions = sorted(track["positions"], key=lambda p: p["timestamp"])Sorting rather than calling reversed() costs nothing and survives the case where the ordering changes or a page boundary interleaves.

Route two: positions without a search
When you want a timeline rather than a flight, go at the position endpoint directly. It takes either a hex address or a tail number, and the registration variant resolves to hex server-side the same way search does.
# Every position for this airframe over three days, across all its flights
p = requests.get(f"{BASE}/ultra/history/positions/registration/G-XWBA",
headers=H, params={
"start": "2026-05-12T00:00:00Z",
"end": "2026-05-15T00:00:00Z",
"limit": 5000,
})
p.raise_for_status()
hist = p.json()The response carries positions and also a flights array, which is the segmentation the archive already worked out for that window — callsigns, airports, takeoff and landing times. That's useful: you can take the raw timeline and still know where the flight boundaries fell, without a separate search.
A registration that isn't in the aircraft registry returns 404 here rather than an empty result, which is a more honest failure than search's empty list and worth handling separately.
The /ultra/ and /mega/ prefixes are not your plan name
This trips people up more than anything else in the historical group, so it's worth being blunt about.
The path prefix controls the retention window and the per-request caps. It is not the name of your subscription:
/ultra/history/... | /mega/history/... | |
|---|---|---|
| Max window per request | 90 days | 365 days |
| Max flights per request | 1,000 | 2,000 |
| Max positions per request | 5,000 | 10,000 |
| Max track positions | 2,000 default, 5,000 cap | 5,000 default, 10,000 cap |
| Plans that may call it | Pro, Ultra, Mega | Ultra, Mega |
So a Pro subscriber uses /ultra/history/... and gets 90-day lookbacks. An Ultra or Mega subscriber may call either, and would use /ultra/... for a recent lookback and /mega/... when they need to reach back further or pull more rows in one request.
Two failure modes follow from this. A 120-day window on /ultra/history/... returns 422 even on a Mega subscription, because the cap belongs to the path. And a Pro subscriber calling /mega/history/... gets a 401 or 403 regardless of how narrow the window is. Neither is worth retrying — retrying a plan-gate error just burns quota against a wall.
Turning a track into something you can use
Two shapes cover most of what people build.
For a map, GeoJSON. The trap here is coordinate order: GeoJSON is [longitude, latitude], which is the reverse of how the API returns them and the reverse of how most people say it out loud. Get it backwards and a Heathrow departure at 51.47, -0.45 renders at latitude -0.45, longitude 51.47 — the Indian Ocean, east of Somalia. It's a distinctive enough failure that you only debug it once.
line = {
"type": "Feature",
"properties": {
"callsign": track.get("callsign"),
"registration": track.get("registration"),
"aircraft_type": track.get("aircraft_type_icao"),
},
"geometry": {
"type": "LineString",
# GeoJSON is [lon, lat] — not [lat, lon]
"coordinates": [[p["longitude"], p["latitude"]]
for p in positions
if p.get("latitude") is not None
and p.get("longitude") is not None],
},
}For a replay, you want to step through positions at a chosen multiple of real time. The gaps between samples aren't uniform, so pace off the timestamps rather than assuming a fixed interval:
from datetime import datetime
def parse(ts):
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
def replay(positions, speed=60.0):
"""Yield (delay_seconds, position) pacing off real timestamps."""
prev = None
for p in positions:
t = parse(p["timestamp"])
delay = 0.0 if prev is None else (t - prev).total_seconds() / speed
prev = t
yield delay, p
for delay, p in replay(positions, speed=120.0):
# time.sleep(delay) in a real player
print(f"{p['timestamp']} {p['latitude']:.4f},{p['longitude']:.4f} "
f"{p['altitude_baro']:>6} ft {p['ground_speed']:>3} kt")Don't interpolate across a long gap and store the result as though it were observed. Draw the dashed line on the map if you want, but keep synthetic points out of the database — the reasoning is the same as for live feeds, and it's covered in handling gaps, jumps, and bad positions.

Read the quality flags before you trust the replay
Archived data carries provenance that live positions don't, and if the output is going into a report someone will act on, these fields matter more than the coordinates.
gps_spoofing_suspected on the flight record, and gps_spoofed per position, mark positions the archive believes were falsified. This is real and increasingly common in some regions; a track through a spoofing zone can look perfectly smooth and be wrong by a long way. Check the flag rather than eyeballing the line.
diversion_suspected says the flight may not have ended where the route implies. Useful on its own, and a reason not to assume arrival_airport_icao is where the aircraft actually parked.
confidence and route_source describe how well the flight was reconstructed. A route_source of vrs_confirmed is a stronger claim than an inferred one.
duration_source is the one to watch in reporting workflows. When it reads estimated, block time was inferred from position data rather than recorded gate events. That is fine for an operational sanity check and shaky as an input to an ESG filing or a contractual delay calculation, and the distinction is invisible unless you look.
off_block_time and on_block_time are gate events, distinct from takeoff_time and landing_time, which are wheels-up and wheels-down. Both block fields can be null when coverage near the gate was incomplete, which is common at airports with poor low-altitude reception. Build the fallback rather than assuming they're populated.
Errors worth handling up front
422, no filter. Search needs at least one of the five filters. An empty search isn't a broad search, it's rejected.
422, window too wide. The span exceeded the path's cap. The message names the cap and the path, which makes it self-diagnosing.
422, registration and ICAO24 mismatch. You supplied both and they disagree. Send one, or make sure they refer to the same airframe.
401 or 403, plan not entitled. The historical group is gated. Note that these come back with a message field rather than the detail used by validation errors, so a handler that only reads detail will log an empty string on exactly the error you most need to read.
404 on track or detail. Almost always a flight_id that didn't come from search.
What you need to call this
Historical ADS-B needs Pro or above. The free trial and Basic don't reach it, so this is one of the few guides on this blog where "try it on the free tier" isn't the advice — the free trial is genuinely useful for the live ADS-B endpoint and the rest of the API, but a historical call on it returns a plan-gate error rather than data.
If you're on Pro, you have /ultra/history/... and 90-day lookbacks, which covers most incident review and recent-utilisation work. If you need to reach back a year, or pull 10,000 positions in one request, that's Ultra or Mega and the /mega/history/... prefix.
The reference pages are Flight History Search, flight detail and track, and Position History, with the tier rules on the Historical ADS-B hub. If you're deciding whether an archive is worth paying for at all, build vs buy for ADS-B ingestion works through what storing this yourself actually costs — the answer is mostly that positions accumulate faster than people plan for.
