API reference

One endpoint, one response shape.

Authenticate with a key, pass a flight number and a date, read the resolved record. Everything below is generated from the route handlers themselves.

Base URL

https://flight-api.dev

All timestamps are ISO 8601 in UTC. Responses are always JSON and always carry a boolean success discriminator.

Machine-readable

Every artifact below is generated from the same source as this page, so it cannot describe a different API.

/openapi.json

Every path, parameter, schema and status code. Feed it to a client generator or an agent toolchain.

OpenAPI 3.1Download
/api/docs

The whole reference as one structured document, including the worked examples on this page.

/docs.md

The same reference as a single file, readable start to finish.

MarkdownDownload
/llms.txt

A short, link-first index for agents — start here and follow the links.

llms.txt
Give an agent the docs

No credential is needed to read any of these. Point an MCP client with fetch capability at the index and it can walk the rest itself.

mcp.json
{
  "mcpServers": {
    "fetch": {
      "command": "uvx",
      "args": [
        "mcp-server-fetch"
      ]
    }
  }
}

Then: “read https://flight-api.dev/llms.txt and use it to call the flight status endpoint”.

Authentication

Keys are hashed with SHA-256 and compared in constant time. The plaintext is shown once, at creation.

AuthorizationBearer flt_…Preferred.
X-Api-Keyflt_…Equivalent.
X-Flight-Api-Keyflt_…Legacy alias, still accepted.

Every key is prefixed flt_. Requests without a credential get a 401; a disabled key or account gets 403; an account with no active plan gets 402. Manage keys from the dashboard.

Metering

One credit per authorised request. The rules below are the whole contract — there is nothing else to model.

When it counts

At authorisation, before the resolve runs. A miss, a cache replay and a full live resolve all cost exactly one credit.

What does not count

Rejected credentials (401), disabled keys and accounts (403), GET /api/v1/account — deliberately unmetered so checking a balance never moves it — and GET /api/v1/airports, which is a static catalogue. Webhook deliveries are never metered either: you are billed for lookups, not for being told about a flight.

Validation failures

A 400 still costs a credit — the key was already authorised. The usage log records it against the same key so the ledger and the meter agree.

At the ceiling

Without overage the next request is refused with 429 and no credit is taken. With overage enabled the request is served and billed at the plan rate.

Windows

Allowances run in 30-day windows that roll from the previous window, not from the first request of the month. Unused credits do not carry over.

Billing the excess

Overage is metered as it happens and invoiced in arrears: one invoice at the start of each month covering the month before, charged automatically. A month without overage is not charged. The plan fee bills separately on its own cycle.

Response headers
X-Credits-IncludedintegerRequests included in the current window. `unlimited` on internal accounts.
X-Credits-UsedintegerRequests consumed so far in the current window, including this one.
X-Credits-RemainingintegerRequests left before the ceiling. Reaches zero one request before a 429.
X-Credits-OverageintegerRequests taken beyond the included allowance. Zero unless overage is enabled.
X-Credits-Period-Endstring (ISO 8601)When the current window closes and the allowance resets.
GET/api/v1/flights/statusAPI key

Resolve a historical flight

Resolves one flight on one date and returns the selected record. Settled disruptions are replayed from storage.

Query parameters
flightNumberstring (2–12)Required

IATA flight designator. Trimmed and upper-cased server-side.

aliases: flight, number

flightDateYYYY-MM-DDRequired

Scheduled departure date in UTC. A full ISO timestamp is truncated to the date.

aliases: date

originIATA (3 letters)

Disambiguates codeshares and repeated flight numbers.

aliases: originIata, from

destinationIATA (3 letters)

Disambiguates codeshares and repeated flight numbers.

aliases: destinationIata, dest, to

refreshboolean flag

Skip the persisted record and resolve the flight again from scratch.

screenshotboolean flag

Attach a rendered proof image of the record as base64. Never persisted.

aliases: includeScreenshot

Boolean flags accept "1" · "0" · "true" · "false" · "yes" · "no".

curl
curl -sG "https://flight-api.dev/api/v1/flights/status" \
  -H "Authorization: Bearer $FLIGHT_API_KEY" \
  --data-urlencode "flightNumber=BA249" \
  --data-urlencode "flightDate=2026-08-14"
TypeScript
const params = new URLSearchParams({
  flightNumber: 'BA249',
  flightDate: '2026-08-14',
})

const response = await fetch(`https://flight-api.dev/api/v1/flights/status?${params}`, {
  headers: { Authorization: `Bearer ${process.env.FLIGHT_API_KEY}` },
})

const { success, data, message } = await response.json()
if (!success) throw new Error(message)

// data.selected is the resolved record.
console.log(data.selected?.status, data.selected?.delayMinutes, data.meta.cached)

Response

A 200 always means the resolve completed — it does not guarantee the flight was found.

Top level (data)
flightNumber
string
Echoed back, normalised to upper case.
flightDate
string
Echoed back as `YYYY-MM-DD`.
originIata
string | null
Resolved departure airport.
destinationIata
string | null
Resolved arrival airport.
selected
object | null
The normalised record, including on-time flights. Null only when nothing was found.
flightDelay
object | null
Delay and disruption verdict with its rationale, when timings were available.
meta
object
Timing, cache and persistence state.
data.selected
found
boolean
The flight was matched.
complete
boolean
Enough timings were present to settle the record.
status
'delayed' | 'cancelled' | 'diverted' | 'on_time' | 'not_found'
Classified outcome.
isDelayed
boolean
Arrival delay greater than zero.
isCancelled
boolean
Flight never operated.
isDiverted
boolean
Landed somewhere other than the destination.
delayMinutes
number | null
Arrival delay in minutes.
departureDelayMinutes
number | null
Departure delay in minutes.
scheduledDeparture
string | null
ISO 8601, UTC.
scheduledArrival
string | null
ISO 8601, UTC.
estimatedDeparture
string | null
ISO 8601, UTC.
estimatedArrival
string | null
ISO 8601, UTC.
actualDeparture
string | null
ISO 8601, UTC.
actualArrival
string | null
ISO 8601, UTC.
airline
object | null
`name`, `iata`, `icao`.
aircraft
object | null
`registration`, `iata`, `icao`, `icao24`, `label`.
flightIata
string | null
Normalised IATA designator.
flightIcao
string | null
Normalised ICAO designator.
statusRaw
string | null
Raw status string, unmodified.
data.meta
durationMs
number
Wall clock for the whole resolve. `0` on a cache hit.
startedAt
string
ISO 8601, UTC.
finishedAt
string
ISO 8601, UTC.
cached
boolean
Served from the persisted record instead of a live resolve.
persisted
boolean
This response was written to the record table.
recordId
string | null
Always null on the public endpoint.
200 — cached disruption
{
  "success": true,
  "data": {
    "flightNumber": "BA249",
    "flightDate": "2026-08-14",
    "originIata": "LHR",
    "destinationIata": "GRU",
    "selected": {
      "found": true,
      "complete": true,
      "status": "delayed",
      "isDelayed": true,
      "isCancelled": false,
      "isDiverted": false,
      "delayMinutes": 47,
      "departureDelayMinutes": 39,
      "originIata": "LHR",
      "destinationIata": "GRU",
      "scheduledDeparture": "2026-08-14T21:40:00.000Z",
      "scheduledArrival": "2026-08-15T05:05:00.000Z",
      "estimatedDeparture": null,
      "estimatedArrival": null,
      "actualDeparture": "2026-08-14T22:19:00.000Z",
      "actualArrival": "2026-08-15T05:52:00.000Z",
      "airline": {
        "name": "British Airways",
        "iata": "BA",
        "icao": "BAW"
      },
      "aircraft": {
        "registration": "G-ZBKF",
        "iata": "789",
        "icao": "B789",
        "icao24": null,
        "label": "Boeing 787-9"
      },
      "flightIata": "BA249",
      "flightIcao": "BAW249",
      "source": null,
      "statusRaw": "Landed 05:52"
    },
    "flightDelay": {
      "delayMinutes": 47,
      "delayFormatted": "47 min late",
      "statusKind": "landed",
      "disruption": {
        "type": "delay",
        "isCancelled": false,
        "isDiverted": false,
        "neverArrived": false,
        "arrivalDelayMinutes": 47,
        "departureDelayMinutes": 39,
        "confidence": "high",
        "sources": [
          "ata_vs_sta"
        ],
        "rationale": [
          "Actual arrival 47 min after scheduled arrival"
        ]
      }
    },
    "meta": {
      "durationMs": 0,
      "startedAt": "2026-08-24T09:12:04.881Z",
      "finishedAt": "2026-08-24T09:12:04.881Z",
      "source": null,
      "cached": true,
      "persisted": true,
      "recordId": null
    },
    "sources": []
  }
}

Caching

Only outcomes that can no longer change are frozen, so a cached reply is never stale.

Persisted

Delayed, cancelled or diverted, complete, and departing more than three UTC days ago. Stored unique on flight number and date, then replayed with meta.cached true and durationMs 0.

Never persisted

On-time and not-found results, anything inside the three-day settle window, and screenshots.

refresh=1

Bypasses the stored record and resolves from scratch. The fresh answer overwrites the row in place when it is still persistable.

Errors

Failures carry a human-readable message; validation errors name the field.

Status codes
200
Resolved. `data.selected` may still be null when the flight was not found.
400
Query validation failed. `message` names the offending field.
401
Missing or invalid API key.
402
No active plan on the account, or the subscription is canceled or past due. No credit is taken.
403
Key or account disabled.
429
Request allowance for the current window is exhausted and overage is not enabled. No credit is taken.
500
Resolve failed. Safe to retry.
400 — validation
{
  "success": false,
  "message": "flightDate: flightDate must be YYYY-MM-DD"
}
GET/api/v1/airportsAPI key

List the airports you can subscribe to

The airports whose arrival and departure boards we poll, and therefore the only values a webhook subscription accepts. Unmetered — this call costs nothing.

These are the boards we actually poll, so they are the only values a webhook subscription accepts. Read it at startup rather than hard-coding the list — it grows.

IATAAirportCityCountryTimezone
OTPBucharest OtopeniBucharestRomaniaEurope/Bucharest
CLJCluj-NapocaCluj-NapocaRomaniaEurope/Bucharest
RMOChișinăuChișinăuMoldovaEurope/Chisinau
TIATiranaTiranaAlbaniaEurope/Tirane
BUDBudapestBudapestHungaryEurope/Budapest
MLAMaltaLuqaMaltaEurope/Malta
BCMBacăuBacăuRomaniaEurope/Bucharest
VIEViennaViennaAustriaEurope/Vienna
CDGParis Charles de GaulleParisFranceEurope/Paris
LTNLondon LutonLondonUnited KingdomEurope/London
VCEVenice Marco PoloVeniceItalyEurope/Rome
BCNBarcelona El PratBarcelonaSpainEurope/Madrid
CPHCopenhagenCopenhagenDenmarkEurope/Copenhagen
BHXBirminghamBirminghamUnited KingdomEurope/London
FCORome FiumicinoRomeItalyEurope/Rome
ATHAthensAthensGreeceEurope/Athens
MANManchesterManchesterUnited KingdomEurope/London
ORYParis OrlyParisFranceEurope/Paris
WROWrocławWrocławPolandEurope/Warsaw
BSLBasel Mulhouse EuroAirportBasel/MulhouseFranceEurope/Paris
BLQBolognaBolognaItalyEurope/Rome
ALCAlicanteAlicanteSpainEurope/Madrid
LGWLondon GatwickLondonUnited KingdomEurope/London
MXPMilan MalpensaMilanItalyEurope/Rome
BRIBariBariItalyEurope/Rome
NAPNaplesNaplesItalyEurope/Rome
MADMadrid BarajasMadridSpainEurope/Madrid
VLCValenciaValenciaSpainEurope/Madrid
ARNStockholm ArlandaStockholmSwedenEurope/Stockholm
BGYMilan BergamoBergamoItalyEurope/Rome
NCENice Côte d’AzurNiceFranceEurope/Paris
SKPSkopjeSkopjeNorth MacedoniaEurope/Skopje
LISLisbonLisbonPortugalEurope/Lisbon
CRLBrussels South CharleroiCharleroiBelgiumEurope/Brussels
CTACatania FontanarossaCataniaItalyEurope/Rome
AGPMálagaMálagaSpainEurope/Madrid
BTSBratislavaBratislavaSlovakiaEurope/Bratislava
KRKKrakówKrakówPolandEurope/Warsaw
KTWKatowiceKatowicePolandEurope/Warsaw
BRUBrusselsBrusselsBelgiumEurope/Brussels
LCALarnacaLarnacaCyprusAsia/Nicosia
WAWWarsaw ChopinWarsawPolandEurope/Warsaw
CPOCopiapó Desierto de AtacamaCopiapóChileAmerica/Santiago
HRKKharkivKharkivUkraineEurope/Kyiv
LPLLiverpool John LennonLiverpoolUnited KingdomEurope/London
OSLOslo GardermoenOsloNorwayEurope/Oslo
WMIWarsaw ModlinWarsawPolandEurope/Warsaw
LBALeeds BradfordLeedsUnited KingdomEurope/London
POZPoznań ŁawicaPoznańPolandEurope/Warsaw
PSAPisa Galileo GalileiPisaItalyEurope/Rome
CIARome CiampinoRomeItalyEurope/Rome
STNLondon StanstedLondonUnited KingdomEurope/London
curl
curl -sS "https://flight-api.dev/api/v1/airports" \
  -H "Authorization: Bearer $FLIGHT_API_KEY"
POSTyour endpointSigned

Airport board events

The push side of the API. Register an https endpoint against one or more of the airports above and every flight observed on their boards is POSTed to you — new rows, delays, cancellations, diversions. Boards are read every 30 minutes and queued events go out within 2. Deliveries never touch the credit meter. Register from the dashboard, or read the full walkthrough.

Event types
flight.trackedhigh volume

A schedule row appeared on a watched arrival or departure board for the first time. One per flight per direction — this is the event that carries every newly fetched flight, on time included.

flight.delayedmedium volume

The flight crossed from on time into delayed. Fires on the transition, not on every re-estimate.

flight.cancelledlow volume

The board now reports the flight as cancelled. Fires once.

flight.divertedlow volume

The flight is landing somewhere other than its filed destination. `data.flight.divertedTo` carries the airport when the board names one.

flight.recoveredlow volume

A flight that was delayed, cancelled or diverted is back to on time. Rare, and worth handling — it is how a false-positive disruption unwinds.

flight.delay_changedhigh volume

The estimated time shifted by at least a whole minute. Subscribe to this only if you track a delay as it grows; `flight.delayed` alone is enough to know a flight is late.

flight.status_changedhigh volume

The board's own status text changed — "Scheduled" to "Estimated 14:35" to "Landed 14:41". Highest volume of the set.

flight.divert_updatedlow volume

The diversion airport was set, changed or cleared.

flight.qualifyinglow volume

A passenger flight crossed into compensation territory: a departure cancelled, diverted or delayed 3h+, or an arrival delayed 3h+. Cargo, military and business-jet movements never fire, nor do cancelled arrivals — their passengers are not at this airport. Once per flight per board. `data.qualification` carries the reason and the estimated compensation.

flight.predicted_qualifyinglow volume

The aircraft due to operate this departure is running late on an earlier leg, far enough that it cannot turn around in time to leave less than 3h late — usually before the airline or airport publishes a delay. `data.prediction` carries the tail, the inbound leg and the projected delay. Once per flight; follow up with `flight.qualifying` when the board confirms it.

Request headers
X-Flight-SignatureHMAC-SHA256 over `{timestamp}.{body}`, as `t=<unix>,v1=<hex>`.
X-Flight-EventBoard event id. Shared by every subscriber to it.
X-Flight-Event-TypeSame value as `type` in the body.
X-Flight-DeliveryDelivery id — unique per endpoint. Same value as `id` in the body.
X-Flight-WebhookThe endpoint that was configured to receive this.
X-Flight-Attempt1 on the first try, incrementing on each retry.
Event
id
string
Delivery id. Unique per endpoint — use it as your idempotency key.
event
string
Board event id. Identical across every endpoint that received this change.
type
string
One of the event types below.
createdAt
string
When the change was observed. ISO 8601, UTC.
webhook
object
The subscription that matched: `id`, `name`, and the `airports` it selected.
airport
object
The watched airport this board belongs to: `iata`, `name`, `city`, `country`, `timezone`.
data
object
The flight and its previous observation. `flight.qualifying` adds `qualification`; `flight.predicted_qualifying` adds `prediction`.
data.flight
id
string
Stable across every event for this flight on this board.
flightNumber
string
IATA designator as the board prints it.
callsign
string | null
ATC callsign, when the board carries one.
direction
'departure' | 'arrival'
Which board of the watched airport this row came from.
airline
object
`name` from the board, `iata` from the designator.
aircraftType
string | null
ICAO type code, e.g. `B738`.
registration
string | null
Tail number, once the aircraft has been looked up. Set on most qualifying and predicted flights.
operation
'passenger' | 'cargo' | 'military' | 'private'
Freighters, air-force movements and business jets share the board with airliners. Only `passenger` flights can owe EU261 compensation — filter on it if you consume the full stream.
origin
object
`{ iata }`. Equals `airport.iata` on a departure. `iata` is null when the board does not name the other end.
destination
object
`{ iata }`. Equals `airport.iata` on an arrival. `iata` is null when the board does not name the other end.
divertedTo
object | null
`{ iata }` when the board names a diversion airport.
scheduledAt
string
ISO 8601, UTC.
estimatedAt
string | null
ISO 8601, UTC. Null until the board estimates.
delayMinutes
number | null
Estimated minus scheduled, in whole minutes. Negative when early.
status
string
The board's own status text, unmodified.
disruption
'scheduled' | 'on_time' | 'delayed' | 'cancelled' | 'diverted'
Normalised verdict. `scheduled` means the board has no estimate yet, so nothing can be claimed.
isCancelled
boolean
The board reports the flight as cancelled.
isDiverted
boolean
The board reports a diversion.
runway
string | null
When published.
firstSeenAt
string
When this row first appeared. ISO 8601, UTC.
lastSeenAt
string
When it was last observed. Use it to discard an out-of-order delivery.
data.previous
status
string | null
Board status text before the change.
estimatedAt
string | null
ISO 8601, UTC.
delayMinutes
number | null
Delay before the change.
disruption
string | null
Verdict before the change.
data.qualification
reason
'cancelled' | 'diverted' | 'delayed'
Arrivals only ever qualify as `delayed` — a cancelled arrival’s passengers never reach this airport.
delayMinutes
number | null
The delay that crossed the line. Null for a cancellation.
detectedAt
string
When the board first showed it. ISO 8601, UTC.
minutesBeforeScheduled
number
Scheduled time minus detection. Positive means the team still has that long before the flight was due.
compensation
object | null
`{ amountEur, distanceKm, band }` — the EU261 amount for the route and delay. Null when either end of the route is unknown.
data.prediction
registration
string
The tail due to operate this departure.
projectedDepartureAt
string
Earliest the aircraft can realistically leave. ISO 8601, UTC.
projectedDelayMinutes
number
Projected departure minus scheduled.
confidence
'high' | 'medium'
`high` when the inbound is airborne or has a published estimate. `medium` when it simply has not left yet — held to a 15-minute higher bar.
minTurnaroundMinutes
number
Ground time assumed for this airframe class — the low end, so projections err short.
inbound
object
`{ flightNumber, origin: { iata }, scheduledArrivalAt, projectedArrivalAt, status }` — the leg the aircraft is flying in on.
detectedAt
string
When the prediction was made. ISO 8601, UTC.
minutesBeforeScheduled
number
Scheduled departure minus detection.
compensation
object | null
`{ amountEur, distanceKm, band }` — the EU261 amount for the route and delay. Null when either end of the route is unknown.
flight.delayed
{
  "id": "whd_7Qw1pKcRr8mF3xTz",
  "event": "clz9k2f9a0004qw2h8vjd1m0p",
  "type": "flight.delayed",
  "createdAt": "2026-09-03T11:42:07.318Z",
  "webhook": {
    "id": "clz8k2f9a0002qw2h",
    "name": "ops",
    "airports": [
      "OTP",
      "CLJ"
    ]
  },
  "airport": {
    "iata": "OTP",
    "name": "Bucharest Otopeni",
    "city": "Bucharest",
    "country": "Romania",
    "timezone": "Europe/Bucharest"
  },
  "data": {
    "flight": {
      "id": "clz9k1v2b0001qw2h",
      "flightNumber": "RO301",
      "callsign": "ROT301",
      "direction": "departure",
      "airline": {
        "name": "TAROM",
        "iata": "RO"
      },
      "aircraftType": "B738",
      "registration": null,
      "operation": "passenger",
      "origin": {
        "iata": "OTP"
      },
      "destination": {
        "iata": "LHR"
      },
      "divertedTo": null,
      "scheduledAt": "2026-09-03T12:50:00.000Z",
      "estimatedAt": "2026-09-03T13:37:00.000Z",
      "delayMinutes": 47,
      "status": "Estimated departure 16:37",
      "disruption": "delayed",
      "isCancelled": false,
      "isDiverted": false,
      "runway": null,
      "firstSeenAt": "2026-09-03T05:12:44.010Z",
      "lastSeenAt": "2026-09-03T11:42:07.301Z"
    },
    "previous": {
      "status": "Scheduled 15:50",
      "estimatedAt": null,
      "delayMinutes": null,
      "disruption": "scheduled"
    }
  }
}
verify.ts
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

export function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
  const timestamp = Number(parts.t)
  if (!Number.isFinite(timestamp)) return false

  // Bound the age first: the timestamp is inside the MAC, so an attacker
  // cannot backdate a captured request to keep replaying it.
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  const a = Buffer.from(parts.v1 ?? '')
  const b = Buffer.from(expected)
  return a.length === b.length && timingSafeEqual(a, b)
}
Signature
What is signed

The literal request body, prefixed with the timestamp and a dot. Sign the raw bytes you received — re-serialising the parsed JSON will not match.

Why the timestamp

It is inside the MAC, so it cannot be edited. Reject anything more than five minutes old and a captured request stops being replayable.

Comparison

Compare in constant time. A byte-by-byte early exit leaks the signature one character at a time.

Rotation

Rotating issues a new secret immediately; the old one stops verifying at once. Rotate from the dashboard when you are ready to deploy the new value.

Delivery
Freshness

Boards are polled every 30 minutes and every observed change is queued in the same pass. An event reaches you within a delivery cycle of the poll that saw it.

At least once, never twice

One delivery row exists per event and endpoint, unique in the database. A retry re-sends the same `id`; a redeploy or an overlapping pass cannot produce a second one. Key your handler on `id` anyway — that is what it is for.

Success

Any 2xx inside 10 seconds. Redirects are not followed — a 3xx is a failure, so point the webhook at the final URL. The response body is discarded.

Retries

Up to 5 attempts, backing off 1, 5, 20, 60 minutes. After the last one the delivery is marked failed and is not retried again.

Auto-disable

25 consecutive failures across all deliveries disables the endpoint and stops the queue. Any 2xx — including a manual test send — resets the counter.

Ordering

Deliveries are queued in event order but sent concurrently, so they can arrive out of order. Every payload carries `data.flight.lastSeenAt`; use it to discard an older observation.

Scope

A webhook only receives the airports it selected. Adding an airport to the catalogue does not widen an existing subscription — you have to select it.

GET/api/v1/accountAPI key

Inspect the calling key

Returns the account and key behind the supplied credential, its lifetime request count and the credit position for the current window. Unmetered — this call costs nothing.

200
{
  "success": true,
  "data": {
    "accountId": "acct_9f3c1d",
    "accountSlug": "acme-9f3c1d",
    "keyId": "clz8k2f9a0002qw2h",
    "keyName": "production",
    "keyPrefix": "flt_0Rk2Xa9c",
    "unlimited": false,
    "requestCount": 1284
  }
}
Status codes
200
Key is valid.
401
Missing or invalid API key.
402
No active plan, or the subscription is canceled or past due.
403
Key or account disabled.
GET/api/healthPublic

Liveness probe

Unauthenticated. Returns immediately.

200
{
  "ok": true,
  "service": "flight-api"
}