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")pip install skylink-api
pip install "skylink-api[pandas]" # optional: DataFrame conversion, see belowRequires Python 3.10+. Runtime dependencies: httpx and pydantic v2.
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...")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")| 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).
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.
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.
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.
| 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.
| 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".
| Method | Endpoint | Returns |
|---|---|---|
search(*, icao=None, iata=None) |
GET /airlines/search |
list[Airline] |
At least one of icao/iata is required (validated client-side).
| 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).
| 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".
| 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.
| 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.
| 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.
| 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.
| 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".
| 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.
| Method | Endpoint | Returns |
|---|---|---|
flight_time(*, origin, destination, aircraft=None) |
GET /ml/flight-time |
FlightTimePrediction |
origin/destination are serialised to the wire keys from/to.
| 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.
| 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}, )
| 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".
| 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.
| 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".
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.
| 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})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.
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.
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.
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.
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.
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() # unsubscribeTTL 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.
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.
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 tooto_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.
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)
raiseThe 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.
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 ..."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.
- 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"}},
)- 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.
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.pyFull API documentation: https://skylinkapi.com/docs.
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 testsTests 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/integrationPublishing 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).
MIT — see LICENSE.