Airports publish traffic statistics, but usually as monthly totals, weeks after the fact, and never broken down the way you want. If you want to know how many flights left Dublin last Tuesday, what the busiest hour was, or how much of the traffic was one airline, you have to count it yourself.
Historical ADS-B data makes that possible for almost any airport. This post shows how to pull a full day of movements with the airport traffic endpoint, how to deal with its result limit, and what you can compute once you have the data. It also covers where ADS-B counts and official counts part ways.
The endpoint is part of Historical ADS-B, which needs a Pro, Ultra or Mega plan.
The request
curl "https://skylink-api.p.rapidapi.com/ultra/history/airport/EGLL/traffic?start=2026-05-12T00:00:00Z&end=2026-05-13T00:00:00Z&direction=dep&limit=1000" \
-H "X-RapidAPI-Key: YOUR_KEY" \
-H "X-RapidAPI-Host: skylink-api.p.rapidapi.com"Each flight in the response carries the aircraft's icao24, its callsign and normalised flight number, the airline, the ICAO type code, both airports, and takeoff_time and landing_time. Every field except flight_id can be null.
The /ultra/ path accepts windows of up to 90 days and returns at most 1,000 flights per request. On Ultra and Mega plans, /mega/ stretches that to 365 days and 2,000 flights.
The problem: there's no next page
There's no offset or cursor. If you ask for a day at Heathrow and get exactly 1,000 flights back, you know you hit the limit, but not which flights were left out, and the docs don't promise any particular order.
A day at Heathrow is a useful benchmark. The airport is capped at 480,000 movements a year, which works out at about 1,300 a day, split roughly evenly between departures and arrivals. Asking for departures and arrivals separately keeps each half under 1,000 on a normal day. A busier airport, a longer window or a both query won't fit.
The docs' advice is to narrow the window when count equals limit. Doing that by hand gets tedious, so let the code do it. Split any full window in half and ask again, recursively:
import requests
from datetime import datetime, timedelta, timezone
BASE = "https://skylink-api.p.rapidapi.com"
H = {"X-RapidAPI-Key": KEY, "X-RapidAPI-Host": "skylink-api.p.rapidapi.com"}
def iso(dt):
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def fetch_window(icao, start, end, direction, limit=1000):
r = requests.get(f"{BASE}/ultra/history/airport/{icao}/traffic", headers=H, timeout=30,
params={"start": iso(start), "end": iso(end),
"direction": direction, "limit": limit})
r.raise_for_status()
return r.json()
def fetch_traffic(icao, start, end, direction, limit=1000, min_span=timedelta(minutes=15)):
"""There's no offset parameter. If a window comes back full, we can't
tell what was cut, so split it in half and ask again."""
data = fetch_window(icao, start, end, direction, limit)
if data["count"] < limit:
return data["flights"]
if end - start <= min_span:
print(f"warning: {iso(start)} still full at {min_span}; results truncated")
return data["flights"]
mid = start + (end - start) / 2
return (fetch_traffic(icao, start, mid, direction, limit, min_span)
+ fetch_traffic(icao, mid, end, direction, limit, min_span))The min_span floor stops the recursion from running away. No real airport fills 1,000 departures in 15 minutes, so in practice the warning tells you something is wrong with the query rather than with the airport.
Every request counts against your quota, including the ones that come back full. A normal day at a big hub costs two requests, one per direction, and a 90-day study at the same airport costs a couple of hundred. That's comfortable on Pro's 20,000 a month.

Building the day
Now put departures and arrivals together. Two details matter here.
First, a departure happens at take-off and an arrival at landing, so each movement needs its own timestamp. A flight that took off from New York at 18:00 and landed at Heathrow at 06:10 is a 06:10 arrival.
Second, windows that touch at the edges can return the same flight twice, so remove duplicates by flight_id:
import pandas as pd
def airport_day(icao, day):
start = datetime(day.year, day.month, day.day, tzinfo=timezone.utc)
end = start + timedelta(days=1)
rows = []
for direction in ("dep", "arr"):
for f in fetch_traffic(icao, start, end, direction):
f["movement"] = direction
rows.append(f)
df = pd.DataFrame(rows).drop_duplicates(["flight_id", "movement"])
# A departure happens at takeoff, an arrival at landing.
df["time"] = pd.to_datetime(df["takeoff_time"].where(df["movement"] == "dep",
df["landing_time"]), utc=True)
return df[(df["time"] >= start) & (df["time"] < end)]The day is in UTC. If you want the airport's local day, which is usually what people mean, convert the boundaries from local midnight first. In summer, Heathrow's local midnight is 23:00 UTC the day before.
What to compute
Movements per hour, and the peak:
hourly = df.set_index("time").groupby("movement").resample("1h").size().unstack(0).fillna(0)
hourly["total"] = hourly.sum(axis=1)
peak = hourly["total"].idxmax()
print(f"peak hour {peak:%H:%M}: {hourly['total'].max():.0f} movements")Hourly totals are the number airport planners care about, because runway capacity is declared per hour. At a slot-coordinated airport, the hourly profile is almost flat through the day: the schedule is built to fill each hour up to the declared limit, as covered in how airport slots work. At an uncoordinated airport you see the waves of a hub bank structure instead, with arrivals bunched, then departures bunched an hour later. That difference shows up clearly in a chart.
For queues, hourly totals hide too much. Look at 15-minute buckets too:
busiest_15 = df.set_index("time").resample("15min").size().max()Airline share and fleet mix:
share = df["airline_name"].fillna("Unknown").value_counts(normalize=True).head(5)
WIDEBODY = {"B77W", "B772", "B789", "B78X", "B788", "A35K", "A359", "A333", "A388", "B744", "B748"}
widebody_share = df["aircraft_type_icao"].isin(WIDEBODY).mean()The widebody share says a lot about an airport. It drives stand planning, ground handling staffing and the number of passengers per movement. Extend the set to suit the airports you study; the aircraft lookup helps when you meet an unfamiliar type code.

Where ADS-B counts and official counts differ
Your number won't match the airport's published figure exactly, for good reasons.
Coverage. A flight is only reconstructed if the aircraft was heard. Coverage at large airports is usually good, but at a remote field, take-offs and landings below receiver coverage can go missing, or come through with null times. Check how many rows have no time before you trust a count.
Definitions. Official statistics count air transport movements, and each airport decides whether to include positioning flights, training circuits, helicopters, business jets and general aviation. ADS-B sees anything with a transponder. If you want something comparable to the official figure, filter by airline or type.
Attribution. The departure and arrival airports are inferred from the track. A go-around, a diversion or a circuit that never leaves the area can be attributed in surprising ways. Check a sample of flights by hand against their replayed tracks before you publish a number.
The sensible approach is to calibrate. Take a month where the airport has published its figure, compare your count with it, and quote your numbers with that ratio in mind. Comparisons within your own data, such as this Tuesday against last Tuesday, or one airline's share over time, hold up much better than absolute totals.
What people build with this
Capacity and peak-hour studies. Hourly and 15-minute profiles over a season, compared with declared capacity.
Airline network analysis. Track how a carrier's share at an airport changes as it adds or cuts routes, with real daily data rather than published schedules.
Recovery and disruption tracking. Count movements on a disrupted day against a normal one. The difference is the real cost of the disruption, and it often looks different from the cancellations count.
Benchmarks for your own data. If you run live airport dashboards, last year's same day is the baseline that makes today's number mean something.
The endpoint isn't a schedule and shouldn't be used as one. For what's departing in the next few hours, use schedules. Historical traffic is for what actually happened.
Historical ADS-B starts on the Pro plan. If you want to check the rest of the API first, the free trial covers the live endpoints.
