SkyLink API — Python SDK
Official Python client for the SkyLink API — live ADS-B tracking, aviation weather, airports and navaids, aerodrome charts, NOTAMs, FAA delays, flight status, schedules, tickets, carbon estimates, AI pre-flight briefings and 90/365-day flight history.
Blocking and asyncio clients over the same surface, pydantic response models, retries with
jittered backoff, and a typed error hierarchy. Ships py.typed.
from skylink_api import SkyLink
with SkyLink() as sky: # RapidAPI, $RAPIDAPI_KEY
metar = sky.weather.metar("KJFK", parsed=True)
traffic = sky.adsb.aircraft(lat=51.47, lon=-0.46, radius=75)
status = sky.flight_status("BA117")
Install
pip install skylink-api
pip install "skylink-api[pandas]" # optional: DataFrame conversion, see below
Requires Python 3.10+. Runtime dependencies: httpx and pydantic v2.
Quickstart
RapidAPI channel (default)
SkyLink() targets https://skylink-api.p.rapidapi.com (no version prefix — the listing
is pinned to v3.1), sends X-RapidAPI-Key/X-RapidAPI-Host, and reads the key from
RAPIDAPI_KEY.
from skylink_api import SkyLink
with SkyLink() as sky: # $RAPIDAPI_KEY
metar = sky.weather.metar("KJFK")
print(metar.raw)
# or pass the key explicitly
sky = SkyLink(api_key="...msh...jsn...")
Direct channel
provider="direct" talks to https://data.skylinkapi.com/v3.1 with an x-api-key header
and reads SKYLINK_API_KEY. Everything else is identical — same methods, same models.
from skylink_api import SkyLink
with SkyLink(provider="direct") as sky: # $SKYLINK_API_KEY
charts = sky.charts.by_airport("EGLL")
API keys and environment variables
| Channel | Base URL | Auth header | Environment |
|---|---|---|---|
rapidapi (default) |
https://skylink-api.p.rapidapi.com |
X-RapidAPI-Key + X-RapidAPI-Host |
RAPIDAPI_KEY, then SKYLINK_API_KEY |
direct |
https://data.skylinkapi.com/v3.1 |
x-api-key |
SKYLINK_API_KEY |
The default channel accepts SKYLINK_API_KEY as a fallback, so a key exported under the
neutral name is picked up by a plain SkyLink(). The reverse never happens: a RapidAPI
subscription key is not valid on data.skylinkapi.com, so provider="direct" never reads
RAPIDAPI_KEY. A blank variable counts as unset, and a missing key raises
AuthenticationError at construction time (unless you pass base_url).
Async
AsyncSkyLink mirrors the sync surface method for method.
import asyncio
from skylink_api import AsyncSkyLink
async def main() -> None:
async with AsyncSkyLink() as sky:
metar, traffic = await asyncio.gather(
sky.weather.metar("EGLL"),
sky.adsb.aircraft(lat=51.47, lon=-0.46, radius=75),
)
print(metar.raw, traffic.total_count)
asyncio.run(main())
One client owns one connection pool — build it once and share it, rather than per request.
Use with / async with (or close() / await aclose()) so the pool is released.
Configuration
Every option is keyword-only and accepted by both clients.
| Option | Type | Default | Notes |
|---|---|---|---|
provider |
"rapidapi" | "direct" |
"rapidapi" |
Selects the base URL, the auth header and the key env var. |
api_key |
str | None |
$RAPIDAPI_KEY → $SKYLINK_API_KEY (rapidapi), $SKYLINK_API_KEY (direct) |
Missing key raises AuthenticationError at construction — unless base_url is set. |
base_url |
str | None |
provider default | Used verbatim; no version is appended. With it, a missing key is allowed (staging with DISABLE_AUTH=true). |
timeout |
float | httpx.Timeout | None |
connect 5s, read/write 30s, pool 5s | None disables timeouts entirely. |
max_retries |
int |
3 |
Applies to retryable statuses and transport failures. 0 disables. |
history_plan |
"ultra" | "mega" |
"ultra" |
Picks the /{plan}/history/... prefix; overridable per call. |
default_headers |
Mapping[str, str] | None |
{} |
Merged into every request (after auth, before per-call headers). |
http_client |
httpx.Client | httpx.AsyncClient | None |
new client | Bring your own for proxies or a custom transport. |
sleep |
callable | time.sleep / asyncio.sleep |
Backoff hook — injection point for tests. |
environ |
Mapping[str, str] | None |
os.environ |
Environment used for the key lookup. |
Introspection on the client: sky.api_key, sky.base_url, sky.provider,
sky.max_retries, sky.history_plan, sky.http_client, the fully resolved sky.config,
and sky.last_rate_limit.
Method index
Every method also accepts request_options: RequestOptions | None (see
Retries and timeouts). Async methods are identical, awaited.
Namespaces are lazy cached_property objects: building a client costs nothing.
The SDK-side namespaces — sky.batch, sky.poll, sky.compose — plus the page iterators,
the pure helpers, the response cache and the pandas bridge are documented under
Beyond the endpoints.
sky.weather
| Method | Endpoint | Returns |
|---|---|---|
metar(icao, *, parsed=False) |
GET /weather/metar/{icao} |
Metar, or MetarWithParsed when parsed=True |
taf(icao, *, parsed=False) |
GET /weather/taf/{icao} |
Taf, or TafWithParsed when parsed=True |
winds_aloft(*, bbox, forecast=12, level="low") |
GET /weather/winds-aloft |
WindsAloftResponse |
pireps(*, bbox, hours=2) |
GET /weather/pireps |
PirepsResponse |
airsigmet(*, bbox, type=None) |
GET /weather/airsigmet |
AirSigmetResponse |
forecast is 6 | 12 | 24, level is "low" | "high", type is "airmet" | "sigmet".
Winds aloft is a US-only product.
sky.airports
| Method | Endpoint | Returns |
|---|---|---|
search(*, icao=None, iata=None) |
GET /airports/search |
EnrichedAirport |
nearby(*, lat, lon, radius=50, type=None, limit=50) |
GET /airports/search/location |
AirportsByLocationResponse |
by_ip(*, ip=None, radius=100, type=None, limit=50) |
GET /airports/search/ip |
AirportsByIPResponse |
search_text(*, q, limit=20, type=None) |
GET /airports/search/text |
AirportsTextSearchResponse |
search needs exactly one of icao/iata (validated client-side). type filters on the
airport class: "large_airport", "medium_airport", "small_airport", "heliport",
"seaplane_base", "balloonport", "closed".
sky.airlines
| Method | Endpoint | Returns |
|---|---|---|
search(*, icao=None, iata=None) |
GET /airlines/search |
list[Airline] |
At least one of icao/iata is required (validated client-side).
sky.navaids
| Method | Endpoint | Returns |
|---|---|---|
list(*, ident=None, airport=None, type=None, country=None, bbox=None, limit=100) |
GET /navaids |
NavaidsResponse |
At least one filter is required (validated client-side, before the request goes out).
sky.geo
| Method | Endpoint | Returns |
|---|---|---|
countries(*, continent=None) |
GET /countries |
CountriesResponse |
country(code) |
GET /countries/{code} |
CountryDetail |
regions(*, country=None, continent=None) |
GET /regions |
RegionsResponse |
region(code) |
GET /regions/{code} |
RegionDetail |
continent is one of "AF" | "AN" | "AS" | "EU" | "NA" | "OC" | "SA".
sky.adsb
| Method | Endpoint | Returns |
|---|---|---|
aircraft(*, icao24=None, callsign=None, lat=None, lon=None, radius=None, bbox=None, min_alt=None, max_alt=None, min_speed=None, max_speed=None, registration=None, airline=None, photos=False, limit=None, offset=None) |
GET /adsb/aircraft |
AdsbAircraftList |
statistics() |
GET /adsb/aircraft/statistics |
AdsbStatistics |
health() |
GET /adsb/health |
AdsbHealth |
radius is in kilometres. lat/lon/radius must be supplied together. This is the
only paginated endpoint (limit/offset); total_count is the match count before paging.
sky.aircraft
| Method | Endpoint | Returns |
|---|---|---|
by_registration(registration, *, photos=True) |
GET /aircraft/registration/{registration} |
AircraftLookup |
by_icao24(icao24, *, photos=True) |
GET /aircraft/icao24/{icao24} |
AircraftLookup |
performance(icao_type) |
GET /aircraft/performance/{icao_type} |
AircraftPerformance |
database_stats() |
GET /aircraft/database/stats |
AircraftDatabaseStats |
An unknown airframe is a 200 with found=False and aircraft=None, not a 404.
sky.charts
| Method | Endpoint | Returns |
|---|---|---|
by_airport(icao, *, source=None) |
GET /charts/{icao} |
ChartsResponse |
by_category(icao, category, *, source=None) |
GET /charts/{icao}/{category} |
ChartsResponse |
sources() |
GET /charts/sources |
ChartSourcesResponse |
category is "GEN" | "GND" | "SID" | "STAR" | "APP". ChartsResponse.charts is a
dict[str, list[Chart]] keyed by category — a category with no charts is simply absent.
sky.delays
| Method | Endpoint | Returns |
|---|---|---|
faa(icao=None) |
GET /delays/faa or GET /delays/faa/{icao} |
FaaDelayResponse |
US airports only. Durations (avg_delay, max_delay) and times are opaque strings.
sky.notams
| Method | Endpoint | Returns |
|---|---|---|
by_airport(icao, *, exclude_qcode=None, exclude_scope=None, include_future=False) |
GET /notams/{icao} |
NotamsResponse |
exclude_qcode/exclude_scope take a CSV string or a sequence; scopes are "AERODROME"
and "FIR".
sky.schedules
| Method | Endpoint | Returns |
|---|---|---|
departures(*, icao=None, iata=None, date=None, time=None, ts=None) |
GET /schedules/departures |
DeparturesResponse |
arrivals(*, icao=None, iata=None, date=None, time=None, ts=None) |
GET /schedules/arrivals |
ArrivalsResponse |
Exactly one of icao/iata is required. date accepts date/datetime/str and is sent
as DD-MM-YYYY.
sky.ml
| Method | Endpoint | Returns |
|---|---|---|
flight_time(*, origin, destination, aircraft=None) |
GET /ml/flight-time |
FlightTimePrediction |
origin/destination are serialised to the wire keys from/to.
sky.carbon
| Method | Endpoint | Returns |
|---|---|---|
estimate(*, departure_icao=None, arrival_icao=None, callsign=None, aircraft_type=None, passengers=None, include_rfi=False) |
GET /carbon/estimate |
CarbonEstimate |
Either the airport pair or a callsign (which the API resolves to a route) is required.
sky.briefing
| Method | Endpoint | Returns |
|---|---|---|
flight(*, origin, destination, include_weather=True, include_notams=True, include_pireps=False, format="json") |
GET /briefing/flight |
FlightBriefing for format="json", otherwise str |
pdf(*, departure_icao, arrival_icao, flight_number=None) |
GET /briefing/pdf |
bytes |
format is "json" | "markdown" | "plain_text" | "html"; the overload makes the static type
follow the argument. Text formats arrive inside a JSON envelope which the SDK unwraps.
These are the slowest calls in the SDK. A briefing is composed by a language model over both airports' weather and NOTAMs: measured live on 2026-08-15,
flight()took 30–85 s andpdf()about 50 s. Both therefore run with their own 180 s read timeout (skylink_api._constants.BRIEFING_TIMEOUT) instead of the client-wide 30 s default — under that default a perfectly healthy request aborts, gets retried three times, and fails after two minutes. It is a default, not a ceiling: cap it yourself when a slow page is worse than no briefing.sky.briefing.flight( origin="KJFK", destination="KLAX", request_options={"timeout": 60.0, "max_retries": 0}, )
sky.routes
| Method | Endpoint | Returns |
|---|---|---|
by_callsign(callsign) |
GET /routes/callsign/{callsign} |
VrsRouteResult | AirlineRoutesResult |
by_airport(code, *, direction="both", limit=100) |
GET /routes/airport/{code} |
AirportRoutesResponse |
pairs(*, departure=None, arrival=None, limit=50) |
GET /routes/pairs |
RoutePairsResponse |
by_callsign returns a union discriminated by source: "vrs" (an exact route) or
"airline_routes" (the operator's network when the exact flight is unknown). direction is
"dep" | "arr" | "both".
sky.tickets
| Method | Endpoint | Returns |
|---|---|---|
search(*, origin, destination, date=None, passengers=1) |
GET /tickets/search |
TicketSearchResponse |
date accepts date/datetime/str and is sent as YYYY-MM-DD.
Offers are cheapest first and there is no small cap — a busy city pair returns 100+, so
slice before rendering. price_usd is the converted total; original_price and
original_currency carry the upstream quote (e.g. 137.0 CHF behind 168.52) and are how
you spot the case where conversion failed and price_usd is silently not USD.
sky.webhooks
| Method | Endpoint | Returns |
|---|---|---|
create(*, url, event_types, filters=None) |
POST /webhooks → 201 |
Webhook |
list() |
GET /webhooks |
list[WebhookSubscription] |
update(webhook_id, *, active) |
PATCH /webhooks/{id} |
WebhookToggleResponse |
delete(webhook_id) |
DELETE /webhooks/{id} → 204 |
None |
event_types() |
GET /webhooks/events |
list[str] |
event_types values: "status_changed", "flight_delayed", "flight_cancelled",
"flight_boarding", "flight_landed", "gate_changed".
sky.history
Every method takes plan: "ultra" | "mega" | None — per-call plan beats the client's
history_plan, which beats "ultra".
| Method | Endpoint | Returns |
|---|---|---|
flights(*, start=None, end=None, icao24=None, registration=None, callsign=None, departure_icao=None, arrival_icao=None, limit=None, plan=None) |
GET /{plan}/history/flights |
HistoryFlightsResponse |
flight(flight_id, *, plan=None) |
GET /{plan}/history/flight/{flight_id} |
HistoryFlight |
track(flight_id, *, limit=None, plan=None) |
GET /{plan}/history/flight/{flight_id}/track |
HistoryTrackResponse |
positions(ident, *, start=None, end=None, limit=None, plan=None) |
dispatches on ident |
HistoryPositionsResponse |
positions_by_icao24(icao24, *, start=None, end=None, limit=None, plan=None) |
GET /{plan}/history/positions/{icao24} |
HistoryPositionsResponse |
positions_by_registration(registration, *, start=None, end=None, limit=None, plan=None) |
GET /{plan}/history/positions/registration/{registration} |
HistoryPositionsResponse |
airport_traffic(icao, *, direction="both", start=None, end=None, limit=None, plan=None) |
GET /{plan}/history/airport/{icao}/traffic |
HistoryAirportTrafficResponse |
start/end accept date/datetime/str and are sent as ISO 8601. positions() treats a
6-hex-character ident as an ICAO24 address and anything else as a registration.
Client methods
| Method | Endpoint | Returns |
|---|---|---|
sky.flight_status(flight_number) |
GET /flight_status/{flight_number} |
FlightStatusResponse |
sky.distance(*, from_icao=None, to_icao=None, from_lat=None, from_lon=None, to_lat=None, to_lon=None, unit="nm") |
GET /distance |
DistanceResponse |
sky.request(method, path, *, query=None, json_body=None, headers=None, response_kind="json", cast_to=None, options=None) |
any | decoded payload |
These two endpoints have a single operation each, so they live directly on the client rather
than in a namespace. Each end of distance is given either as an airport code or as a
lat/lon pair, and the two styles mix freely; unit is "nm" | "km" | "mi".
request() is the escape hatch for anything this SDK does not model yet — same auth, retries
and error handling, with an optional pydantic cast_to:
raw = sky.request("GET", "/weather/metar/KJFK", query={"parsed": True})
Beyond the endpoints
Everything below is SDK-side: three extra namespaces that combine calls, iterators over the paged endpoints, a module of pure helpers, an opt-in cache and the pandas bridge. No new runtime dependency, and nothing here changes how a plain endpoint call behaves.
sky.batch — one call, many identifiers
The API is one-identifier-per-request. sky.batch fans that out with bounded concurrency
(default 5, because of marketplace quotas), collapses duplicates and returns a
{identifier: value | SkyLinkError} mapping — one bad code costs one value, not the batch.
from skylink_api import SkyLinkError
from skylink_api.helpers.batch import failures, successes
reports = sky.batch.metars(["EGLL", "KJFK", "ZZZZ"], concurrency=3)
for icao, report in reports.items(): # keys are your strings, in input order
if isinstance(report, SkyLinkError):
print(icao, "unavailable")
else:
print(icao, report.raw)
good, bad = successes(reports), failures(reports)
| Method | Per identifier | Value type |
|---|---|---|
batch.metars(icaos) |
GET /weather/metar/{icao} |
Metar |
batch.tafs(icaos) |
GET /weather/taf/{icao} |
Taf |
batch.notams(icaos) |
GET /notams/{icao} |
NotamsResponse |
batch.airports(codes) |
GET /airports/search |
EnrichedAirport |
batch.flight_statuses(numbers) |
GET /flight_status/{number} |
FlightStatusResponse |
All take concurrency=5 and request_options=None. batch.airports() picks icao= or
iata= from the shape of each code; OurAirports pseudo-codes (GB-0888) cannot be resolved
by that endpoint and land in the result as errors — filter them with
helpers.idents.is_local_pseudocode first. helpers.batch.raise_for_errors(results) turns
the first failure into an exception when a partial answer is not acceptable, and
helpers.batch.map_concurrent / amap_concurrent are the same primitive for your own calls.
sky.compose — the page, not the endpoint
An "airport page" is eight requests; a "flight page" is four. sky.compose issues them in
parallel and returns one dataclass. A part that fails is None and its error lands in
.errors[part] — the aggregate degrades, it does not raise. The single exception is the
primary request (airports.search for airport_brief, the flight status for flight_brief),
without which the result would be meaningless.
brief = sky.compose.airport_brief("EGLL", schedules_limit=5)
print(brief.metar.raw if brief.metar else "no observation")
print(brief.errors) # {'delays': NotFoundError(...)} — EGLL is not an FAA field
| Method | Returns | Notes |
|---|---|---|
compose.airport_brief(icao, *, include=None, exclude=None, schedules_limit=10) |
AirportBrief |
airport, metar, taf, notams, delays, charts, departures, arrivals |
compose.flight_brief(number, *, include=None, exclude=None) |
FlightBrief |
status → airframe → route → CO2 (a chain, not a fan-out) |
compose.route_brief(origin, destination, *, include=None, exclude=None, aircraft_type=None, passengers=None) |
RouteBrief |
distance, block time, both ends' weather, CO2 |
compose.enrich_adsb(states, *, concurrency=5, max_lookups=50, photos=False) |
list[EnrichedAircraft] |
joins live contacts with the airframe registry, memoised per icao24 |
compose.schedules_with_status(icao, *, direction="departures", limit=10, concurrency=5) |
list[ScheduleWithStatus] |
board rows plus each flight's live status |
compose.north_america_countries() |
list[Country] |
the 41 NA countries, tolerant of every spelling the API has used |
include= is the exact set of parts to request (so an unwanted part costs no quota),
exclude= subtracts from the full set; passing both is a ValueError, and the part names are
the result's own field names (AIRPORT_BRIEF_PARTS, FLIGHT_BRIEF_PARTS, ROUTE_BRIEF_PARTS
in skylink_api.resources.compose). A part that was never requested is None with no
entry in errors, so "not asked for" and "asked for and failed" stay distinguishable.
north_america_countries()was written becausegeo.countries(continent="NA")used to return nothing: the backend read its CSV with pandas, which parses the literalNAas not-a-number, so every North American country arrived withcontinent: null. That is fixed — as of 2026-08-15 the filter returns the 41 countries (andgeo.regions440 regions) directly, and that is the call to prefer. The method stays because it is public API and because it accepts the oldnull/""spellings as well as"NA", so it answers correctly against an older deployment; the price is a full ~250-row download.
Iterators and pollers
Paging and "ask again in a minute" are the two loops every integration writes by hand.
for aircraft in sky.adsb.iter_aircraft(bbox=box, page_size=100, max_items=500):
... # the only paginated endpoint; stops on a short page
for flight in sky.history.iter_flights(registration="G-STBA", window_days=7, max_items=50):
... # slices a long range into per-window requests, newest first
for diff in sky.poll.adsb(bbox=box, interval=10, max_iterations=6):
if diff.is_first:
draw_all(diff.snapshot.values())
continue
add(diff.appeared); remove(diff.disappeared); move(diff.updated)
for status in sky.poll.flight_status("BA117", interval=60):
print(status.status) # only when it changed; stops itself once the flight lands
- The first request goes out immediately;
intervalis the pause between requests. 429and5xxare survived (waiting outRetry-After) and count againstmax_iterations;401/403/422propagate — a wrong key never fixes itself.poll.adsbyields anAdsbDiff(appeared,disappearedasicao24strings,updated,snapshot,is_first); "updated" means position, altitude or ground speed moved —last_seenis deliberately ignored, or every aircraft would be updated on every tick.poll.flight_statuscompares status prose plus times, gates, terminals and the baggage belt, with""/"--"folded to "unknown"; terminal is a case-insensitive substring match on landed/arrived/cancelled/diverted. Pairuntil_terminal=Truewithmax_iterationsfor a flight number you do not trust — an unknown flight stays"Unknown"forever.sleep=is injectable on both, and the async client returnsAsyncIterators from the same method names.
skylink_api.helpers — pure functions, no client
from skylink_api import helpers
from skylink_api.helpers.geojson import adsb_to_geojson
from skylink_api.helpers.weather import flight_category
box = helpers.bbox_around(51.4706, -0.4619, radius_km=60)
live = sky.adsb.aircraft(bbox=box)
layer = adsb_to_geojson(live) # [lon, lat], per RFC 7946
category = flight_category(sky.weather.metar("EGLL", parsed=True)) # 'VFR' | 'MVFR' | ...
| Module | Contents |
|---|---|
helpers.units |
ft_to_m, kt_to_kmh, inhg_to_hpa, c_to_f, … plus normalize_altimeter (the API sends pressure without a unit), parse_visibility ("P6SM", "M1/4SM", 9999), parse_duration_minutes/parse_duration ("7h 23m"), humidity_to_percent |
helpers.spatial |
bbox, bbox_around, parse_bbox, haversine_km/_nm, initial_bearing, destination_point, great_circle_points, track_stats, simplify_track, point_coords |
helpers.weather |
flight_category, ceiling_ft, metar_age, is_stale, wind_components (head/tail and crosswind for a runway) |
helpers.geojson |
adsb_to_geojson, track_to_geojson, airports_to_geojson, navaids_to_geojson — plain TypedDicts, always [longitude, latitude] |
helpers.idents |
classify_airport_code, is_local_pseudocode, is_icao24, normalize_icao24, normalize_registration, split_flight_number |
helpers.sentinels |
is_found/require_found, has_results/require_results, require_ip_result — turn the 200-with-a-sentinel answers into exceptions where a miss is fatal |
helpers.batch |
map_concurrent, amap_concurrent, successes, failures, raise_for_errors |
helpers.cache |
MemoryCache, CacheProtocol — see below |
Every converter accepts str | float | int | None (the API serves numbers as strings often
enough) and returns None rather than raising on input it cannot read.
Response cache and quota hooks
The cache is off by default, and a bare MemoryCache() is inert: TTLs are opt-in per
operation. Only successful GETs are cached, keyed by
provider | base_url | METHOD path?sorted-query, and a hit re-validates the stored payload,
so a caller who mutates a returned model cannot corrupt the next one's copy.
from skylink_api import MemoryCache, SkyLink
cache = MemoryCache(ttls={"weather.metar": 60, "airports.*": 3600, "geo.*": 86_400})
with SkyLink(cache=cache) as sky: # "adsb.aircraft" left out on purpose
sky.weather.metar("EGLL") # network
sky.weather.metar("EGLL") # cache
stop = sky.on_rate_limit(lambda info: print(info.remaining, "of", info.limit))
sky.on_quota_low(warn, threshold=0.1) # edge-triggered: fires once per window
stop() # unsubscribe
TTL lookup is by exact operation name, then by namespace prefix ("weather.*"), then
default_ttl; 0 means "do not cache". Any store with get(key)/set(key, value, ttl)
satisfies CacheProtocol (Redis, diskcache, …), and a cache that raises is degraded to no
cache with a RuntimeWarning rather than failing the request. on_rate_limit receives the
snapshot of that response (unlike last_rate_limit, which is last-writer-wins under
concurrency); a hook that raises is reported as a warning and never breaks the call.
from_env and with_options
sky = SkyLink.from_env(provider="direct") # key can only come from the environment
patient = sky.with_options(timeout=120.0, max_retries=0)
archive = sky.with_options(history_plan="mega")
with_options reuses the same httpx client — no second connection pool — copies the
registered hooks as a snapshot and shares the cache unless you pass cache=. Ownership of the
transport stays with the original: keep it alive for as long as any clone is in use.
pandas
from skylink_api.pandas_ext import to_dataframe # pip install "skylink-api[pandas]"
frame = to_dataframe(sky.adsb.aircraft(bbox=box)) # rows from the "aircraft" field
frame = to_dataframe(sky.schedules.departures("EGLL")) # ... "flights"
frame = to_dataframe(sky.history.track(flight_id)) # ... "positions"
frame = to_dataframe(page.aircraft) # a bare list works too
to_dataframe is a free function, not a model method: pandas stays out of the SDK's own type
annotations and is imported on first call. It unwraps the list-shaped envelopes
(aircraft, positions, flights, navaids, countries, regions, airports, notams,
reports, stations, routes, sources — pandas_ext.LIST_FIELDS), a bare list of models
or a bare list of dicts; field="flights" picks the other list on a response that carries
two. Nothing is coerced on the way in, so the string-typed columns described under
Gotchas stay strings. Without pandas installed the call raises ImportError
naming the extra.
Error handling
SkyLinkError
├── APIConnectionError transport failed, no HTTP response
│ └── APITimeoutError connect/read/write timeout
├── APIResponseValidationError 2xx body did not match the model (.body keeps the payload)
└── APIStatusError non-2xx (.status_code, .headers, .body, .code, .errors)
├── BadRequestError 400
├── AuthenticationError 401 — also raised at construction with no key
├── PermissionDeniedError 403 — plan does not cover this call
├── NotFoundError 404
├── UnprocessableEntityError 422 — .errors holds the per-field items
├── RateLimitError 429 — .retry_after, .rate_limit
└── InternalServerError 500 and other 5xx
└── ServiceUnavailableError 503 — an upstream source is not ready
from skylink_api import APIStatusError, NotFoundError, RateLimitError, SkyLink
with SkyLink() as sky:
try:
metar = sky.weather.metar("ZZZZ")
except NotFoundError:
metar = None
except RateLimitError as err:
print(err.retry_after, err.rate_limit)
raise
except APIStatusError as err:
print(err.status_code, err.message, err.body)
raise
Three body shapes, one message
The API answers errors in three formats and the SDK normalises all of them into .message:
| Shape | Example | Exposed as |
|---|---|---|
| Gateway 401 | {"error": "Unauthorized", "message": "...", "code": "MARKETPLACE_ACCESS_REQUIRED"} |
.message, .code |
HTTPException |
{"detail": "Airport not found"} |
.message |
| Validation 422 | {"detail": [{"loc": [...], "msg": "...", "type": "..."}]} |
.message (joined) + .errors |
Anything else (HTML from a proxy, an empty body) degrades to HTTP <status> with the raw
payload kept on .body.
"Not found" that is not an error
Three endpoints report absence with a 200 and a sentinel field. These are typed values, not
exceptions:
lookup = sky.aircraft.by_registration("N0000X")
if not lookup.found: # aircraft is None
...
result = sky.airports.by_ip()
if result.error: # IP geolocation failed
...
flights = sky.history.flights(registration="G-STBA")
if flights.count == 0:
print(flights.note) # "Registration 'G-ZZZZ' not found ..."
Quota
sky.last_rate_limit holds a RateLimitInfo(limit, remaining, reset) parsed from the quota
headers of the most recent response. Both channels are understood: RapidAPI sends
X-RateLimit-Requests-* (the plan's request quota, which wins over the marketplace's noisier
X-RateLimit-rapid-free-plans-hard-limit-* counters), the direct gateway sends
X-RateLimit-*. It stays None when the response carried no quota headers at all — for
example against a staging instance behind neither gateway. On a 429 the same snapshot is on
RateLimitError.rate_limit.
Retries and timeouts
- Retried:
429,500,502,503,504, plus connection and timeout failures. - Never retried:
400,401,403,404,422. POSTis replayed on429only — a throttled request provably never reached the handler, soPOST /webhookscannot be duplicated by a retry. After a 5xx or a transport error it is not replayed.- Backoff: full jitter,
random() * min(8s, 0.5s * 2 ** attempt). ARetry-Afterheader (delta-seconds or HTTP-date) wins and is capped at 60s. - Default
max_retriesis 3, so a failing call is attempted at most 4 times. - Default timeout: connect 5s, read 30s, write 30s, pool 5s.
Both are overridable per call through request_options, alongside extra headers and query
parameters:
metar = sky.weather.metar(
"KJFK",
request_options={"timeout": 5.0, "max_retries": 0, "headers": {"X-Trace-Id": "abc"}},
)
Gotchas
- Schedules use PascalCase on the wire.
ScheduleFlightkeys arrive asTime,Date,IATA,Flight,Airline,Status,Destination/Origin; the model exposes them as snake_case attributes through aliases.flight.destination, notflight["Destination"]. - Opaque time strings stay strings.
flight_statustimes, NOTAMeffective/expirationand the FAA delay durations are scraped in whatever format the source used ("14:25","--","","1 hour 30 minutes"). The SDK never guesses adatetimefor them. Genuine ISO 8601 fields (ADS-Blast_seen, history timestamps, webhookcreated_at) are parsed intodatetime. ml.flight_timerenames its arguments. You passorigin=/destination=; the wire keys arefrom/to(reserved words in Python, valid on the query string).ultravsmegais a path, not a parameter./ultra/history/...covers 90 days (1 000 flights / 5 000 positions per call),/mega/history/...covers 365 days (2 000 / 10 000). Calling a plan the key is not subscribed to is a403.briefing.pdf()returnsbytes. It is the only non-JSON endpoint — write it out withpathlib.Path("brief.pdf").write_bytes(pdf).Airline.activeis a string."Y"or"N", not a bool. Same policy elsewhere: values the upstream serves as strings stay strings —Navaid.frequency_khz,AirportFrequency.frequency_mhz,AircraftDetails.year_built.bboxis(lat1, lon1, lat2, lon2), south-west corner first, serialised to one comma separated string. Boxes withlat1 >= lat2orlon1 >= lon2are rejected with a 400.photosdefaults differ.Falseonadsb.aircraft()(slow, only covers the first 50 rows of a page),Trueon theaircraft.by_*()lookups.- Unknown fields survive. Every model allows extras, so a field the upstream adds
tomorrow is preserved on the instance instead of raising. Responses are validated
best-effort; a shape change that breaks a declared field raises
APIResponseValidationErrorwith the raw payload on.body. - Some calls are validated before sending —
navaids.list(),airlines.search(),airports.search(),carbon.estimate()andschedules.*raiseValueErroron a missing or ambiguous selector instead of spending a round trip on a guaranteed 400.
Examples
Runnable scripts in examples/:
| File | Shows |
|---|---|
weather.py |
METAR raw and decoded, TAF, winds aloft by bounding box |
adsb_tracking.py |
Live traffic in a radius, paging, last_rate_limit, feed statistics and health |
flight_briefing.py |
Structured vs markdown briefing (the format overload), saving the PDF |
history.py |
Archived flights, track, both position lookups, the mega plan |
webhooks.py |
Full create → list → update → delete cycle plus event_types() |
async_usage.py |
AsyncSkyLink with asyncio.gather fan-out |
batch_requests.py |
sky.batch over many identifiers, reading successes and failures |
airport_brief.py |
sky.compose.airport_brief / route_brief, include=, and printing errors |
polling.py |
poll.adsb diffs, poll.flight_status until landed, iter_aircraft |
map_export.py |
bbox_around, flight_category, wind_components, GeoJSON layers written to disk |
cache_and_quota.py |
MemoryCache TTLs, on_rate_limit/on_quota_low, from_env, with_options |
export RAPIDAPI_KEY=...msh...jsn... # or SKYLINK_API_KEY with provider="direct"
python examples/weather.py
Full API documentation: https://skylinkapi.com/docs.
Contributing
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
pytest # unit suite, no network
mypy src/skylink_api # strict
ruff check src tests
ruff format src tests
Tests never touch the network: HTTP is mocked with respx and backoff sleeps are injected.
Fixtures under tests/fixtures/ are extracted from the backend routers — see
tests/fixtures/SOURCES.md for the file:line provenance of each one.
Integration tests are gated on an environment variable and skipped otherwise:
SKYLINK_TEST_API_KEY=...msh...jsn... pytest tests/integration # RapidAPI (default)
SKYLINK_TEST_BASE_URL=http://localhost:8081/v3.1 pytest tests/integration
SKYLINK_TEST_PROVIDER=direct SKYLINK_TEST_API_KEY=sk_live_... pytest tests/integration
Publishing is automated: pushing a v* tag builds the distribution and uploads it to PyPI via
trusted publishing (the publisher must be configured on PyPI for this repository first).
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file skylink_api-0.1.0.tar.gz.
File metadata
- Download URL: skylink_api-0.1.0.tar.gz
- Upload date:
- Size: 355.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b83926b9e9aa4f31614c1a76f814403016904f878bc49413ed215cb742822594
|
|
| MD5 |
509a3ad08b271818ec29c03541ad4877
|
|
| BLAKE2b-256 |
3021008ea3666478e1900514c4d84768b72f46910529810992598920b0fe1c1e
|
Provenance
The following attestation bundles were made for skylink_api-0.1.0.tar.gz:
Publisher:
publish.yml on SkyLink-API/SkyLink-API-Python-SDK
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
skylink_api-0.1.0.tar.gz -
Subject digest:
b83926b9e9aa4f31614c1a76f814403016904f878bc49413ed215cb742822594 - Sigstore transparency entry: 2665873383
- Sigstore integration time:
-
Permalink:
SkyLink-API/SkyLink-API-Python-SDK@b54946eeba8850619ecf18fc0f1173a2d413ce56 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/SkyLink-API
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b54946eeba8850619ecf18fc0f1173a2d413ce56 -
Trigger Event:
push
-
Statement type:
File details
Details for the file skylink_api-0.1.0-py3-none-any.whl.
File metadata
- Download URL: skylink_api-0.1.0-py3-none-any.whl
- Upload date:
- Size: 226.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c345c474e3285a9139ef3c921ebd7115a9678e294b770ed5b17374b666e466a
|
|
| MD5 |
5c00ff9bc3478ba0410efbf7d9c87a6b
|
|
| BLAKE2b-256 |
14ce00004a01e151b3a6d4084e6e7aa9048445dae451abcf40dc28660f31f8a6
|
Provenance
The following attestation bundles were made for skylink_api-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on SkyLink-API/SkyLink-API-Python-SDK
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
skylink_api-0.1.0-py3-none-any.whl -
Subject digest:
6c345c474e3285a9139ef3c921ebd7115a9678e294b770ed5b17374b666e466a - Sigstore transparency entry: 2665873406
- Sigstore integration time:
-
Permalink:
SkyLink-API/SkyLink-API-Python-SDK@b54946eeba8850619ecf18fc0f1173a2d413ce56 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/SkyLink-API
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@b54946eeba8850619ecf18fc0f1173a2d413ce56 -
Trigger Event:
push
-
Statement type: