# FlightByte documentation > Flight intelligence, byte by byte. A flight status API with signed webhooks: look a flight up by carrier, number and date, or subscribe and receive every gate change, delay, departure and arrival at your endpoint. Base URL: https://api.flightbyte.dev/v1. Authentication: the `x-api-key` header. Pricing: https://flightbyte.dev/pricing. Free key: https://app.flightbyte.dev/signup. Paid plans: by card in the dashboard, https://app.flightbyte.dev/plan. Custom plans: https://flightbyte.dev/get-api-key. Support: https://flightbyte.dev/contact, or @flightbyte on Telegram (https://t.me/flightbyte). This file is the whole documentation in one place: the docs, the API reference generated from the OpenAPI document, the engineering guides and the glossary. https://flightbyte.dev/llms.txt is an index of the same pages. - Any docs page as Markdown: its URL plus `.md` (for example https://flightbyte.dev/docs/webhooks.md), or the page URL requested with `Accept: text/markdown` - OpenAPI document: https://flightbyte.dev/openapi.json - Building with an AI coding assistant (prompt, AGENTS.md and Cursor rules, a Claude Code skill): https://flightbyte.dev/docs/ai --- # Introduction > What FlightByte does, the two requests most integrations are made of, and where to go next. Source: https://flightbyte.dev/docs FlightByte is a flight status API built for the code on the receiving end. You can ask it about a flight, and you can ask it to watch one: from then on every gate change, delay, departure, landing and baggage carousel arrives at your endpoint as a signed webhook, while FlightByte does the polling on a schedule that tightens as departure nears. ## The shape of it Everything lives under one base URL and one header. ```text https://api.flightbyte.dev/v1 x-api-key: ``` A typical integration is two requests. ```bash title="1. Look a flight up" curl "https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` ```bash title="2. Watch it" curl -X POST "https://api.flightbyte.dev/v1/subscriptions" \ -H "x-api-key: $FLIGHTBYTE_KEY" \ -H "content-type: application/json" \ -d '{ "carrier": "TK", "flightNumber": "1848", "date": "2026-09-12", "webhookUrl": "https://yourapp.com/hooks/flights" }' ``` From here on, FlightByte polls the flight, diffs every answer against the last one, and sends you only what changed, signed with the subscription's secret. When the flight has arrived and the baggage belt is known, the watch ends on its own. ## What you get back Every flight is returned as one canonical snapshot, whichever upstream it came from: scheduled, estimated and actual times in UTC and local time, terminals, gates, baggage carousel, status with a coarse `phase`, aircraft, codeshares, every leg the flight number operates that day, and the airline's own timeline of changes. The same snapshot travels inside every webhook, next to the list of what changed. ## Where to go next - [Quickstart](https://flightbyte.dev/docs/quickstart): From a key to your first webhook in ten minutes. - [Flights](https://flightbyte.dev/docs/flights): Lookups, leg selection, timelines and caching. - [Subscriptions](https://flightbyte.dev/docs/subscriptions): Watching a flight, its lifecycle, and the delivery log. - [Webhooks](https://flightbyte.dev/docs/webhooks): Payloads, the 23 change types, signatures and retries. - [API reference](https://flightbyte.dev/docs/reference): Every endpoint with schemas, examples and a live playground. - [Errors](https://flightbyte.dev/docs/errors): The error envelope and what each status code means. --- # Quickstart > Look a flight up, subscribe to it, receive and verify the first webhook. Source: https://flightbyte.dev/docs/quickstart You need an API key and an HTTPS endpoint that can receive a POST. If you have neither yet, [request a key](https://flightbyte.dev/get-api-key) and use a tunnel such as `ngrok` or `cloudflared` for the endpoint while you develop. ## 1. Look a flight up Flights are addressed by airline code, flight number and the **local departure date** at the origin. **curl** ```bash curl "https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` **Node.js** ```ts const res = await fetch("https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12", { headers: { "x-api-key": process.env.FLIGHTBYTE_KEY! }, }); const flight = await res.json(); console.log(flight.status.label, flight.departure.gate, flight.arrival.times.estimatedGate?.utc); ``` **Python** ```python import os, requests flight = requests.get( "https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12", headers={"x-api-key": os.environ["FLIGHTBYTE_KEY"]}, ).json() print(flight["status"]["label"], flight["departure"]["gate"]) ``` No code yet? [Run this lookup in the playground](https://app.flightbyte.dev/playground?op=get-flight&carrier=TK&flightNumber=1848) with your own key. The response is the flight snapshot, trimmed here to the parts most code reads first: ```json { "flightKey": "TK1848:2026-09-12@ATH-IST", "carrier": { "iata": "TK", "icao": "THY", "name": "Turkish Airlines" }, "departure": { "airport": { "iata": "ATH", "name": "Athens Eleftherios Venizelos International Airport", "city": "Athens", "timezone": "Europe/Athens" }, "terminal": "M", "gate": "A11", "times": { "scheduledGate": { "utc": "2026-09-12T09:10:00.000Z", "local": "2026-09-12T12:10:00.000", "timezone": "EEST" } }, "bestTimeUtc": "2026-09-12T09:10:00.000Z", "delayMinutes": null }, "arrival": { "airport": { "iata": "IST" }, "baggage": null, "times": { "scheduledGate": { "utc": "2026-09-12T10:45:00.000Z" } } }, "status": { "code": "S", "label": "Scheduled", "phase": "scheduled", "delayed": false, "cancelled": false, "diverted": false }, "equipment": { "tailNumber": "TC-LTP", "iata": "32Q", "name": "Airbus A321neo" }, "alternates": [], "cached": false } ``` > **Two legs, one number** > > Some flight numbers fly twice on the same day. If the one you asked for does, the response describes the upstream's default leg and lists every leg under `alternates`; pass `?origin=ATH` to choose. [Flights → Leg selection](https://flightbyte.dev/docs/flights#leg-selection) has the details. ## 2. Subscribe One subscription watches one flight on one date. Give it the URL to call and, optionally, which change types you want. ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions" \ -H "x-api-key: $FLIGHTBYTE_KEY" \ -H "content-type: application/json" \ -d '{ "carrier": "TK", "flightNumber": "1848", "date": "2026-09-12", "webhookUrl": "https://yourapp.com/hooks/flights", "events": ["flight.departed", "flight.arrived", "delay.increased", "arrival.gate.changed"], "metadata": { "bookingId": "BK-1042" } }' ``` ```json title="201 Created (trimmed)" { "id": "sub_7fd9fa92faf3475db99b", "status": "active", "flightKey": "TK1848:2026-09-12@ATH-IST", "webhookUrl": "https://yourapp.com/hooks/flights", "secret": "whsec_Y0Cp2VtM9s8Lq2fXo1nD3a7MA0u", "events": ["flight.departed", "flight.arrived", "delay.increased", "arrival.gate.changed"], "metadata": { "bookingId": "BK-1042" } } ``` Store the `secret`: it is shown in full only in this response, and you need it to verify deliveries. `metadata` is opaque to FlightByte and comes back in every webhook, which is how you map a delivery to your own record. A `subscription.created` event is delivered right away, so you can confirm the endpoint works before anything about the flight changes. ## 3. Receive and verify Every delivery is a POST with a JSON body and a signature header. Verify the signature, check the timestamp, then act on `changes`. **Node.js** ```ts import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const app = express(); const SECRET = process.env.FLIGHTBYTE_WEBHOOK_SECRET!; // The signature covers the raw body; parse it only after verifying. app.post("/hooks/flights", express.raw({ type: "application/json" }), (req, res) => { const header = req.header("x-flightbyte-signature") ?? ""; const [t, v1] = header.split(",").map((part) => part.split("=")[1]); const expected = createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; const valid = v1?.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); if (!fresh || !valid) return res.status(401).end(); const event = JSON.parse(req.body.toString()); for (const change of event.changes) console.log(event.flightKey, change.type, change.message); res.status(204).end(); }); ``` **Python** ```python import hmac, hashlib, json, os, time from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["FLIGHTBYTE_WEBHOOK_SECRET"].encode() @app.post("/hooks/flights") def hook(): parts = dict(p.split("=", 1) for p in request.headers.get("x-flightbyte-signature", "").split(",")) t, v1 = parts.get("t", ""), parts.get("v1", "") expected = hmac.new(SECRET, f"{t}.".encode() + request.get_data(), hashlib.sha256).hexdigest() if abs(time.time() - float(t or 0)) > 300 or not hmac.compare_digest(v1, expected): abort(401) event = json.loads(request.get_data()) for change in event["changes"]: print(event["flightKey"], change["type"], change["message"]) return "", 204 ``` Respond with any `2xx` within ten seconds. Anything else is retried with backoff; [Webhooks → Retries](https://flightbyte.dev/docs/webhooks#retries-and-suspension) explains the schedule. ## 4. Send a test event You do not have to wait for a real gate change to see the pipeline work: ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions/sub_7fd9fa92faf3475db99b/test" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` A `subscription.test` event goes through the same signing, delivery and logging as a real one. Then read what happened: ```bash curl "https://api.flightbyte.dev/v1/subscriptions/sub_7fd9fa92faf3475db99b/deliveries" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` Each entry carries the HTTP status your endpoint returned, the latency, the attempt number and any error, so a misbehaving receiver is visible from the API rather than from a guess. --- # Build with AI > A prompt, rules files and a Claude Code skill that teach a coding assistant the API, and the whole documentation as Markdown. Source: https://flightbyte.dev/docs/ai A coding assistant writes a good FlightByte integration when it reads these docs instead of guessing from what it saw in training. Everything here helps it do that: a prompt to start with, rules your repository keeps for every later session, a skill for Claude Code, and the documentation in a form a model reads best. Every docs page also has **Copy as Markdown**, **Open in ChatGPT** and **Open in Claude** under its title, for when one page is all the context you need. ## Start with a prompt Paste this into Claude Code, Cursor, Copilot, Codex or a chat assistant, in the repository you want to integrate. It points the assistant at the full documentation and names the parts that are easy to get wrong. ```text title="Integration prompt" Integrate the FlightByte flight status API into this project. First read https://flightbyte.dev/llms-full.txt, the complete documentation as Markdown. Use https://flightbyte.dev/openapi.json for exact request and response schemas. Then: 1. Keep the API key server-side, in an environment variable named FLIGHTBYTE_KEY, and send it in the x-api-key header to https://api.flightbyte.dev/v1. 2. Look flights up with GET /v1/flights/{carrier}/{flightNumber}/{date}, where date is the local departure date at the origin (YYYY-MM-DD). When a flight number operates more than one leg that day, choose one with the origin parameter. 3. To be told about changes, create a subscription with POST /v1/subscriptions, send an Idempotency-Key header, and store the secret from the response: it is shown in full only once. 4. Add a webhook endpoint that verifies the x-flightbyte-signature header (HMAC-SHA256 of "." with the subscription secret) on the raw request body before parsing it, rejects timestamps more than five minutes old, answers 2xx within ten seconds and deduplicates deliveries on the event id. 5. Handle the error envelope: retry 429 after Retry-After, retry 502 and 503 with backoff, and do not retry other 4xx responses. Ask me which flights to track and where webhooks should be delivered if the code does not make it clear. Show me a plan before changing files. ``` ## Rules for your repository A prompt lasts one conversation. A rules file is read at the start of every session, so the assistant keeps the essentials even when nobody mentions FlightByte. ### AGENTS.md or CLAUDE.md Add this section to `AGENTS.md` at the root of your repository, which Codex, Cursor, GitHub Copilot and many other agents read, or to `CLAUDE.md` for Claude Code. ```md title="AGENTS.md" ## FlightByte API This project uses the FlightByte flight status API. Read the docs rather than guessing: https://flightbyte.dev/llms-full.txt has all of it, any page is Markdown at https://flightbyte.dev/docs/.md, and the OpenAPI document is https://flightbyte.dev/openapi.json. - Base URL `https://api.flightbyte.dev/v1`. Send the key in the `x-api-key` header from the `FLIGHTBYTE_KEY` environment variable. The key is a server-side secret: never ship it to a browser or a mobile app. - A flight is addressed by carrier, flight number and the local departure date at the origin: `GET /v1/flights/TK/1848/2026-09-12`. Leading zeros in the number are ignored. - One number can fly several legs a day. Lookups list them under `alternates`; pass `origin` (an IATA code such as `IST`) to pick one. A subscription for an ambiguous number without `origin` is refused with `400` and the list of `legs`. - Branch on `status.phase` (`scheduled`, `boarding`, `departed`, `en_route`, `landed`, `arrived`, `cancelled`, `diverted`, `redirected`, `unknown`), not on `status.code`. For a single departure or arrival time use `bestTimeUtc`. - Watch a flight with `POST /v1/subscriptions` and `{ carrier, flightNumber, date, webhookUrl, events?, metadata? }`, with an `Idempotency-Key` header so a retry does not create a second one. Store the returned `secret`: it is shown in full only in that response. - Webhooks: verify `x-flightbyte-signature: t=,v1=`, where `v1` is HMAC-SHA256 of `"."` with the subscription secret, on the raw bytes before JSON parsing, in constant time, with the timestamp within 300 seconds. Answer `2xx` within 10 seconds and do the work afterwards. Deduplicate on the event `id` and do not assume order. `metadata` comes back in every delivery. - Test an endpoint with `POST /v1/subscriptions/{id}/test`, then read `GET /v1/subscriptions/{id}/deliveries`. Change types and payloads: https://flightbyte.dev/docs/webhooks.md. - Errors share one envelope (`statusCode`, `error`, `message`, `requestId`). Retry `429` after `Retry-After` and `502` or `503` with backoff; `400`, `401` and `404` answer the same until the request changes. `402` with `"code": "watched_flight_limit"` means the plan's monthly watched-flight cap is reached. ``` ### Cursor rules Save this as `.cursor/rules/flightbyte.mdc`. With a description and `alwaysApply: false`, Cursor's agent pulls the rule in when a task matches it. ```md title=".cursor/rules/flightbyte.mdc" --- description: FlightByte flight status API: flight lookups, subscriptions, and verifying signed webhooks alwaysApply: false --- This project uses the FlightByte flight status API. Read the docs rather than guessing: https://flightbyte.dev/llms-full.txt has all of it, any page is Markdown at https://flightbyte.dev/docs/.md, and the OpenAPI document is https://flightbyte.dev/openapi.json. - Base URL `https://api.flightbyte.dev/v1`. Send the key in the `x-api-key` header from the `FLIGHTBYTE_KEY` environment variable. The key is a server-side secret: never ship it to a browser or a mobile app. - A flight is addressed by carrier, flight number and the local departure date at the origin: `GET /v1/flights/TK/1848/2026-09-12`. Leading zeros in the number are ignored. - One number can fly several legs a day. Lookups list them under `alternates`; pass `origin` (an IATA code such as `IST`) to pick one. A subscription for an ambiguous number without `origin` is refused with `400` and the list of `legs`. - Branch on `status.phase` (`scheduled`, `boarding`, `departed`, `en_route`, `landed`, `arrived`, `cancelled`, `diverted`, `redirected`, `unknown`), not on `status.code`. For a single departure or arrival time use `bestTimeUtc`. - Watch a flight with `POST /v1/subscriptions` and `{ carrier, flightNumber, date, webhookUrl, events?, metadata? }`, with an `Idempotency-Key` header so a retry does not create a second one. Store the returned `secret`: it is shown in full only in that response. - Webhooks: verify `x-flightbyte-signature: t=,v1=`, where `v1` is HMAC-SHA256 of `"."` with the subscription secret, on the raw bytes before JSON parsing, in constant time, with the timestamp within 300 seconds. Answer `2xx` within 10 seconds and do the work afterwards. Deduplicate on the event `id` and do not assume order. `metadata` comes back in every delivery. - Test an endpoint with `POST /v1/subscriptions/{id}/test`, then read `GET /v1/subscriptions/{id}/deliveries`. Change types and payloads: https://flightbyte.dev/docs/webhooks.md. - Errors share one envelope (`statusCode`, `error`, `message`, `requestId`). Retry `429` after `Retry-After` and `502` or `503` with backoff; `400`, `401` and `404` answer the same until the request changes. `402` with `"code": "watched_flight_limit"` means the plan's monthly watched-flight cap is reached. ``` ## A Claude Code skill A skill is a longer brief that Claude Code loads by itself when a task touches FlightByte: lookups and leg selection, subscriptions, the webhook payload, signature verification in Node.js and Python, retries, errors, and a checklist to finish with. [Download SKILL.md](https://flightbyte.dev/skills/flightbyte/SKILL.md), or install it from a terminal: ```bash title="For this project" mkdir -p .claude/skills/flightbyte curl -fsSL https://flightbyte.dev/skills/flightbyte/SKILL.md -o .claude/skills/flightbyte/SKILL.md ``` ```bash title="For every project on this machine" mkdir -p ~/.claude/skills/flightbyte curl -fsSL https://flightbyte.dev/skills/flightbyte/SKILL.md -o ~/.claude/skills/flightbyte/SKILL.md ``` Commit the project copy and everyone working in the repository gets it. ## The docs as Markdown | URL | What it is | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | [`/llms.txt`](https://flightbyte.dev/llms.txt) | An index of every page with a one-line description, in the [llms.txt](https://llmstxt.org) convention. | | [`/llms-full.txt`](https://flightbyte.dev/llms-full.txt) | The docs, the API reference, the guides and the glossary in one Markdown file. | | `/docs/.md` | Any docs page as Markdown, for example [`/docs/webhooks.md`](https://flightbyte.dev/docs/webhooks.md). Reference pages are written out from their OpenAPI operation. | | [`/openapi.json`](https://flightbyte.dev/openapi.json) | The OpenAPI 3.0 document the reference is generated from. Import it into Postman or Insomnia, or generate a client from it. | An agent that asks for Markdown gets it at the page's own URL: ```bash curl -H "Accept: text/markdown" https://flightbyte.dev/docs/webhooks ``` --- # Authentication and quotas > The API key header, the quota windows on every key, and what a 429 looks like. Source: https://flightbyte.dev/docs/authentication ## The key Every request carries your key in the `x-api-key` header. `Authorization: Bearer ` is accepted too. ```bash curl "https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` A free key comes from [signing up](https://app.flightbyte.dev/signup); paid plans are issued from the [request form](https://flightbyte.dev/get-api-key). Treat keys as secrets: keep them server-side, never in a browser or a mobile app. If a key leaks, rotate it in the [dashboard](https://app.flightbyte.dev/keys); the old one stops working the moment the new one is issued. The only endpoints that work without a key are the flag images under `/v1/flags`, which are meant to be referenced straight from HTML. ## Quotas Your keys share three independent windows: requests per minute, per day and per month, sized by your [plan](https://flightbyte.dev/pricing). They are aligned to UTC: the minute window resets on the minute, the day at `00:00 UTC`, the month on the first. Every response tells you where you stand: ```http X-RateLimit-Limit-Minute: 120 X-RateLimit-Remaining-Minute: 117 X-RateLimit-Reset-Minute: 1789306080 X-RateLimit-Limit-Day: 10000 X-RateLimit-Remaining-Day: 9814 X-RateLimit-Reset-Day: 1789344000 ``` `Reset` values are Unix seconds. A window with no limit sends no headers. Subscriptions are counted separately: each one a key creates in a UTC month is a watched flight. Paid plans have no cap and bill flights past the plan's allowance. Free has a cap of 3, and the fourth subscription in a month is refused with `402` and `"code": "watched_flight_limit"`, while the three you have keep running. ## When a window is full The request is refused with `429`, a `Retry-After` header, and a body that names the window: ```json { "statusCode": 429, "error": "TooManyRequests", "message": "Quota exceeded for the current minute window (120/120).", "window": "minute", "retryAfterSeconds": 23, "path": "/v1/flights/TK/1848/2026-09-12", "timestamp": "2026-09-12T10:41:37.000Z", "requestId": "5129839a-26c5-49d2-bf4c-eb4f47d10a03" } ``` A refused request does not consume any window. The three checks happen atomically, so a request that fails the daily limit does not eat a minute or a month. > **Webhooks are free of quota** > > Quotas count the requests you make. Webhook deliveries, and the polling FlightByte does on your behalf, are never counted against them; watched flights are metered separately by your plan. --- # Flights > Looking flights up, choosing a leg when a number flies twice, the timeline, and how caching works. Source: https://flightbyte.dev/docs/flights ## Looking a flight up ```http GET /v1/flights/{carrier}/{flightNumber}/{date} ``` | Part | Meaning | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `carrier` | Airline code, two or three characters: `TK`, `PC`, `3U`. | | `flightNumber` | One to four digits, an optional letter suffix; leading zeros are ignored, so `0012` and `12` are the same flight. | | `date` | `YYYY-MM-DD`, the **local departure date at the origin**. A flight that leaves Istanbul at 23:50 and lands the next morning is on the day it left. | The same lookup is available as a query: `GET /v1/flights/search?carrier=TK&flightNumber=1848&date=2026-09-12`. Optional query parameters on both: | Parameter | Use | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `origin` | Pick a leg by departure airport. Accepts the IATA code (`IST`) or the code the data upstream uses (`ISL`); both resolve to the same leg. | | `destination` | Narrow further when several legs leave the same airport. | | `flightId` | Exact pick using `alternates[].providerFlightId` from a previous answer. | | `refresh` | `true` skips the cache and asks upstream now. Counts as a normal request. | The response is the [flight snapshot](https://flightbyte.dev/docs/flights#the-snapshot) plus one field, `cached`, that says whether it came from cache. ## Leg selection Flight numbers are not unique within a day. `TK1` is SAW → AMS in the morning and IST → JFK in the afternoon. FlightByte never guesses which one you mean: * **Lookups** without a selector return the upstream's default leg and list every leg under `alternates`, with `selected: true` on the one described. * **Lookups** with a selector that matches nothing answer `404` and name the legs that exist. * **Subscriptions** for an ambiguous number without a selector are refused with `400` and the legs, because watching the wrong aircraft is worse than an error. ```json title="404 when the selector matches nothing" { "statusCode": 404, "error": "NotFoundException", "message": "TK1 on 2026-09-12 has no leg matching origin=LHR. Available: SAW-AMS, ISL-JFK." } ``` Use `origin`: a passenger always knows where they are leaving from. The resolved leg gets a canonical key, `TK1:2026-09-12@IST-JFK`, which is what appears in webhooks and subscriptions. ## The snapshot One shape for every flight, whichever upstream it came from. | Field | What it holds | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `flightKey` | Canonical key: `TK1848:2026-09-12` or, once a leg is resolved, `TK1848:2026-09-12@ATH-IST`. | | `carrier`, `operatedBy`, `codeshares` | Marketing airline, operating airline when different, and every codeshare number. | | `departure`, `arrival` | Airport (with country, emoji and flag URLs), terminal, gate, `baggage` on arrival, `times`, `bestTimeUtc`, `delayMinutes`. | | `times` | `scheduledGate`, `estimatedGate`, `actualGate`, `scheduledRunway`, `estimatedRunway`, `actualRunway`, each with `utc`, `local` and `timezone`. Missing ones are absent, not guessed. | | `bestTimeUtc` | The best time known right now: actual, else estimated, else scheduled. Use this when you need one number. | | `status` | `code` (upstream letter), `label`, `phase`, and the booleans `delayed`, `cancelled`, `diverted`, plus gate delays in minutes. | | `diverted` | The airport the flight went to instead, when it did. | | `equipment` | Aircraft type and tail number. | | `duration` | Scheduled, estimated and actual minutes. | | `timeline` | The airline's own history of changes for this leg, newest first. | | `alternates` | Every leg this number operates that day. | | `fetchedAt` | When this snapshot was taken upstream. | ### Phases `status.phase` is the field to branch on; `status.code` and `label` carry the upstream's wording. | Phase | Meaning | | ------------------------------------- | -------------------------------------------------------------- | | `scheduled` | Not yet departed. | | `boarding` | Gate activity reported. | | `departed` | Left the gate, not yet airborne. | | `en_route` | Airborne. | | `landed` | On the ground, not yet at the gate. | | `arrived` | At the gate; watch continues briefly for the baggage carousel. | | `cancelled`, `diverted`, `redirected` | Terminal states with the obvious meaning. | | `unknown` | Upstream said nothing usable. | ## Timeline ```http GET /v1/flights/{carrier}/{flightNumber}/{date}/timeline ``` Returns `{ "flightKey", "count", "data": [...] }` where each entry has `occurredAt`, `title`, `source` and a list of `events` in the airline's words, newest first. It is the same data as `snapshot.timeline`, exposed on its own for pages that only need the history. ## Caching Lookups are served from cache while fresh; the `cached` flag says which. Concurrent requests for the same flight collapse into one upstream call. | Flight state | Cache lifetime | | ----------------------------------------- | -------------- | | Departs in more than 24 hours | 30 minutes | | Departs in 3 to 24 hours | 5 minutes | | Within 3 hours of departure or in the air | 60 seconds | | Completed | 60 minutes | | More than 48 hours in the past | 7 days | | Not found | 2 minutes | If you need a guaranteed fresh answer, pass `refresh=true`. For anything you want to be told about rather than ask for, [subscribe](https://flightbyte.dev/docs/subscriptions). ## Coverage Live records exist for flights from a few days before today to a couple of days ahead. A lookup outside that window answers `404`. A subscription can still be created up to seven days ahead: it waits as `pending` and activates by itself when the flight appears. --- # Subscriptions > Watching a flight, what you can filter, the lifecycle of a subscription, and the logs behind it. Source: https://flightbyte.dev/docs/subscriptions A subscription watches one flight on one date and delivers every change to one URL. Creating it is the second and last request a typical integration needs. A subscription belongs to the key that created it: another key cannot list, read or change it, and asking for it by id answers `404`. ## Creating one ```http POST /v1/subscriptions ``` | Field | Required | Meaning | | ----------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------- | | `carrier`, `flightNumber`, `date` | yes | The flight, exactly as in a [lookup](https://flightbyte.dev/docs/flights#looking-a-flight-up). | | `origin`, `destination`, `flightId` | when ambiguous | Pick the leg. Required when the number operates more than one leg that day; the `400` tells you. | | `webhookUrl` | yes | `https://` or `http://` URL to POST to. Addresses that resolve to private, loopback or link-local ranges are refused. | | `secret` | no | Your own signing secret. One is generated when omitted and returned once, in this response. | | `events` | no | Change types to deliver, from the [list of 23](https://flightbyte.dev/docs/webhooks#change-types). Default: all of them. | | `delayThresholdMinutes` | no | Delay changes smaller than this are not delivered. `1` to `240`, default `1`. | | `headers` | no | Extra headers to send with every delivery, for example your own auth token. | | `metadata` | no | String map echoed in every webhook. Put your booking or order id here. | Send an `Idempotency-Key` header to make the call safe to retry: the same key returns the same subscription instead of creating a second one. ```json title="201 Created" { "id": "sub_7fd9fa92faf3475db99b", "status": "active", "flightKey": "TK1848:2026-09-12@ATH-IST", "carrier": "TK", "flightNumber": "1848", "date": "2026-09-12", "webhookUrl": "https://yourapp.com/hooks/flights", "secret": "whsec_Y0Cp2VtM9s8Lq2fXo1nD3a7MA0u", "events": ["status.changed", "flight.departed", "..."], "delayThresholdMinutes": 1, "metadata": { "bookingId": "BK-1042" }, "flightResolved": true, "createdAt": "2026-09-12T06:02:11.000Z", "deliveredCount": 0, "failedCount": 0, "lastPolledAt": null, "lastChangeAt": null, "lastDeliveryAt": null } ``` Every later read shows the secret as `secretHint` (`whsec_Y0Cp2...MA0u`). `GET /v1/subscriptions/{id}/secret` reveals it again; `POST /v1/subscriptions/{id}/secret/rotate` replaces it. ## Lifecycle | Status | Meaning | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `active` | The flight was found and is being polled. | | `pending` | The flight is not published upstream yet. It is checked on a slow schedule; when it appears the subscription becomes `active` and you receive `subscription.activated`. Subscriptions can be created up to seven days ahead. | | `paused` | You paused it. No polling, no deliveries, nothing lost; `resume` continues. | | `suspended` | Twenty consecutive deliveries failed. Polling stops so a dead endpoint is not hammered forever; fix the endpoint and `resume`. | | `completed` | The flight arrived and the baggage carousel window passed, or the date went by without the flight ever appearing. `subscription.completed` is the last event. | ```http POST /v1/subscriptions/{id}/pause POST /v1/subscriptions/{id}/resume DELETE /v1/subscriptions/{id} ``` Deleting removes the subscription and stops its deliveries. The events and deliveries already recorded are kept on our side for thirty days, so whether a particular webhook went out can still be checked after the fact. When the last subscription on a flight goes away, FlightByte stops polling that flight. ## How often the flight is polled You never schedule anything; the cadence follows the flight. | Situation | Interval | | --------------------------------------- | -------------------------------------------------------------------------- | | More than 48 hours before departure | Every 12 hours | | 24 to 48 hours | Every 6 hours | | 6 to 24 hours | Hourly | | 2 to 6 hours | Every 20 minutes | | 45 minutes to 2 hours | Every 10 minutes | | Under 45 minutes, boarding, or departed | Every 3 minutes | | In the air | Every 10 minutes, every 3 minutes in the last 45 | | Landed, waiting for the gate | Every 2 minutes | | At the gate | Every 5 minutes for 45 minutes, for the baggage carousel, then `completed` | | Cancelled | Every 30 minutes for a while, in case it is reinstated | | Not published yet (`pending`) | Every 6 hours far out, hourly within two days, every 15 minutes on the day | The full decision function, with the reasons behind each row, is in [How adaptive polling works](https://flightbyte.dev/guides/how-adaptive-polling-works). Many subscriptions on the same leg share one poll; adding subscribers never adds upstream load. ## Updating ```http PATCH /v1/subscriptions/{id} ``` `webhookUrl`, `events`, `delayThresholdMinutes`, `headers` and `metadata` can change at any time. The flight itself cannot; create a new subscription for a different flight. ## Listing ```http GET /v1/subscriptions?status=active&carrier=TK&flightNumber=1848&date=2026-09-12&limit=50&offset=0 ``` Returns `{ "total", "data": [...] }`. All filters are optional. ## Test events ```http POST /v1/subscriptions/{id}/test ``` Sends a `subscription.test` event through the real pipeline, signed with the real secret, and returns its `eventId`. Use it to prove an endpoint before the first real change. ## Deliveries and events Everything FlightByte sent, and how it went, is readable from the API. ```http GET /v1/subscriptions/{id}/deliveries?limit=50 GET /v1/subscriptions/{id}/events?limit=20 ``` A delivery record: ```json { "eventId": "evt_2f0c8a4e-6f1d-4d0d-9a3b-0f1e2d3c4b5a", "attempt": 2, "status": "failed", "httpStatus": 500, "error": "HTTP 500", "durationMs": 3, "at": "2026-09-12T11:41:07.000Z", "changeTypes": ["departure.estimated.changed", "delay.increased"] } ``` `events` returns the exact payloads that were sent, so you can replay one into a local receiver or see what a field looked like at the time. Delivery records are kept for thirty days. --- # Webhooks > The delivery format, every event and change type, how to verify signatures, and what retries look like. Source: https://flightbyte.dev/docs/webhooks A webhook is a `POST` with a JSON body to your subscription's URL. It carries the event, the full flight snapshot at that moment, and the list of what changed since the previous delivery. ## Headers ```http POST /hooks/flights HTTP/1.1 Content-Type: application/json x-flightbyte-event-id: evt_2f0c8a4e-6f1d-4d0d-9a3b-0f1e2d3c4b5a x-flightbyte-event-type: flight.updated x-flightbyte-subscription-id: sub_7fd9fa92faf3475db99b x-flightbyte-delivery-attempt: 1 x-flightbyte-timestamp: 1789305971 x-flightbyte-signature: t=1789305971,v1=5f1c9a...e3b2 ``` Any `headers` you set on the subscription are sent as well. > **Legacy header names** > > The same six values are also sent as `x-flightradar-*` for receivers written before the rename. Read the `x-flightbyte-*` names in new code; the old ones will be dropped in a future release. ## Payload ```json { "id": "evt_2f0c8a4e-6f1d-4d0d-9a3b-0f1e2d3c4b5a", "type": "flight.updated", "createdAt": "2026-09-12T09:26:11.000Z", "subscriptionId": "sub_7fd9fa92faf3475db99b", "flightKey": "TK1848:2026-09-12@ATH-IST", "flight": { "...": "the full flight snapshot" }, "changes": [ { "type": "departure.gate.changed", "path": "departure.gate", "from": "A11", "to": "A22", "message": "Departure gate changed from A11 to A22" }, { "type": "arrival.estimated.changed", "path": "arrival.times.estimatedGate", "from": "2026-09-12T10:45:00Z", "to": "2026-09-12T11:15:00Z", "deltaMinutes": 30, "message": "Arrival estimated gate time changed from 2026-09-12T10:45:00Z to 2026-09-12T11:15:00Z" } ], "previousFetchedAt": "2026-09-12T09:16:04.000Z", "metadata": { "bookingId": "BK-1042" } } ``` | Field | Meaning | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Stable across retries. Deduplicate on it. | | `type` | One of the [event types](https://flightbyte.dev/docs/webhooks#event-types). | | `flight` | The [snapshot](https://flightbyte.dev/docs/flights#the-snapshot) as of this event. Present on every event that has a resolved flight. | | `changes` | Empty on lifecycle events; otherwise one entry per change, each with a `path` into the snapshot, `from`, `to`, a human `message`, and `deltaMinutes` for time changes. | | `previousFetchedAt` | When the snapshot these changes were computed against was taken. | | `metadata` | Exactly what you stored on the subscription. | ## Event types | Type | When | | ------------------------ | ----------------------------------------------------------------------------------- | | `subscription.created` | Right after creation. Carries the flight if it could be resolved. | | `subscription.activated` | A `pending` subscription's flight appeared upstream. | | `flight.updated` | Something about the flight changed. The only type with a non-empty `changes`. | | `subscription.test` | You called the test endpoint. | | `subscription.suspended` | Deliveries kept failing and polling stopped. Delivery of this one is attempted too. | | `subscription.completed` | The watch ended: the flight arrived, or the date passed without it. | ## Change types These are the values for a subscription's `events` filter and for `changes[].type`. A `flight.updated` event can carry several at once. | Type | Fires when | | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | | `status.changed` | The status code or phase changed for any reason. | | `flight.departed` | The flight left the gate or the runway. | | `flight.arrived` | The flight reached the gate. | | `flight.cancelled` | Cancelled. | | `flight.diverted` | Landing somewhere other than planned. | | `flight.reinstated` | A cancelled flight is back. | | `departure.gate.changed`, `departure.terminal.changed` | Departure gate or terminal moved. | | `arrival.gate.changed`, `arrival.terminal.changed` | Arrival gate or terminal moved. | | `arrival.baggage.changed` | Baggage carousel assigned or changed. | | `departure.scheduled.changed`, `departure.estimated.changed`, `departure.actual.changed` | A departure time changed. | | `arrival.scheduled.changed`, `arrival.estimated.changed`, `arrival.actual.changed` | An arrival time changed. | | `delay.increased`, `delay.decreased` | The gate delay grew or shrank by at least `delayThresholdMinutes`. | | `aircraft.changed`, `aircraft.tail.changed` | Aircraft type or the specific airframe changed. | | `route.changed` | Origin or destination changed. | | `codeshares.changed` | The codeshare list changed. | ## Verifying a delivery The signature header is `t=,v1=` where `v1` is `HMAC-SHA256(secret, ".")`. Verify against the **raw** bytes you received, before any JSON parsing, and reject timestamps older than a few minutes; FlightByte itself allows five. **Node.js** ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret: string, header: string, rawBody: Buffer, toleranceSeconds = 300): boolean { const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2))); if (!parts.t || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex"); return parts.v1.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)); } ``` **Python** ```python import hmac, hashlib, time def verify(secret: bytes, header: str, raw_body: bytes, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1 or abs(time.time() - float(t)) > tolerance: return False expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(v1, expected) ``` **Go** ```go func verify(secret, header string, rawBody []byte, tolerance time.Duration) bool { var t, v1 string for _, part := range strings.Split(header, ",") { if k, v, ok := strings.Cut(part, "="); ok { switch k { case "t": t = v case "v1": v1 = v } } } ts, err := strconv.ParseInt(t, 10, 64) if err != nil || time.Since(time.Unix(ts, 0)).Abs() > tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(v1), []byte(expected)) } ``` > **Frameworks that parse JSON for you** > > Express, Fastify, Django and friends usually hand you an object, not bytes. Configure the route to keep the raw body (`express.raw`, `rawBody: true` in Fastify, `request.body` in Django) and verify that; re-serialising the parsed object will not reproduce the exact bytes that were signed. ## Retries and suspension A delivery succeeds when your endpoint answers any `2xx` within ten seconds. Otherwise: * Network errors, timeouts and `5xx` responses are retried up to **8 times** with exponential backoff, starting at four seconds. * `408` and `429` are retried the same way. * Any other `4xx` is treated as your endpoint rejecting the event and is **not** retried. * Every attempt is written to the [delivery log](https://flightbyte.dev/docs/subscriptions#deliveries-and-events) with its status and latency. After **20 consecutive failed deliveries** the subscription is `suspended` and polling stops. Fix the endpoint, then `POST /v1/subscriptions/{id}/resume`; use the events log to see what you missed. ## Writing a receiver that behaves * **Answer fast, work later.** Acknowledge with `204`, then process. Ten seconds is generous until your database is slow. * **Deduplicate on `id`.** A retry carries the same `id` and the same body. Keep a small table of processed ids. * **Do not assume order.** Retries can arrive after a newer event. `createdAt` and `flight.fetchedAt` tell you which snapshot is newer; when in doubt, the `flight` in the latest event is the truth. * **Use `metadata`, not the URL.** One endpoint can serve every subscription; `metadata` tells you which booking it is about. * **Fail loudly in development.** Log signature failures with the reason; a wrong secret and a re-serialised body look identical from the outside. Three receivers that do all of the above, in Node.js, Python and Go, with a script that sends signed test events, are in [flightbyte-webhook-examples](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples). --- # Countries and flags > Country names in any language, emoji, and SVG flags you can drop into a page. Source: https://flightbyte.dev/docs/countries Every airport in a flight snapshot carries its country with a name, an emoji and two flag URLs, so most integrations never call these endpoints directly. They exist for the cases where you need the list or the image on its own. ## Countries ```http GET /v1/countries?lang=tr GET /v1/countries/{code}?lang=tr ``` `code` is an ISO 3166-1 alpha-2 code (`TR`, `GR`, `US`). `lang` is any BCP 47 language tag; names come from the Unicode CLDR data, so every language that Unicode covers is available without a separate dataset. ```json { "code": "TR", "name": "Türkiye", "emoji": "🇹🇷", "flagSvg": "https://api.flightbyte.dev/v1/flags/tr.svg", "flagSvgSquare": "https://api.flightbyte.dev/v1/flags/tr.svg?shape=1x1" } ``` The list endpoint returns `{ "count", "data": [...] }` with the same shape per entry. ## Flags ```http GET /v1/flags/{code}.svg GET /v1/flags/{code}.svg?shape=1x1 ``` 271 flags as SVG, in 4:3 by default and 1:1 with `shape=1x1`. The endpoint needs no API key and answers with `Cache-Control: public, max-age=604800, immutable`, so it is safe to reference straight from an `` tag or a CSS background. ```html Greece ``` --- # Errors > One error envelope for every failure, and what each status code means here. Source: https://flightbyte.dev/docs/errors ## The envelope Every error is JSON with the same fields, plus anything specific the situation adds. ```json { "statusCode": 400, "error": "BadRequestException", "message": "TK1 operates 2 legs on 2026-09-12. Pass \"origin\" to choose one.", "path": "/v1/subscriptions", "timestamp": "2026-09-12T11:06:12.138Z", "requestId": "5129839a-26c5-49d2-bf4c-eb4f47d10a03", "legs": [ { "origin": "SAW", "destination": "AMS", "carrier": "AJA", "scheduledDepartureLocal": "08:45", "flightId": "1407278900" }, { "origin": "ISL", "destination": "JFK", "carrier": "TK", "scheduledDepartureLocal": "14:10", "flightId": "1407266450" } ] } ``` `message` is a string, or an array of strings when several validation rules failed at once. Quote `requestId` when you write to us about a request; it is the id in our logs. ## Status codes | Code | When | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | The request is malformed, or a value failed validation. For an ambiguous flight number the body carries `legs`. | | `401` | No key, or a key that is invalid, revoked or expired. | | `402` | The key's plan caps subscriptions per month and this one would pass the cap (Free: 3). The body carries `"code": "watched_flight_limit"`, `limit`, `used` and `resetsAt`; the subscriptions you have keep running. See [quotas](https://flightbyte.dev/docs/authentication#quotas). | | `404` | No such flight on that date, no leg matching the selector, or no such subscription for this key. | | `409` | A subscription for this flight and URL already exists under the same `Idempotency-Key`. | | `429` | A quota window is full. The body names the `window` and `retryAfterSeconds`; `Retry-After` is set. See [quotas](https://flightbyte.dev/docs/authentication#quotas). | | `502` | The upstream data source failed after retries. Safe to retry after a short wait; watched flights are not affected, polling retries by itself. | | `503` | Temporarily unavailable, for example during a deploy. Retry with backoff. | ## Retrying `429`, `502` and `503` are worth retrying; the first with the `Retry-After` value, the others after a second or two with backoff. `400`, `401` and `404` will answer the same way until something in the request changes, and `402` until the month rolls over or the plan does. --- # API reference > Every endpoint, generated from the API's OpenAPI document, with schemas, examples and a live playground. Source: https://flightbyte.dev/docs/reference The reference is generated from the same OpenAPI document the API serves, so it cannot drift from the code. Each operation page shows the parameters, the request and response schemas, example requests in several languages, and a playground that calls the real API with your key. Base URL: `https://api.flightbyte.dev`. Authentication: the `x-api-key` header, see [Authentication](https://flightbyte.dev/docs/authentication). The document itself is at [`/openapi.json`](https://flightbyte.dev/openapi.json): import it into Postman or Insomnia, or generate a client from it. Every operation page opens in the dashboard playground with **Try in playground**, and reads as Markdown at its URL plus `.md`. ## Flights - [Get a flight](https://flightbyte.dev/docs/reference/flights/get-flight): One leg by carrier, number and date, with optional leg selection. - [Search flights](https://flightbyte.dev/docs/reference/flights/search-flights): The same lookup as a query string. - [Flight timeline](https://flightbyte.dev/docs/reference/flights/flight-timeline): The airline's own history of changes for a leg. ## Subscriptions - [Create a subscription](https://flightbyte.dev/docs/reference/subscriptions/create-subscription): Start watching a flight and receiving webhooks. - [List subscriptions](https://flightbyte.dev/docs/reference/subscriptions/list-subscriptions): Filter by status, flight or date. - [Update a subscription](https://flightbyte.dev/docs/reference/subscriptions/update-subscription): Change the URL, the event filter or the metadata. - [Send a test event](https://flightbyte.dev/docs/reference/subscriptions/send-test-event): A signed event through the real pipeline. - [List deliveries](https://flightbyte.dev/docs/reference/subscriptions/list-deliveries): Every attempt with its status and latency. - [Rotate the secret](https://flightbyte.dev/docs/reference/subscriptions/rotate-secret): Replace the signing secret; the old one stops at once. ## Countries - [List countries](https://flightbyte.dev/docs/reference/countries/list-countries): Names in any language, emoji and flag URLs. - [Get a flag](https://flightbyte.dev/docs/reference/countries/get-flag): SVG, 4:3 or 1:1, no key required. --- # List countries > List every country with its flag URLs and emoji. Source: https://flightbyte.dev/docs/reference/countries/list-countries `GET https://api.flightbyte.dev/v1/countries` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `list-countries`. Guide: [Countries and flags](https://flightbyte.dev/docs/countries). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `lang` | query | string | no | Example: `tr`. | ## Responses ### 200 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/countries" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Get a country > Resolve a single ISO 3166-1 alpha-2 country code. Source: https://flightbyte.dev/docs/reference/countries/get-country `GET https://api.flightbyte.dev/v1/countries/{code}` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `get-country`. Guide: [Countries and flags](https://flightbyte.dev/docs/countries). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `code` | path | string | yes | Example: `TR`. | | `lang` | query | string | no | Example: `tr`. | ## Responses ### 200 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/countries/TR" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Get a flag > Serve a country flag as SVG. Source: https://flightbyte.dev/docs/reference/countries/get-flag `GET https://api.flightbyte.dev/v1/flags/{file}` Authentication: none, this endpoint works without a key. Operation id: `get-flag`. Guide: [Countries and flags](https://flightbyte.dev/docs/countries). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `file` | path | string | yes | Example: `tr.svg`. | | `shape` | query | string | no | One of `4x3`, `1x1`. | ## Responses ### 200 `image/svg+xml`. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. ## Example request ```bash curl "https://api.flightbyte.dev/v1/flags/tr.svg" ``` --- # Search flights > Serves from the Redis cache when fresh. Concurrent identical searches are coalesced into a single upstream call. When the flight number operates more than one leg that day, pass `origin` (and optionally `destination`) to pick one; without a selector the provider default is returned and every leg is listed under `alternates`. Source: https://flightbyte.dev/docs/reference/flights/search-flights `GET https://api.flightbyte.dev/v1/flights/search` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `search-flights`. Guide: [Flights](https://flightbyte.dev/docs/flights). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `carrier` | query | string | yes | Airline IATA/ICAO code. Example: `TK`. | | `flightNumber` | query | string | yes | Example: `1848`. | | `date` | query | string | yes | Local departure date at the origin. Example: `2026-09-08`. | | `origin` | query | string | no | Departure airport, when the flight number operates several legs that day. IATA or the upstream code; both resolve to the same leg. Example: `IST`. | | `destination` | query | string | no | Arrival airport, to narrow further. Example: `JFK`. | | `flightId` | query | string | no | Provider flight id from a previous response's `alternates`. Exact match. Example: `1407266450`. | | `refresh` | query | string | no | Bypass the cache. Example: `false`. | ## Responses ### 200 `application/json`: a `FlightSnapshot` object. - `flightKey` (string, required): Example: `TK1848:2026-09-08`. - `carrierCode` (string, required): Example: `TK`. - `flightNumber` (string, required): Example: `1848`. - `date` (string, required): Local departure date at the origin. Example: `2026-09-08`. - `providerFlightId` (string): Example: `1407084032`. - `fetchedAt` (string, required): Example: `2026-09-09T13:20:00.000Z`. - `carrier` (CarrierRef, required) - `iata` (string, required): Example: `TK`. - `icao` (string): Example: `THY`. - `name` (string, required): Example: `Turkish Airlines`. - `operatedBy` (string): Example: `AnadoluJet`. - `codeshares` (array of CodeshareRef, required) - `carrier` (string, required): Example: `A3`. - `name` (string, required): Example: `Aegean Airlines`. - `flightNumber` (string, required): Example: `3160`. - `departure` (FlightEndpoint, required) - `airport` (AirportRef, required) - `iata` (string, required): Example: `IST`. - `icao` (string): Example: `LTFM`. - `code` (object): Upstream airport code, may differ from IATA. - `name` (string, required): Example: `Istanbul Airport`. - `city` (string): Example: `Istanbul`. - `state` (string) - `country` (CountryRef, required) - `code` (string, required): Example: `TR`. - `name` (string, required): Example: `Turkey`. - `emoji` (string, required): Example: `🇹🇷`. - `flagSvg` (string, required): Example: `https://api.example.com/v1/flags/tr.svg`. - `flagSvgSquare` (string, required): Example: `https://api.example.com/v1/flags/tr.svg?shape=1x1`. - `timezone` (string): Example: `Europe/Istanbul`. - `terminal` (string): Example: `M`. - `gate` (string): Example: `A11`. - `baggage` (string): Baggage carousel (arrival only). Example: `16`. - `times` (EndpointTimes, required) - `scheduledGate` (FlightTime) - `utc` (string, required): Example: `2026-09-08T09:10:00.000Z`. - `local` (string): Example: `2026-09-08T12:10:00.000`. - `timezone` (string): Example: `EEST`. - `estimatedGate` (FlightTime, fields as above) - `actualGate` (FlightTime, fields as above) - `scheduledRunway` (FlightTime, fields as above) - `estimatedRunway` (FlightTime, fields as above) - `actualRunway` (FlightTime, fields as above) - `bestTimeUtc` (object): Best known time: actual, else estimated, else scheduled. - `delayMinutes` (object): Gate delay in minutes, negative when early. - `arrival` (FlightEndpoint, required, fields as above) - `diverted` (FlightEndpoint, fields as above) - `status` (FlightStatusInfo, required) - `code` (string, required): Upstream status code (S/A/L/C/D/R/U/NO). Example: `L`. - `label` (string, required): Example: `Arrived`. - `description` (string): Example: `On time`. - `color` (string): Example: `green`. - `phase` (string, required): One of `scheduled`, `boarding`, `departed`, `en_route`, `landed`, `arrived`, `cancelled`, `diverted`, `redirected`, `unknown`. - `delayed` (boolean, required) - `cancelled` (boolean, required) - `diverted` (boolean, required) - `departureGateDelayMinutes` (object) - `arrivalGateDelayMinutes` (object) - `equipment` (EquipmentInfo, required) - `tailNumber` (string): Example: `TC-LTP`. - `iata` (string): Example: `32Q`. - `name` (string): Example: `Airbus A321neo`. - `source` (string): Example: `Actual`. - `duration` (DurationInfo, required) - `scheduledMinutes` (object) - `estimatedMinutes` (object) - `actualMinutes` (object) - `timeline` (array of TimelineEntry, required) - `occurredAt` (string, required): Example: `2026-09-09T10:38:16.000Z`. - `title` (string, required): Example: `Time Adjustment`. - `source` (string): Example: `Airline`. - `events` (array of string, required) - `alternates` (array of FlightLegRef, required): Every leg the upstream lists for this flight number on this date. - `providerFlightId` (string): Example: `1407278900`. - `carrier` (string): Example: `AJA`. - `departureIata` (string, required): Example: `SAW`. - `arrivalIata` (string, required): Example: `AMS`. - `scheduledDepartureLocal` (string): Example: `08:45`. - `scheduledArrivalLocal` (string): Example: `11:30`. - `selected` (boolean, required): True for the leg this snapshot describes. - `fingerprint` (string, required): Example: `9f2b...`. Example: ```json { "flightKey": "TK1848:2026-09-08", "carrierCode": "TK", "flightNumber": "1848", "date": "2026-09-08", "providerFlightId": "1407084032", "fetchedAt": "2026-09-09T13:20:00.000Z", "carrier": { "iata": "TK", "icao": "THY", "name": "Turkish Airlines" }, "operatedBy": "AnadoluJet", "codeshares": [ { "carrier": "A3", "name": "Aegean Airlines", "flightNumber": "3160" } ], "departure": { "airport": { "iata": "IST", "icao": "LTFM", "name": "Istanbul Airport", "city": "Istanbul", "state": "", "country": { "code": "TR", "name": "Turkey", "emoji": "🇹🇷", "flagSvg": "https://api.example.com/v1/flags/tr.svg", "flagSvgSquare": "https://api.example.com/v1/flags/tr.svg?shape=1x1" }, "timezone": "Europe/Istanbul" }, "terminal": "M", "gate": "A11", "baggage": "16", "times": { "scheduledGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "scheduledRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" } } }, "arrival": { "airport": { "iata": "IST", "icao": "LTFM", "name": "Istanbul Airport", "city": "Istanbul", "state": "", "country": { "code": "TR", "name": "Turkey", "emoji": "🇹🇷", "flagSvg": "https://api.example.com/v1/flags/tr.svg", "flagSvgSquare": "https://api.example.com/v1/flags/tr.svg?shape=1x1" }, "timezone": "Europe/Istanbul" }, "terminal": "M", "gate": "A11", "baggage": "16", "times": { "scheduledGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "scheduledRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" } } }, "diverted": { "airport": { "iata": "IST", "icao": "LTFM", "name": "Istanbul Airport", "city": "Istanbul", "state": "", "country": { "code": "TR", "name": "Turkey", "emoji": "🇹🇷", "flagSvg": "https://api.example.com/v1/flags/tr.svg", "flagSvgSquare": "https://api.example.com/v1/flags/tr.svg?shape=1x1" }, "timezone": "Europe/Istanbul" }, "terminal": "M", "gate": "A11", "baggage": "16", "times": { "scheduledGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "scheduledRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" } } }, "status": { "code": "L", "label": "Arrived", "description": "On time", "color": "green", "phase": "scheduled" }, "equipment": { "tailNumber": "TC-LTP", "iata": "32Q", "name": "Airbus A321neo", "source": "Actual" }, "timeline": [ { "occurredAt": "2026-09-09T10:38:16.000Z", "title": "Time Adjustment", "source": "Airline", "events": [ "Actual Gate Arrival changed to 13:35" ] } ], "alternates": [ { "providerFlightId": "1407278900", "carrier": "AJA", "departureIata": "SAW", "arrivalIata": "AMS", "scheduledDepartureLocal": "08:45", "scheduledArrivalLocal": "11:30" } ], "fingerprint": "9f2b..." } ``` ### 404 No such flight, or no leg matching the selector. The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/flights/search?carrier=TK&flightNumber=1848&date=2026-09-12" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Get a flight > Fetch a single flight leg by its natural key. Source: https://flightbyte.dev/docs/reference/flights/get-flight `GET https://api.flightbyte.dev/v1/flights/{carrier}/{flightNumber}/{date}` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `get-flight`. Guide: [Flights](https://flightbyte.dev/docs/flights). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `carrier` | path | string | yes | Example: `TK`. | | `flightNumber` | path | string | yes | Example: `1848`. | | `date` | path | string | yes | Example: `2026-09-08`. | | `origin` | query | string | no | Example: `IST`. | | `destination` | query | string | no | Example: `JFK`. | | `flightId` | query | string | no | | | `refresh` | query | string | no | Example: `false`. | ## Responses ### 200 `application/json`: a `FlightSnapshot` object. - `flightKey` (string, required): Example: `TK1848:2026-09-08`. - `carrierCode` (string, required): Example: `TK`. - `flightNumber` (string, required): Example: `1848`. - `date` (string, required): Local departure date at the origin. Example: `2026-09-08`. - `providerFlightId` (string): Example: `1407084032`. - `fetchedAt` (string, required): Example: `2026-09-09T13:20:00.000Z`. - `carrier` (CarrierRef, required) - `iata` (string, required): Example: `TK`. - `icao` (string): Example: `THY`. - `name` (string, required): Example: `Turkish Airlines`. - `operatedBy` (string): Example: `AnadoluJet`. - `codeshares` (array of CodeshareRef, required) - `carrier` (string, required): Example: `A3`. - `name` (string, required): Example: `Aegean Airlines`. - `flightNumber` (string, required): Example: `3160`. - `departure` (FlightEndpoint, required) - `airport` (AirportRef, required) - `iata` (string, required): Example: `IST`. - `icao` (string): Example: `LTFM`. - `code` (object): Upstream airport code, may differ from IATA. - `name` (string, required): Example: `Istanbul Airport`. - `city` (string): Example: `Istanbul`. - `state` (string) - `country` (CountryRef, required) - `code` (string, required): Example: `TR`. - `name` (string, required): Example: `Turkey`. - `emoji` (string, required): Example: `🇹🇷`. - `flagSvg` (string, required): Example: `https://api.example.com/v1/flags/tr.svg`. - `flagSvgSquare` (string, required): Example: `https://api.example.com/v1/flags/tr.svg?shape=1x1`. - `timezone` (string): Example: `Europe/Istanbul`. - `terminal` (string): Example: `M`. - `gate` (string): Example: `A11`. - `baggage` (string): Baggage carousel (arrival only). Example: `16`. - `times` (EndpointTimes, required) - `scheduledGate` (FlightTime) - `utc` (string, required): Example: `2026-09-08T09:10:00.000Z`. - `local` (string): Example: `2026-09-08T12:10:00.000`. - `timezone` (string): Example: `EEST`. - `estimatedGate` (FlightTime, fields as above) - `actualGate` (FlightTime, fields as above) - `scheduledRunway` (FlightTime, fields as above) - `estimatedRunway` (FlightTime, fields as above) - `actualRunway` (FlightTime, fields as above) - `bestTimeUtc` (object): Best known time: actual, else estimated, else scheduled. - `delayMinutes` (object): Gate delay in minutes, negative when early. - `arrival` (FlightEndpoint, required, fields as above) - `diverted` (FlightEndpoint, fields as above) - `status` (FlightStatusInfo, required) - `code` (string, required): Upstream status code (S/A/L/C/D/R/U/NO). Example: `L`. - `label` (string, required): Example: `Arrived`. - `description` (string): Example: `On time`. - `color` (string): Example: `green`. - `phase` (string, required): One of `scheduled`, `boarding`, `departed`, `en_route`, `landed`, `arrived`, `cancelled`, `diverted`, `redirected`, `unknown`. - `delayed` (boolean, required) - `cancelled` (boolean, required) - `diverted` (boolean, required) - `departureGateDelayMinutes` (object) - `arrivalGateDelayMinutes` (object) - `equipment` (EquipmentInfo, required) - `tailNumber` (string): Example: `TC-LTP`. - `iata` (string): Example: `32Q`. - `name` (string): Example: `Airbus A321neo`. - `source` (string): Example: `Actual`. - `duration` (DurationInfo, required) - `scheduledMinutes` (object) - `estimatedMinutes` (object) - `actualMinutes` (object) - `timeline` (array of TimelineEntry, required) - `occurredAt` (string, required): Example: `2026-09-09T10:38:16.000Z`. - `title` (string, required): Example: `Time Adjustment`. - `source` (string): Example: `Airline`. - `events` (array of string, required) - `alternates` (array of FlightLegRef, required): Every leg the upstream lists for this flight number on this date. - `providerFlightId` (string): Example: `1407278900`. - `carrier` (string): Example: `AJA`. - `departureIata` (string, required): Example: `SAW`. - `arrivalIata` (string, required): Example: `AMS`. - `scheduledDepartureLocal` (string): Example: `08:45`. - `scheduledArrivalLocal` (string): Example: `11:30`. - `selected` (boolean, required): True for the leg this snapshot describes. - `fingerprint` (string, required): Example: `9f2b...`. Example: ```json { "flightKey": "TK1848:2026-09-08", "carrierCode": "TK", "flightNumber": "1848", "date": "2026-09-08", "providerFlightId": "1407084032", "fetchedAt": "2026-09-09T13:20:00.000Z", "carrier": { "iata": "TK", "icao": "THY", "name": "Turkish Airlines" }, "operatedBy": "AnadoluJet", "codeshares": [ { "carrier": "A3", "name": "Aegean Airlines", "flightNumber": "3160" } ], "departure": { "airport": { "iata": "IST", "icao": "LTFM", "name": "Istanbul Airport", "city": "Istanbul", "state": "", "country": { "code": "TR", "name": "Turkey", "emoji": "🇹🇷", "flagSvg": "https://api.example.com/v1/flags/tr.svg", "flagSvgSquare": "https://api.example.com/v1/flags/tr.svg?shape=1x1" }, "timezone": "Europe/Istanbul" }, "terminal": "M", "gate": "A11", "baggage": "16", "times": { "scheduledGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "scheduledRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" } } }, "arrival": { "airport": { "iata": "IST", "icao": "LTFM", "name": "Istanbul Airport", "city": "Istanbul", "state": "", "country": { "code": "TR", "name": "Turkey", "emoji": "🇹🇷", "flagSvg": "https://api.example.com/v1/flags/tr.svg", "flagSvgSquare": "https://api.example.com/v1/flags/tr.svg?shape=1x1" }, "timezone": "Europe/Istanbul" }, "terminal": "M", "gate": "A11", "baggage": "16", "times": { "scheduledGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "scheduledRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" } } }, "diverted": { "airport": { "iata": "IST", "icao": "LTFM", "name": "Istanbul Airport", "city": "Istanbul", "state": "", "country": { "code": "TR", "name": "Turkey", "emoji": "🇹🇷", "flagSvg": "https://api.example.com/v1/flags/tr.svg", "flagSvgSquare": "https://api.example.com/v1/flags/tr.svg?shape=1x1" }, "timezone": "Europe/Istanbul" }, "terminal": "M", "gate": "A11", "baggage": "16", "times": { "scheduledGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualGate": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "scheduledRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "estimatedRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" }, "actualRunway": { "utc": "2026-09-08T09:10:00.000Z", "local": "2026-09-08T12:10:00.000", "timezone": "EEST" } } }, "status": { "code": "L", "label": "Arrived", "description": "On time", "color": "green", "phase": "scheduled" }, "equipment": { "tailNumber": "TC-LTP", "iata": "32Q", "name": "Airbus A321neo", "source": "Actual" }, "timeline": [ { "occurredAt": "2026-09-09T10:38:16.000Z", "title": "Time Adjustment", "source": "Airline", "events": [ "Actual Gate Arrival changed to 13:35" ] } ], "alternates": [ { "providerFlightId": "1407278900", "carrier": "AJA", "departureIata": "SAW", "arrivalIata": "AMS", "scheduledDepartureLocal": "08:45", "scheduledArrivalLocal": "11:30" } ], "fingerprint": "9f2b..." } ``` ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Flight timeline > Upstream change history for a flight leg, newest first. Source: https://flightbyte.dev/docs/reference/flights/flight-timeline `GET https://api.flightbyte.dev/v1/flights/{carrier}/{flightNumber}/{date}/timeline` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `flight-timeline`. Guide: [Flights](https://flightbyte.dev/docs/flights). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `carrier` | path | string | yes | | | `flightNumber` | path | string | yes | | | `date` | path | string | yes | | | `origin` | query | string | no | Example: `IST`. | | `destination` | query | string | no | Example: `JFK`. | ## Responses ### 200 `application/json`: a `FlightTimeline` object. - `flightKey` (string, required): Example: `TK1848:2026-09-08@ATH-IST`. - `count` (number, required): Example: `1`. - `data` (array of TimelineEntry, required) - `occurredAt` (string, required): Example: `2026-09-09T10:38:16.000Z`. - `title` (string, required): Example: `Time Adjustment`. - `source` (string): Example: `Airline`. - `events` (array of string, required) Example: ```json { "flightKey": "TK1848:2026-09-08@ATH-IST", "count": 1, "data": [ { "occurredAt": "2026-09-09T10:38:16.000Z", "title": "Time Adjustment", "source": "Airline", "events": [ "Actual Gate Arrival changed to 13:35" ] } ] } ``` ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12/timeline" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # List subscriptions > List subscriptions, newest first. Source: https://flightbyte.dev/docs/reference/subscriptions/list-subscriptions `GET https://api.flightbyte.dev/v1/subscriptions` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `list-subscriptions`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `status` | query | string | no | One of `active`, `pending`, `paused`, `completed`, `suspended`. | | `carrier` | query | string | no | Example: `TK`. | | `flightNumber` | query | string | no | Example: `1848`. | | `date` | query | string | no | Example: `2026-09-08`. | | `offset` | query | number | no | Example: `0`. | | `limit` | query | number | no | Example: `25`. | ## Responses ### 200 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/subscriptions" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Create a subscription > Validates that the flight exists, seeds the change-detection baseline and starts adaptive polling. The signing secret is returned in full only here. Source: https://flightbyte.dev/docs/reference/subscriptions/create-subscription `POST https://api.flightbyte.dev/v1/subscriptions` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `create-subscription`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `Idempotency-Key` | header | string | no | Replaying the same key within 24h returns the original subscription. | ## Request body `application/json`, required: a `CreateSubscriptionDto` object. - `carrier` (string, required): Example: `TK`. - `flightNumber` (string, required): Example: `1848`. - `date` (string, required): Example: `2026-09-08`. - `origin` (string): Departure airport. Required when the flight number operates more than one leg on that date; the error response lists the available legs. Example: `IST`. - `destination` (string): Arrival airport, to narrow further. Example: `JFK`. - `flightId` (string): Provider flight id from a search response's `alternates`. Exact match. Example: `1407266450`. - `webhookUrl` (string, required): Example: `https://example.com/hooks/flights`. - `secret` (string): Bring your own signing secret. One is generated when omitted. Example: `whsec_my_own_secret`. - `events` (array of string): Change types to deliver. Defaults to all of them. Each one of `status.changed`, `flight.departed`, `flight.arrived`, `flight.cancelled`, `flight.diverted`, `flight.reinstated`, `departure.gate.changed`, `departure.terminal.changed`, `arrival.gate.changed`, `arrival.terminal.changed`, `arrival.baggage.changed`, `departure.scheduled.changed`, `departure.estimated.changed`, `departure.actual.changed`, `arrival.scheduled.changed`, `arrival.estimated.changed`, `arrival.actual.changed`, `delay.increased`, `delay.decreased`, `aircraft.changed`, `aircraft.tail.changed`, `route.changed`, `codeshares.changed`. - `delayThresholdMinutes` (number): From 1 to 240. Example: `1`. - `headers` (object): Extra headers to send with each webhook. - `metadata` (object): Opaque data echoed back in every webhook. ## Responses ### 201 `application/json`: a `Subscription` object. - `id` (string, required): Example: `sub_7Kd91mQx0Zb2`. - `status` (string, required): One of `active`, `pending`, `paused`, `completed`, `suspended`. - `flightKey` (string, required): Example: `TK1848:2026-09-08`. - `carrier` (string, required): Example: `TK`. - `flightNumber` (string, required): Example: `1848`. - `date` (string, required): Example: `2026-09-08`. - `webhookUrl` (string, required): Example: `https://example.com/hooks/flights`. - `secret` (string, required): HMAC-SHA256 signing secret. Returned in full only at creation. Example: `whsec_...`. - `headers` (object): Extra headers sent with every delivery. - `events` (array of string, required): Each one of `status.changed`, `flight.departed`, `flight.arrived`, `flight.cancelled`, `flight.diverted`, `flight.reinstated`, `departure.gate.changed`, `departure.terminal.changed`, `arrival.gate.changed`, `arrival.terminal.changed`, `arrival.baggage.changed`, `departure.scheduled.changed`, `departure.estimated.changed`, `departure.actual.changed`, `arrival.scheduled.changed`, `arrival.estimated.changed`, `arrival.actual.changed`, `delay.increased`, `delay.decreased`, `aircraft.changed`, `aircraft.tail.changed`, `route.changed`, `codeshares.changed`. - `delayThresholdMinutes` (number, required): Ignore delay swings smaller than this. Example: `1`. - `metadata` (object): Opaque data echoed back in every webhook. - `createdAt` (string, required) - `updatedAt` (string, required) - `lastPolledAt` (object) - `lastChangeAt` (object) - `lastDeliveryAt` (object) - `deliveredCount` (number, required): Example: `0`. - `failedCount` (number, required): Example: `0`. - `flightResolved` (boolean, required): False while the flight has not been found upstream yet. Example: `true`. - `statusReason` (object): Why the subscription was suspended or completed. Example: ```json { "id": "sub_7Kd91mQx0Zb2", "status": "active", "flightKey": "TK1848:2026-09-08", "carrier": "TK", "flightNumber": "1848", "date": "2026-09-08", "webhookUrl": "https://example.com/hooks/flights", "secret": "whsec_...", "events": [ "status.changed" ], "delayThresholdMinutes": 1, "deliveredCount": 0, "failedCount": 0, "flightResolved": true } ``` ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions" \ -H "x-api-key: $FLIGHTBYTE_KEY" \ -H "content-type: application/json" \ -d '{ "carrier": "TK", "flightNumber": "1848", "date": "2026-09-12", "webhookUrl": "https://example.com/hooks/flights" }' ``` --- # Get a subscription > Fetch one subscription. The secret is masked. Source: https://flightbyte.dev/docs/reference/subscriptions/get-subscription `GET https://api.flightbyte.dev/v1/subscriptions/{id}` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `get-subscription`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | ## Responses ### 200 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/subscriptions/" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Update a subscription > Update the webhook URL, event filter or delay threshold. Source: https://flightbyte.dev/docs/reference/subscriptions/update-subscription `PATCH https://api.flightbyte.dev/v1/subscriptions/{id}` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `update-subscription`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | ## Request body `application/json`, required: a `UpdateSubscriptionDto` object. - `webhookUrl` (string): Example: `https://example.com/hooks/v2`. - `events` (array of string): Each one of `status.changed`, `flight.departed`, `flight.arrived`, `flight.cancelled`, `flight.diverted`, `flight.reinstated`, `departure.gate.changed`, `departure.terminal.changed`, `arrival.gate.changed`, `arrival.terminal.changed`, `arrival.baggage.changed`, `departure.scheduled.changed`, `departure.estimated.changed`, `departure.actual.changed`, `arrival.scheduled.changed`, `arrival.estimated.changed`, `arrival.actual.changed`, `delay.increased`, `delay.decreased`, `aircraft.changed`, `aircraft.tail.changed`, `route.changed`, `codeshares.changed`. - `delayThresholdMinutes` (number): From 1 to 240. Example: `5`. - `headers` (object) - `metadata` (object) ## Responses ### 200 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl -X PATCH "https://api.flightbyte.dev/v1/subscriptions/" \ -H "x-api-key: $FLIGHTBYTE_KEY" \ -H "content-type: application/json" \ -d '{}' ``` --- # Delete a subscription > Delete a subscription and stop polling if it was the last one. Source: https://flightbyte.dev/docs/reference/subscriptions/delete-subscription `DELETE https://api.flightbyte.dev/v1/subscriptions/{id}` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `delete-subscription`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | ## Responses ### 204 No body. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl -X DELETE "https://api.flightbyte.dev/v1/subscriptions/" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Pause a subscription > Stop polling and delivering without losing the subscription. Source: https://flightbyte.dev/docs/reference/subscriptions/pause-subscription `POST https://api.flightbyte.dev/v1/subscriptions/{id}/pause` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `pause-subscription`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | ## Responses ### 201 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions//pause" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Resume a subscription > Resume a paused or auto-suspended subscription. Source: https://flightbyte.dev/docs/reference/subscriptions/resume-subscription `POST https://api.flightbyte.dev/v1/subscriptions/{id}/resume` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `resume-subscription`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | ## Responses ### 201 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions//resume" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Send a test event > Send a signed test webhook to verify the receiver. Source: https://flightbyte.dev/docs/reference/subscriptions/send-test-event `POST https://api.flightbyte.dev/v1/subscriptions/{id}/test` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `send-test-event`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | ## Responses ### 200 `application/json`. Example: ```json { "eventId": "evt_..." } ``` ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions//test" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # List deliveries > Recent webhook delivery attempts, newest first. Source: https://flightbyte.dev/docs/reference/subscriptions/list-deliveries `GET https://api.flightbyte.dev/v1/subscriptions/{id}/deliveries` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `list-deliveries`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | | `limit` | query | number | no | Example: `25`. | ## Responses ### 200 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/subscriptions//deliveries" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # List sent events > Recent events generated for this subscription. Source: https://flightbyte.dev/docs/reference/subscriptions/list-events `GET https://api.flightbyte.dev/v1/subscriptions/{id}/events` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `list-events`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | | `limit` | query | number | no | Example: `25`. | ## Responses ### 200 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/subscriptions//events" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Reveal the secret > Reveal the signing secret again. Source: https://flightbyte.dev/docs/reference/subscriptions/reveal-secret `GET https://api.flightbyte.dev/v1/subscriptions/{id}/secret` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `reveal-secret`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | ## Responses ### 200 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl "https://api.flightbyte.dev/v1/subscriptions//secret" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Rotate the secret > Generate a new signing secret. Previous signatures stop validating. Source: https://flightbyte.dev/docs/reference/subscriptions/rotate-secret `POST https://api.flightbyte.dev/v1/subscriptions/{id}/secret/rotate` Authentication: your API key in the `x-api-key` header ([Authentication](https://flightbyte.dev/docs/authentication)). Operation id: `rotate-secret`. Guide: [Subscriptions](https://flightbyte.dev/docs/subscriptions). ## Parameters | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | string | yes | | ## Responses ### 201 The body is not described in the OpenAPI document; the guide shows it. ## Errors Every error is JSON with `statusCode`, `error`, `message`, `path`, `timestamp` and `requestId`; [Errors](https://flightbyte.dev/docs/errors) lists what each status code means. - `401`: no key, or a key that is invalid, revoked or expired. - `429`: a quota window is full. Wait for the `Retry-After` header's seconds, then retry. ## Example request ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions//secret/rotate" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` --- # Building a flight delay notification system > What "delayed" means in the data, who needs to hear it and when, the rules that keep a notifier quiet until it matters, a working Node reference with hysteresis, and the edge cases that show up in the first month. Source: https://flightbyte.dev/guides/building-a-flight-delay-notification-system "Send a message when the flight is delayed" is a one-line ticket that hides a dozen decisions. Delayed by how much? Measured from what? Told once, or every time it moves? What about when it recovers? This guide makes those decisions explicitly, then builds the system around a subscription so the polling, the diff and the delivery are not part of it. ## What "delayed" means in the data A delay is a difference between two times, and there are several pairs to choose from. The one people mean is the **gate** delay: estimated (or actual) departure from the gate minus the scheduled departure from the gate. Runway times are for operations people; a passenger boards at a gate. On the arrival side the same subtraction applies to the arrival gate times, and that is the number a driver or a hotel wants. FlightByte does the subtraction for you: `departure.delayMinutes` and `arrival.delayMinutes`, negative when early, absent when no estimate exists yet. The `status.delayed` flag is true when either side is late by the upstream's own definition. Airlines and regulators mostly agree that fifteen minutes is where "late" starts; your users may have an opinion of their own. > **Delay is a flag, not a phase** > > A delayed flight is still `scheduled` (or `en_route`, or `landed`). The phase says where the aircraft is; the delay says how far behind the timetable it is. Branch on both, separately. [Flight phase](https://flightbyte.dev/glossary/flight-phase) has the vocabulary. ## Who is told, and when Three audiences, three different rules: | Audience | What they act on | Threshold | Side | | ----------------------- | --------------------------------- | ----------------------------------- | --------- | | Passenger | Leaving for the airport, boarding | 15 minutes, then every 15 | Departure | | Driver, shuttle, pickup | Leaving for the arrivals door | 20 to 30 minutes | Arrival | | Front desk, ops | Holding a room, re-timing a crew | 30 minutes, cancellation, diversion | Arrival | The threshold is not a constant; it is the smallest change that alters what that person does. A passenger with a two-hour buffer does not need to hear about ten minutes. A dispatcher does not need to hear about anything under twenty. ## The rules that keep it quiet The naive rule, "notify whenever the delay changes", produces a message for every two-minute drift and gets the app's notifications switched off before the cancellation arrives. Three rules replace it: 1. **A threshold**: nothing is said until the delay reaches *T* minutes. 2. **A step**: after the first message, nothing more is said until the delay has moved by another *S* minutes in either direction. 3. **Recovery**: when a delay that was announced drops back under *T*, say so once. Good news is a notification too, and an early aircraft is the one that strands people. Half of that lives in the subscription. `delayThresholdMinutes` stops FlightByte from delivering `delay.increased` and `delay.decreased` for movements smaller than the step, so most of the drift never reaches you. The other half, "have I already told this person", is state on your side, because only you know what was sent. ## The architecture ``` booking made ──▶ POST /v1/subscriptions ──▶ FlightByte polls, diffs, signs, retries │ ▼ your receiver: verify ─▶ dedupe ─▶ 204 ─▶ queue │ ▼ rules: threshold, step, recovery, per booking │ ▼ notifier: SMS / push / Slack / PMS note ─▶ log ``` Five boxes, and the first two are one HTTP call each. What you write is the rules, the templates and the notifier; the [signature guide](https://flightbyte.dev/guides/verifying-webhook-signatures) covers the receiver and the [20-minute guide](https://flightbyte.dev/guides/flight-landed-notification-nodejs) has it in full. ## The subscription Ask for exactly the events the rules use, and let the threshold do the first filter: ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions" \ -H "x-api-key: $FLIGHTBYTE_KEY" \ -H "content-type: application/json" \ -H "Idempotency-Key: booking-BK-1042" \ -d "{ \"carrier\": \"TK\", \"flightNumber\": \"1848\", \"date\": \"2026-09-12\", \"origin\": \"ATH\", \"webhookUrl\": \"https://api.yourapp.com/hooks/flights\", \"secret\": \"$FLIGHTBYTE_WEBHOOK_SECRET\", \"delayThresholdMinutes\": 15, \"events\": [ \"delay.increased\", \"delay.decreased\", \"flight.cancelled\", \"flight.reinstated\", \"flight.diverted\", \"departure.scheduled.changed\" ], \"metadata\": { \"bookingId\": \"BK-1042\", \"audience\": \"passenger\" } }" ``` `departure.scheduled.changed` is on the list because a timetable change days out is not a delay, but it does change what "on time" means, and the passenger should hear about it once. ## The rules, in code A reference implementation of the three rules with per-booking state. Plug it in after the receiver has verified and deduplicated; `notify` is whatever sends your messages. ```js title="delay-rules.mjs" const THRESHOLD = 15; // minutes before anyone is told const STEP = 15; // further movement before they are told again // bookingId -> { announced: number } (a table in production) const state = new Map(); const hhmm = (local) => local?.slice(11, 16) ?? "?"; /** Returns the messages to send for one delivery, and updates the booking's state. */ export function decide(event) { const booking = event.metadata?.bookingId; const side = event.metadata?.audience === "passenger" ? event.flight.departure : event.flight.arrival; const s = state.get(booking) ?? { announced: 0 }; const out = []; for (const change of event.changes) { switch (change.type) { case "delay.increased": { const delay = side.delayMinutes ?? 0; if (delay >= THRESHOLD && delay - s.announced >= STEP) { out.push(`Delayed ${delay} min. Now ${hhmm(side.times.estimatedGate?.local)}.`); s.announced = delay; } break; } case "delay.decreased": { const delay = side.delayMinutes ?? 0; if (s.announced >= THRESHOLD && delay < THRESHOLD) { out.push(`Back on time: ${hhmm(side.bestTimeUtc && side.times.estimatedGate?.local)}.`); s.announced = 0; } else if (s.announced - delay >= STEP) { out.push(`Delay down to ${delay} min. Now ${hhmm(side.times.estimatedGate?.local)}.`); s.announced = delay; } break; } case "departure.scheduled.changed": out.push(`Schedule changed: departs ${hhmm(change.to)} instead of ${hhmm(change.from)}.`); break; case "flight.cancelled": out.push("Cancelled. We will help you rebook."); break; case "flight.reinstated": out.push("Reinstated: the flight is operating again."); s.announced = 0; break; case "flight.diverted": out.push(change.message); // "Flight diverted to ..." in the airline's words break; } } state.set(booking, s); return out; } ``` Three things about it: * **It reads the delay from the snapshot, not the change.** `change.deltaMinutes` says how much the delay moved this time; `side.delayMinutes` says where it stands. The rules are about where it stands. * **The step is hysteresis.** Without it, a delay hovering around the threshold produces "delayed / on time / delayed" three times in an hour. With it, the announced value has to move by a full step before anything is said. * **State is per booking, not per flight.** Two passengers on the same flight with different thresholds are two rows. The flight's state is FlightByte's problem. ## Templates Write them for the person and the moment, with the airport's local time, and keep them to one line each: | Situation | Passenger | Driver | | ------------ | ------------------------------------------- | ----------------------------------------- | | First delay | TK1848 delayed 35 min. Now departing 14:40. | TK1848 into IST now landing 16:52 (+35). | | Further slip | TK1848 now departing 15:10 (+65 min). | Landing pushed to 17:22. | | Recovery | TK1848 back on time, departing 14:05. | Landing back to 16:17. Leave now. | | Cancellation | TK1848 is cancelled. Tap to rebook. | TK1848 cancelled. Pickup BK-1042 on hold. | Every time in those sentences comes from `times.*.local`; never convert a UTC time on the way out. ## Edge cases from the first month * **Early flights.** `delayMinutes` goes negative. A driver wants to hear about minus twenty; a passenger does not care. Treat "early" as its own message for arrival audiences. * **The delay that vanishes.** An estimate can be withdrawn, and `delayMinutes` becomes absent rather than zero. Treat absent as "unknown", not as "on time", and do not announce a recovery on it. * **Cancelled, then not.** Airlines reverse cancellations. `flight.reinstated` exists for this; reset the booking's state when it arrives, because the delay history from before the cancellation is meaningless. * **Diverted.** The arrival airport changed. The arrival delay is now measured against a place the passenger is not going to; hand the situation to a human and stop the delay rules for that booking. * **Overnight departures.** A 23:50 departure that slips to 00:20 is still the flight of the day it was scheduled to leave. Subscribe with the local departure date at the origin; the snapshot's `date` and `flightKey` carry it. * **The same number, twice.** `TK1` flies two legs on one day. A subscription without `origin` is refused with a `400` and the legs; pass the airport the passenger leaves from. [The next guide](https://flightbyte.dev/guides/flight-number-ambiguity) is about nothing else. ## Testing it `POST /v1/subscriptions/{id}/test` proves the pipe. For the rules, write the fixtures: a `flight.updated` with `delay.increased` and `delayMinutes: 35`, another with `delayMinutes: 37` (must be silent), a third with `delay.decreased` to 5 (must announce recovery). The [example receivers repo](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples) has a signed sender that posts any fixture to your local port. ## What it costs A booking is a watched flight: $9 for a hundred a month, $19 for five hundred, $49 for two thousand, $99 for five thousand, overage instead of refusal. The polling, the diff and the retries are inside that; the rules above are yours, and they are sixty lines. The [flight delay API](https://flightbyte.dev/flight-delay-api) page has the subscription and the delay fields on one screen, without the rules. --- # Flight landed notification in 20 minutes with Node.js > A Node service that watches a passenger's flight and messages the driver when it lands, when it reaches the gate and when the bags come out. No polling loop, no cron, no dependencies. Source: https://flightbyte.dev/guides/flight-landed-notification-nodejs The job is old and specific. Someone booked an airport transfer. Their flight lands at some point this evening. The driver needs to leave at the right moment, and nobody wants to refresh a flight tracker for four hours. By the end of this guide you will have a Node service that: * subscribes to a flight when a booking is made, * receives signed webhooks and rejects anything it cannot verify, * messages the driver when the flight lands, when it reaches the gate, and when the baggage belt is assigned, * and stops on its own when the flight is done. There is no polling loop in it. The times in the headings are honest for someone who has Node installed and an API key in hand. ## What you need (0:00) * Node.js 20 or newer. Nothing gets installed. * A FlightByte API key. [Request one](https://flightbyte.dev/get-api-key); Hobby is $9 for a hundred watched flights. * A way to expose a local port over HTTPS while you develop: `cloudflared` or `ngrok`. Put the key in your shell: ```bash export FLIGHTBYTE_KEY=fb_live_... ``` ## Look the flight up (0:00 – 3:00) A lookup proves the key works and shows you the shape every webhook will carry. Flights are addressed by airline code, flight number and the local departure date at the origin. ```bash curl "https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` The answer is the flight snapshot. Three parts of it matter for this job: ```json { "flightKey": "TK1848:2026-09-12@ATH-IST", "status": { "phase": "scheduled", "delayed": false, "cancelled": false, "diverted": false }, "arrival": { "airport": { "iata": "IST", "city": "Istanbul", "timezone": "Europe/Istanbul" }, "baggage": null, "times": { "scheduledGate": { "utc": "2026-09-12T10:45:00.000Z", "local": "2026-09-12T13:45:00.000" } }, "bestTimeUtc": "2026-09-12T10:45:00.000Z" } } ``` `status.phase` is the field you branch on. It walks through `scheduled`, `boarding`, `departed`, `en_route`, `landed` and `arrived`, or ends in `cancelled` or `diverted`. `arrival.baggage` is the belt, `null` until the airport assigns one. Every time has a `utc` and a `local` form, so you never convert anything for the driver's message. > **Two legs, one number** > > Some numbers fly twice a day. If yours does, the lookup lists the legs under `alternates` and a subscription without an `origin` is refused with a `400` that names them. A passenger always knows where they are leaving from, so pass `origin` and move on. ## Write the receiver (3:00 – 8:00) A webhook receiver has four responsibilities, and this one does all four in forty lines with the standard library: verify the signature against the raw bytes, deduplicate on the event id, answer `204` before doing any work, and only then act. ```js title="server.mjs" import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; const SECRET = process.env.FLIGHTBYTE_WEBHOOK_SECRET; // x-flightbyte-signature: t=,v1= // v1 = HMAC-SHA256(secret, `${t}.${raw body}`) function verify(header = "", rawBody) { const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2))); if (!parts.t || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; // replay window const expected = createHmac("sha256", SECRET).update(`${parts.t}.`).update(rawBody).digest("hex"); return parts.v1.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)); } const seen = new Set(); // a table keyed by event id in production createServer((req, res) => { if (req.method !== "POST") return res.writeHead(405).end(); const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const raw = Buffer.concat(chunks); if (!verify(req.headers["x-flightbyte-signature"], raw)) return res.writeHead(401).end(); const event = JSON.parse(raw.toString("utf8")); res.writeHead(204).end(); // acknowledge first; FlightByte waits ten seconds if (seen.has(event.id)) return; // a retry carries the same id and body seen.add(event.id); setImmediate(() => handle(event)); // the work happens off the request }); }).listen(4599, () => console.log("listening on http://localhost:4599")); function handle(event) { console.log(`[${event.type}] ${event.flightKey ?? ""}`); } ``` Three details are worth a sentence each. **Verify the raw body.** The signature covers the exact bytes FlightByte sent. If a framework parses JSON for you and you re-serialise the object, the bytes differ and every delivery fails verification. With Express, mount `express.raw({ type: "application/json" })` on the route; with Fastify, set `rawBody: true`. **Compare in constant time.** `timingSafeEqual` instead of `===`, so a wrong signature takes as long to reject as a nearly-right one. **Answer, then work.** FlightByte counts a delivery as successful when it sees any `2xx` within ten seconds. Your database being slow at 18:40 should not turn into a retry storm. Pick a signing secret now and keep it; the subscription in the next step will use it, so one receiver serves every booking: ```bash export FLIGHTBYTE_WEBHOOK_SECRET=$(openssl rand -hex 32) node server.mjs ``` ## Put it on the internet (8:00 – 10:00) FlightByte needs an HTTPS URL it can reach. During development a tunnel is enough: ```bash cloudflared tunnel --url http://localhost:4599 # or: ngrok http 4599 ``` Copy the public URL it prints. Addresses that resolve to private or loopback ranges are refused at subscription time, which is why the tunnel exists. ## Subscribe to the flight (10:00 – 13:00) One subscription watches one flight on one date and delivers changes to one URL. This is the request your booking flow makes, and it is the only piece of scheduling in the whole system. ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions" \ -H "x-api-key: $FLIGHTBYTE_KEY" \ -H "content-type: application/json" \ -H "Idempotency-Key: booking-BK-1042" \ -d "{ \"carrier\": \"TK\", \"flightNumber\": \"1848\", \"date\": \"2026-09-12\", \"webhookUrl\": \"https:///hooks/flights\", \"secret\": \"$FLIGHTBYTE_WEBHOOK_SECRET\", \"events\": [ \"status.changed\", \"flight.arrived\", \"arrival.baggage.changed\", \"arrival.estimated.changed\", \"flight.cancelled\", \"flight.diverted\" ], \"metadata\": { \"bookingId\": \"BK-1042\" } }" ``` What each field is doing: | Field | Why it is there | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `secret` | Your own signing secret, so every subscription is verified by the one receiver. Omit it and FlightByte generates one per subscription and returns it once. | | `events` | The six change types this job cares about. Leave it out and you get all 23. | | `metadata` | Echoed back in every webhook. Put your booking id here, never the driver's phone number; look that up on your side. | | `Idempotency-Key` | Retrying the booking flow returns the same subscription instead of creating a second one. | The response is `201` with the subscription's `id` and `status`. Within a second or two your receiver logs `[subscription.created] TK1848:2026-09-12@ATH-IST`. That delivery went through the same signing, retry and logging as a real one, so the pipeline is proven before anything about the flight changes. > **Booking a week ahead** > > Subscriptions can be created up to seven days before the flight. If the airline has not published it yet the subscription waits as `pending`, checks on a slow schedule, and sends `subscription.activated` when the flight appears. Your booking flow does not need to know the difference. ## Turn changes into messages (13:00 – 16:00) A `flight.updated` delivery carries the full snapshot under `flight` and a list of `changes`, each with a `type`, the `from` and `to` values, a `deltaMinutes` for time changes, and a sentence you could forward as-is. Replace the placeholder `handle` with this: ```js title="server.mjs (handle)" const hhmm = (local) => local?.slice(11, 16) ?? "?"; function handle(event) { const booking = event.metadata?.bookingId; const flight = event.flight; switch (event.type) { case "subscription.created": case "subscription.activated": return console.log(`${booking}: watching ${event.flightKey ?? "(not published yet)"}`); case "subscription.completed": return console.log(`${booking}: done`); case "subscription.suspended": return pageMe(`${booking}: deliveries keep failing, fix the endpoint`); case "subscription.test": return console.log(`${booking}: test event`); } for (const change of event.changes) { switch (change.type) { case "status.changed": if (flight.status.phase === "landed") notify(booking, `Landed at ${flight.arrival.airport.iata}. Gate in a few minutes.`); break; case "flight.arrived": notify(booking, flight.arrival.baggage ? `At the gate. Bags on belt ${flight.arrival.baggage}.` : "At the gate. Belt not assigned yet."); break; case "arrival.baggage.changed": notify(booking, `Bags on belt ${change.to}.`); break; case "arrival.estimated.changed": if (Math.abs(change.deltaMinutes) >= 15) { const sign = change.deltaMinutes > 0 ? "+" : ""; notify(booking, `Now expected ${hhmm(flight.arrival.times.estimatedGate?.local)} (${sign}${change.deltaMinutes} min).`); } break; case "flight.cancelled": case "flight.diverted": pageMe(`${booking}: ${change.message}`); break; } } } function notify(booking, text) { // Look the driver up by booking id and send the text: SMS, push, WhatsApp, Slack. Your call. console.log(`→ ${booking}: ${text}`); } function pageMe(text) { // Something a human at your company should see now. console.error(`!! ${text}`); } ``` Two decisions in that code are deliberate. **Read the phase from the snapshot, not the change.** `status.changed` fires for any status movement. The snapshot in the same delivery is the truth at that moment, so the code asks `flight.status.phase` rather than parsing `change.to`. **Filter small estimate moves.** Airlines nudge the estimate by two minutes all afternoon. The 15-minute threshold keeps the driver's phone quiet until something changed their plan. If you would rather have FlightByte do the filtering, `delayThresholdMinutes` on the subscription does the same for the `delay.*` events. ## Test it without waiting for a plane (16:00 – 18:00) You do not need a real gate change to see the pipeline end to end. ```bash curl -X POST "https://api.flightbyte.dev/v1/subscriptions/sub_.../test" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` A `subscription.test` event is signed with the real secret and delivered through the real queue; your receiver logs `BK-1042: test event`. To exercise the `flight.updated` branch with a realistic body, the [example receivers repo](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples) has a script that signs a fixture the way FlightByte does and posts it to your local port, with flags to send a tampered or stale copy and watch the `401`. Then read what FlightByte saw: ```bash curl "https://api.flightbyte.dev/v1/subscriptions/sub_.../deliveries" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` Every attempt is there with the HTTP status your endpoint returned, the latency and the attempt number. When a driver says the message never came, this is where you look. ## Ship it (18:00 – 20:00) The service above is complete for one machine. Before it takes real bookings: * **Move `seen` into a table.** A processed-events table keyed by event id, with a date column so you can prune it. Retries can arrive minutes apart and after a restart. * **Store the subscription id with the booking.** You will want `DELETE /v1/subscriptions/{id}` when a booking is cancelled, and the deliveries log when someone asks a question. * **Alert on `subscription.suspended`.** After twenty consecutive failed deliveries FlightByte stops polling the flight. That is your endpoint being down, and it should page someone; `POST /v1/subscriptions/{id}/resume` picks up where it left off. * **Do not assume order.** A retried event can land after a newer one. `createdAt` on the event and `fetchedAt` on the snapshot say which is newer; when in doubt, the snapshot in the latest event is the truth. * **Keep the secret out of the repo.** It is an environment variable on purpose. `POST /v1/subscriptions/{id}/secret/rotate` exists for the day it leaks. That is the whole system: one `POST` when a booking is made, one receiver, and a `switch`. The polling schedule, the diffing, the retries and the log live on the other side of the webhook. ## What it costs A booking is a watched flight. Hobby covers a hundred a month for $9, Starter five hundred for $19, and past that it is $0.02 to $0.08 per extra flight rather than a refusal. Lookups like the one at the top are included and cached. The full table is on [pricing](https://flightbyte.dev/pricing). ## Where next * The same receiver in [Python and Go](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples), with the signed test sender. * [Webhooks](https://flightbyte.dev/docs/webhooks): every event and change type, the retry schedule, and the checklist for a receiver that behaves. * [For airport transfers](https://flightbyte.dev/for/airport-transfers): the product view of this exact job, including which plan fits how many transfers. --- # TK1 flies twice a day: the flight number ambiguity nobody documents > A flight number is not a flight. Some numbers operate two legs on one date, most APIs hand you both and let you guess, and the guess is silent. What the ambiguity looks like in the data, how FlightByte refuses to guess, and the three lines of code that handle it. Source: https://flightbyte.dev/guides/flight-number-ambiguity On most days, Turkish Airlines flight 1 leaves Istanbul Sabiha Gökçen for Amsterdam in the morning and Istanbul Airport for New York in the afternoon. Two aircraft, two crews, two sets of passengers, one number. Ask a flight API "where is TK1 today" and the honest answer is a question back: which one? Very few APIs ask it. They return an array, or the first match, and the integration that called them watches whichever leg came first. Nothing fails. A driver goes to the airport for a New York arrival that is actually a morning flight from Amsterdam, and the bug is filed as "the flight data was wrong". ## Why numbers repeat A flight number is a marketing label for a route in a timetable. Airlines have a few thousand of them and many more legs to cover, so the same number is reused: * **Every day.** `TK1848` tomorrow is a different aircraft from `TK1848` today. Every lookup needs a date, and the date is the local departure date at the origin, which matters for anything that leaves near midnight. * **Twice on one day.** The `TK1` case: two unrelated legs sharing a number, usually a short morning rotation and a long-haul afternoon one. * **Through-flights.** One number, one aircraft, two legs with a stop: A to B, then B to C. The feeds publish both legs under the same number and date. A passenger on the second leg wants the second leg's arrival; a passenger travelling A to C wants both. * **Codeshares.** Not a repeat of the same number, but the same aircraft under several numbers, which is the mirror-image problem. [Codeshare flight](https://flightbyte.dev/glossary/codeshare-flight) covers it. How often it bites depends on which airlines your customers fly. It is not rare: any hub carrier with a mix of domestic rotations and long-haul flights has numbers like this, and a booking system that sees a few hundred flights a week meets one. ## What the ambiguity looks like Look up `TK1` for a date without saying which leg, and FlightByte describes the upstream's default leg while listing every leg under `alternates`, with `selected: true` on the one described: ```json { "flightKey": "TK1:2026-09-12@SAW-AMS", "departure": { "airport": { "iata": "SAW" } }, "arrival": { "airport": { "iata": "AMS" } }, "alternates": [ { "providerFlightId": "1407278900", "departureIata": "SAW", "arrivalIata": "AMS", "scheduledDepartureLocal": "08:45", "scheduledArrivalLocal": "11:30", "selected": true }, { "providerFlightId": "1407278912", "departureIata": "ISL", "arrivalIata": "JFK", "scheduledDepartureLocal": "14:10", "scheduledArrivalLocal": "18:05", "selected": false } ] } ``` That is enough to build a picker, and it is all a lookup will do on its own: a lookup is a question, and a question can come back with a default. A subscription is a commitment to watch one aircraft for a day, and there FlightByte will not guess. ## The refusal Subscribe to an ambiguous number without a selector and the answer is a `400` with the legs: ```json { "statusCode": 400, "error": "Bad Request", "message": "TK1 operates 2 legs on 2026-09-12. Pass \"origin\" to choose one.", "legs": [ { "origin": "SAW", "destination": "AMS", "carrier": "TK", "scheduledDepartureLocal": "08:45", "flightId": "1407278900" }, { "origin": "ISL", "destination": "JFK", "carrier": "TK", "scheduledDepartureLocal": "14:10", "flightId": "1407278912" } ] } ``` This is the design decision the guide is about. An error at subscription time costs the caller one retry. A silent wrong guess costs a passenger a driver. Between an integration that fails loudly on a Tuesday and one that fails quietly on a Saturday night, the loud one is the kind you can fix. Pass a selector and the refusal goes away: | Selector | Use when | Example | | ------------- | ---------------------------------------------------------------------------- | -------------------------- | | `origin` | Almost always. A passenger knows where they are leaving from. | `"origin": "ISL"` | | `destination` | You know where they are going and not where they start; hotels and pickups. | `"destination": "JFK"` | | `flightId` | You already have a leg from a lookup's `alternates` and want that exact one. | `"flightId": "1407278912"` | Airport codes are accepted in the IATA form and in the upstream's own form when they differ (Istanbul Airport is `IST` and `ISL`), so whichever your booking system stored will match. Once the leg is resolved the flight key gains its suffix, `TK1:2026-09-12@ISL-JFK`, and that is the string to store with the booking. [Flight number vs flight ID](https://flightbyte.dev/glossary/flight-number-vs-flight-id) has the vocabulary. > **Selectors that match nothing** > > A selector that matches no leg is a `404`, and the message names what exists: `TK1 on 2026-09-12 has no leg matching origin=LHR. Available: SAW-AMS, ISL-JFK.` The same message shape covers a typo in the airport code and a booking for a route the number no longer flies. ## Handling it in three lines The right time to resolve a leg is when the booking is made, because that is when a human who knows the answer is present. Most booking flows already have the origin; pass it and the `400` never happens. For flows that do not, catch it and ask: ```ts const res = await fetch("https://api.flightbyte.dev/v1/subscriptions", { method: "POST", headers: { "x-api-key": KEY, "content-type": "application/json", "idempotency-key": `booking-${booking.id}` }, body: JSON.stringify({ carrier, flightNumber, date, webhookUrl, secret, metadata: { bookingId: booking.id } }), }); if (res.status === 400) { const { legs } = await res.json(); if (legs) { // Two legs, one number. The booking's airport decides; otherwise ask the customer. const pick = legs.find((leg) => leg.destination === booking.arrivalAirport) ?? (await askCustomer(legs)); return subscribe({ ...booking, origin: pick.origin }); } } ``` The `legs` array is designed to be shown to a person as-is: origin, destination and a local departure time are what a customer needs to say "the afternoon one". ## Through-flights A number with a stop produces two legs that are not alternatives but a sequence: `LH123` from Frankfurt to Nairobi, continuing to Dar es Salaam under the same number. The `alternates` list shows both; the selectors work the same way. Which leg to watch depends on what you need: * **A pickup at the final destination**: subscribe with `destination` set to it. That leg's `flight.arrived` is the one that matters. * **A passenger boarding at the origin**: subscribe with `origin`. Gate changes and delays at the first airport are on that leg. * **Both**: two subscriptions, one per leg, with the same `metadata`. Two watched flights, because they are two aircraft movements. ## The date boundary Ambiguity's quieter cousin. A flight that departs Istanbul at 23:50 and lands in Bangkok the next afternoon is, to every feed, the flight of the day it left. A booking system that stores the arrival date and subscribes with it asks about a flight that does not exist, gets a `pending` subscription that waits for a flight that never appears, and receives `subscription.completed` a day late. Store the local departure date at the origin. The snapshot's `date` and `flightKey` carry it, and every time in the snapshot has a `local` form so the arrival date is never in doubt on the other side. ## What other APIs do For the record, and for migrations: * **aviationstack** returns both legs in the `data` array for the number and date. Nothing marks one as default; your code picks. * **AeroDataBox** returns an array too, ordered by time; the same applies. * **AeroAPI** returns about fourteen days of the ident, each with a `fa_flight_id`; you filter by date and origin, and its own documentation recommends the ICAO ident to reduce ambiguity. None of them is wrong to return a list. The difference is what happens when you commit to watching one: a list is fine for a question and dangerous for a subscription, and a subscription that refuses to guess is the only kind that never watches the wrong aircraft. --- # Flight status push notifications: one webhook, every device on the flight > How a travel app turns one signed webhook into the right push on every phone that cares. The table that maps flights to devices, a receiver that answers fast and sends later, copy per change type, collapse keys so a new gate replaces the old one, quiet rules, and driving an iOS Live Activity from the same event. Source: https://flightbyte.dev/guides/flight-push-notifications-fan-out A gate change is one fact. Forty of your users are on that flight, some with two devices, in six languages, and three of them muted delay alerts last week. The work of a travel app is not learning the fact. It is delivering it forty-something times, once each, in the right words, without waking anyone who asked not to be woken. FlightByte sends the fact to your server as a signed webhook. It never sees your users, their device tokens or your copy. This guide is the part on your side: from one delivery to every device on the flight. ## The shape of it ```text FlightByte ──POST──▶ receiver ──▶ queue ──▶ fan-out worker ──▶ APNs / FCM / Expo │ │ └─ 204 in milliseconds └─ reads flight → users → devices ``` Two processes, on purpose. The receiver verifies, records and acknowledges. The worker does the slow part. A delivery has ten seconds to be answered with a `2xx`; a push provider having a bad afternoon should never be the reason a webhook is retried. ## Three tables ```sql create table watched_flights ( id text primary key, flight_key text unique not null, subscription text, snapshot jsonb -- the latest one, for the trip screen ); create table flight_followers ( watched_flight text references watched_flights(id), user_id text not null, role text not null default 'passenger', -- or 'watcher' primary key (watched_flight, user_id) ); create table devices ( user_id text not null, token text primary key, platform text not null, -- ios | android | expo locale text not null default 'en', muted text[] not null default '{}' -- change types this user silenced ); ``` **Subscribe per distinct flight, never per user.** Fifty users on `TK1848` are one row in `watched_flights`, one subscription, one line on the bill, and fifty rows in `flight_followers`. A user who adds the flight later joins the followers table; nothing is sent to FlightByte. A user who removes it leaves the table; the subscription is deleted only when the last follower goes. [The import guide](https://flightbyte.dev/guides/from-booking-email-to-watched-flight) covers how those rows get created, including the case where two codeshare numbers are one aircraft. The `role` column earns its place quickly. The person flying wants the gate. The person collecting them wants the landing and the belt, and does not want boarding calls. ## The receiver ```ts import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const app = express(); const SECRET = process.env.FLIGHTBYTE_WEBHOOK_SECRET!; app.post("/hooks/flights", express.raw({ type: "application/json" }), async (req, res) => { const header = req.header("x-flightbyte-signature") ?? ""; const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const expected = createHmac("sha256", SECRET).update(`${parts.t}.`).update(req.body).digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; const valid = parts.v1?.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)); if (!fresh || !valid) return res.status(401).end(); const event = JSON.parse(req.body.toString()); // A retry carries the same id. Insert-or-ignore is the whole deduplication. const firstTime = await db.insertIgnore("processed_events", { id: event.id }); if (firstTime) await queue.add("fan-out", event); res.status(204).end(); }); ``` Verify against the raw bytes, before parsing. [The signature guide](https://flightbyte.dev/guides/verifying-webhook-signatures) explains why, and has the same receiver for other frameworks. ## The worker ```ts queue.process("fan-out", async (event) => { const flightId = event.metadata.watchedFlight; // Out-of-order protection: a retry can arrive after a newer event. const stored = await db.getSnapshot(flightId); if (stored && stored.fetchedAt > event.flight.fetchedAt) return; await db.saveSnapshot(flightId, event.flight); if (event.type !== "flight.updated") return handleLifecycle(event); const followers = await db.followersWithDevices(flightId); for (const follower of followers) { const changes = event.changes.filter( (c) => wantedBy(follower.role, c.type) && !follower.muted.includes(c.type), ); if (changes.length === 0) continue; const message = compose(changes, event.flight, follower.locale); for (const device of follower.devices) { await push(device, message, { collapseId: `flight:${flightId}:${message.topic}` }); } } }); ``` Saving the snapshot first means the trip screen is correct even for users who receive no push at all. The app opening an hour later should show the new gate whether or not anybody was notified. ## One delivery, one push A `flight.updated` event can carry several changes. A gate move and a new departure estimate often arrive together, because the airline published them together. Send one notification: > **TK1848 · Gate B14** > Athens to Istanbul, now departing 14:40, 35 min late Rank the changes and let the most important one be the title: | Priority | Change types | Title | | -------- | ---------------------------------------------------------- | -------------------------------- | | 1 | `flight.cancelled`, `flight.diverted`, `flight.reinstated` | TK1848 is cancelled | | 2 | `departure.gate.changed`, `departure.terminal.changed` | Gate changed to B14 | | 3 | `delay.increased`, `delay.decreased` | Now departing 14:40, 35 min late | | 4 | `flight.departed`, `flight.arrived` | TK1848 has landed | | 5 | `arrival.baggage.changed` | Bags on belt 7 | Everything below the winner goes in the body, or is dropped. Every time in the copy comes from `times.*.local` in the snapshot; the airport's clock is the one the passenger is looking at. > **Filter at the source as well** > > Set `events` on the subscription to the change types you will ever push, and `delayThresholdMinutes` to the smallest delay you would announce, ten or fifteen for passengers. Changes below the threshold are never delivered, so they cost you no handler time and no deduplication rows. Aircraft swaps, runway times and codeshare lists are noise to a passenger; leave them out. ## Collapse, do not stack The gate moves from A11 to A22 at 13:02 and to B14 at 13:20. A phone that was in a pocket should show one notification, the current one. All three transports support replacing a notification by key: | Transport | How | | --------- | ---------------------------------------------------------------------------------------------------------- | | APNs | `apns-collapse-id` header, 64 bytes at most | | FCM | `android.collapse_key` for delivery, and `android.notification.tag` to replace what is on screen | | Expo | No collapse field of its own; set `channelId` and use a `data` key the app uses to dismiss the earlier one | Key it on flight and topic, `flight:flt_8c1e:gate`, not on flight alone. A delay update should not erase the gate. ## Quiet rules The rules that decide whether a push is worth sending live in your code, because they are about your users: * **Roles.** Passengers: gate, terminal, delay, cancellation, boarding-relevant times. Watchers: departure, arrival estimate, landing, belt. * **Windows.** A delay announced twelve hours before departure is useful at 09:00 and hostile at 03:00. Hold non-urgent pushes overnight in the user's zone and send a summary in the morning. Cancellations and gate changes inside three hours of departure ignore the window. * **Hysteresis.** `delayThresholdMinutes` stops the small drifts. If you still see 35, 40, 35, 40, apply a step of your own: announce again only after another fifteen minutes of movement. [The delay guide](https://flightbyte.dev/guides/building-a-flight-delay-notification-system) has the rule set and the reasons. * **The last one.** `arrival.baggage.changed` is the most appreciated push of the trip and the cheapest to get right. Send it, then go quiet for that flight. ## Driving a Live Activity An iOS Live Activity is the same event with a different destination. Start the activity from the app, or remotely with a push-to-start token, when the flight comes within a few hours of departure. Store the activity's push token next to the device. Then, in the worker, send an ActivityKit update alongside the normal alert: ```http POST /3/device/{activityPushToken} apns-push-type: liveactivity apns-topic: com.yourapp.push-type.liveactivity apns-priority: 10 ``` ```json { "aps": { "timestamp": 1789305971, "event": "update", "content-state": { "phase": "boarding", "gate": "B14", "departsLocal": "14:40", "delayMinutes": 35 } } } ``` `content-state` must decode into the `ContentState` struct your widget declares, so build it from the snapshot with the same field names on both sides. Use priority 10 for gate changes and cancellations and priority 5 for estimate drift; iOS budgets high-priority activity updates. When `subscription.completed` arrives, send `"event": "end"` with a final state and a `dismissal-date` a few minutes out. Because every delivery carries the whole snapshot, the activity never has to be patched. Each update is a complete picture, and an update that was lost is repaired by the next one. ## Lifecycle events Three are worth handling, none of them with a push: | Event | Do | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `subscription.activated` | The airline published the flight. Fill in the trip screen: terminals, aircraft, local times. | | `subscription.suspended` | Your endpoint failed twenty deliveries in a row. Page a human, fix it, call `resume`, then read the events log for what was missed. | | `subscription.completed` | The flight is at the gate and the belt window has passed. End the Live Activity, archive the flight, keep the trip. | ## Testing the fan-out `POST /v1/subscriptions/{id}/test` sends a real signed `subscription.test` through the real pipeline, which proves the receiver and the queue. For the composer, replay recorded payloads: `GET /v1/subscriptions/{id}/events` returns exactly what was sent, and the [example receivers repo](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples) includes a script that signs and posts any fixture to a local port. Keep one fixture per row of the priority table and assert on the title. ## What it costs The bill counts distinct flights, not followers and not pushes. An app whose users share popular routes pays less per user as it grows, because the fiftieth follower of a flight is free. [The travel apps page](https://flightbyte.dev/for/travel-apps) has the table from fifty flights a month to ten thousand. --- # Flight status codes explained: S, A, L, C, D, R and what phase they map to > The letter codes that airline status feeds use, what each one hides, how FlightByte turns them into a phase you can branch on, and how the same moments are named by aviationstack, AeroDataBox and AeroAPI. Source: https://flightbyte.dev/guides/flight-status-codes-explained Most airline status feeds describe a flight with a single letter. The letters are compact, stable, and older than JSON, and each one covers more ground than it looks. This guide is what they mean, what they leave out, and how a phase fills the gap. ## The codes | Code | Name | What it actually covers | | ---- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `S` | Scheduled | Published and not yet departed. Includes flights that are delayed, boarding, or pushing back in a minute; `S` says nothing about any of that. | | `A` | Active | Left the gate. Covers taxiing out, airborne, and on approach: the whole span between pushback and touchdown. | | `L` | Landed | On the ground at the destination. Covers taxiing in, at the gate, and bags on the belt. | | `C` | Cancelled | Not operating. Can be reversed, and occasionally is. | | `D` | Diverted | Landed, or landing, somewhere other than the planned destination. | | `R` | Redirected | Re-routed to a different destination before landing, without the "emergency" flavour of a diversion. Rare. | | `U` | Unknown | The feed has no usable status. Common far ahead of departure and for flights the feed does not track live. | | `NO` | Not operational | The number exists in the schedule but did not operate on this date. | | `DN` | Data needed | The feed is waiting on a source. Treated as cancelled in practice: nothing will arrive. | Three of these are ambiguous in a way that matters to anyone writing a notification. `S` includes "delayed". `A` includes "still on the taxiway" and "an hour from landing". `L` includes "not at the gate yet" and "bags on belt 7 twenty minutes ago". The code is where the aircraft was last filed, not what a passenger is experiencing. ## What a phase adds A phase is the code plus the times. FlightByte derives it like this: | Code | Phase | Decided by | | --------- | ------------ | ------------------------------------------------------------------------------------ | | `S` | `scheduled` | | | `A` | `departed` | No actual runway departure time yet: left the gate, not airborne. | | `A` | `en_route` | An actual runway departure time exists. | | `L` | `landed` | No actual gate arrival time yet. | | `L` | `arrived` | An actual gate arrival time exists. | | `C`, `DN` | `cancelled` | | | `D` | `diverted` | | | `R` | `redirected` | | | `U`, `NO` | `unknown` | Unless the feed's live state says the aircraft is airborne, which yields `en_route`. | A `boarding` phase is reserved for feeds that report gate activity; most flights go from `scheduled` straight to `departed`. Delay is deliberately not a phase. A flight forty minutes late is still `scheduled`, with `status.delayed: true` and `departure.delayMinutes: 40`. Two questions, two fields. ```json { "status": { "code": "A", "label": "Active", "phase": "departed", "delayed": true, "cancelled": false, "diverted": false, "departureGateDelayMinutes": 22 } } ``` `code` and `label` are the upstream's words, kept for display and debugging. `phase` is the enum; branch on it. See [Flight phase](https://flightbyte.dev/glossary/flight-phase) for the full list and [Flights → Phases](https://flightbyte.dev/docs/flights#phases) in the reference. ## The transitions worth an event Most integrations do not care about every phase, only about four moments. FlightByte names them so a subscription can ask for just those: | Event | Fires when the phase becomes | | ------------------- | ---------------------------------------------- | | `flight.departed` | `departed` or `en_route` from anything earlier | | `flight.arrived` | `arrived` | | `flight.cancelled` | `cancelled` | | `flight.diverted` | `diverted` | | `flight.reinstated` | anything from `cancelled` | | `status.changed` | anything else, including `landed` | To be told about touchdown specifically, subscribe to `status.changed` and read `flight.status.phase` in the delivery; the [20-minute guide](https://flightbyte.dev/guides/flight-landed-notification-nodejs) does exactly that. ## The same moments, in other vendors' words If you are migrating, or reading two feeds at once, this is the table to keep open. | Moment | FlightByte phase | aviationstack | AeroDataBox | AeroAPI | | ----------------------- | ----------------------- | ------------- | ----------------------------------------------- | ------------------------ | | Published, not departed | `scheduled` | `scheduled` | `Expected`, `CheckIn`, `Boarding`, `GateClosed` | "Scheduled" | | Late, not departed | `scheduled` + `delayed` | `scheduled` | `Delayed` | "Delayed" | | Left the gate | `departed` | `active` | `Departed` | "Taxiing / Left Gate" | | Airborne | `en_route` | `active` | `EnRoute`, `Approaching` | "En Route" | | On the runway | `landed` | `landed` | `Arrived` | "Landed / Taxiing" | | At the gate | `arrived` | `landed` | `Arrived` | "Arrived / Gate Arrival" | | Cancelled | `cancelled` | `cancelled` | `Canceled`, `CanceledUncertain` | "Cancelled" | | Diverted | `diverted` | `diverted` | `Diverted` | "Diverted" | | Nothing usable | `unknown` | — | `Unknown` | — | Two things the table shows. aviationstack's `active` and `landed` have the same two-in-one problem as `A` and `L`, so a migration from it gains a distinction it never had. AeroAPI's status is a sentence, useful to display and fragile to branch on; its `actual_off` and `actual_on` times are the reliable signal, which is the same trick the phase derivation uses. > **A rule for any feed** > > Whatever the vocabulary, the actual times are the truth. A status that says "active" with no actual take-off time is a flight on the taxiway; a status that says "landed" with an actual gate time is a flight whose passengers are already walking. Branch on the times when the words are ambiguous, and let the phase do it for you when it can. --- # From a booking email to a watched flight: lookup, legs, codeshares > An itinerary app starts with whatever a confirmation email said. How to turn that into a flight record you can trust and a subscription you pay for once: what to parse, when a lookup can confirm it, how to pick the leg, how to stop two codeshare numbers becoming two watches, and when to subscribe. Source: https://flightbyte.dev/guides/from-booking-email-to-watched-flight An itinerary manager does not begin with a flight. It begins with an email somebody forwarded: a confirmation from an airline, an agency or a corporate booking tool, in one of a few thousand layouts. Your parser, or your model, pulls a flight number and a date out of it. Everything after that depends on how much of that pair you can trust. This guide is the pipeline between "we extracted `TK 1848, 12 Sep`" and "this flight is watched, once, and every user on it will hear about the gate". It assumes you already parse the email; it is about what to do with the result. ## What the email gives you, and what it does not | From the email | Reliable? | Notes | | ------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- | | Airline code and flight number | Mostly | Often the [marketing number](https://flightbyte.dev/glossary/operating-vs-marketing-carrier), not the one on the airport board. | | Departure date | Yes | Local to the origin, which is exactly what the API wants. | | Origin and destination airports | Yes | Keep them. They are your leg selector. | | Scheduled times | At time of booking | Schedules move in the months between booking and flying. | | Terminal, gate, aircraft | Rarely, and stale | Do not store these from the email as facts. | | "Operated by" | Sometimes, in small print | Useful as a cross-check, not as a key. | Store four things as the identity of the flight: carrier, number, local departure date, origin. Store the destination too. Treat every other field as a display value that will be replaced. > **The date is the easy part, for once** > > FlightByte addresses a flight by the local departure date at the origin. A confirmation email states exactly that date, in the passenger's terms. There is no UTC conversion to get wrong here, and a 23:50 departure that lands tomorrow is still today's flight. ## Step 1: confirm it, if the calendar allows A lookup turns the pair into a full record: ```bash curl "https://api.flightbyte.dev/v1/flights/TK/1848/2026-09-12?origin=ATH" \ -H "x-api-key: $FLIGHTBYTE_KEY" ``` Inside the live window, which runs from a few days back to a couple of days ahead, this returns the snapshot: terminals, gates when assigned, the aircraft type and tail, the operating airline, every codeshare number, and times in both UTC and airport-local form. That is enough to replace everything the email guessed at. Outside the window the answer is `404`. That is not an error in your parser. Most bookings are forwarded weeks or months ahead, and no operational record exists yet. The rule is simple: * **Within the window:** look up, store the snapshot's display fields, mark the flight confirmed. * **Beyond it:** keep what the email said, mark the flight unconfirmed, and let the subscription confirm it later. Do not poll a future flight waiting for it to appear. That is what a pending subscription is for, and it is the next-to-last step here. ## Step 2: pick the leg A flight number is not a flight. `TK1` is SAW to AMS in the morning and IST to JFK in the afternoon, same date. A lookup without a selector describes one of them and lists all of them: ```json { "flightKey": "TK1:2026-09-12@SAW-AMS", "alternates": [ { "departureIata": "SAW", "arrivalIata": "AMS", "scheduledDepartureLocal": "08:45", "selected": true }, { "departureIata": "IST", "arrivalIata": "JFK", "scheduledDepartureLocal": "13:40", "selected": false } ] } ``` You never need to guess, because the email told you the origin. Pass `origin` on every lookup and every subscription. If the origin you parsed matches nothing, the lookup answers `404` and names the legs that exist, which is a strong signal that the parse was wrong and worth a second look before the user sees it. The resolved leg has a canonical `flightKey`, for example `TK1:2026-09-12@IST-JFK`. Use that as the identity of the flight in your own tables from the moment you have it. [The ambiguity guide](https://flightbyte.dev/guides/flight-number-ambiguity) covers the odd cases. ## Step 3: one aircraft, one watch Two of your users are on the same aircraft. One booked with Lufthansa and forwarded `LH8320`. The other booked with United and forwarded `UA906`. Parsed naively those are two flights, two subscriptions, two line items, and two sets of notifications that say the same thing a second apart. The snapshot gives you what you need to notice: ```json { "carrier": { "iata": "LH", "name": "Lufthansa" }, "operatedBy": "United Airlines", "codeshares": [ { "carrier": "UA", "name": "United Airlines", "flightNumber": "906" }, { "carrier": "AC", "name": "Air Canada", "flightNumber": "5310" } ] } ``` Keep an alias table next to your flights: ```sql create table watched_flights ( id text primary key, -- your own id flight_key text unique not null, -- from the snapshot subscription text, -- sub_… once created local_date date not null, origin text not null ); create table flight_aliases ( carrier text not null, flight_number text not null, local_date date not null, origin text not null, watched_flight text not null references watched_flights(id), primary key (carrier, flight_number, local_date, origin) ); ``` When a snapshot arrives, insert one alias for the number you asked with and one for every entry in `codeshares[]`, all pointing at the same watched flight. When the next booking is parsed, check the alias table first. `UA906` on that date from that origin is already there; attach the user to the existing flight and do not subscribe again. Subscribing with either number watches the same aircraft, so it does not matter which user's number created the watch. For display, show each user the number on their own booking and mention the operator when `operatedBy` is present, because that is the name on the board they will be standing under. > **Unconfirmed flights cannot be deduplicated yet** > > A booking months out has no snapshot and therefore no codeshare list. Two users on one aircraft under two numbers will look like two flights until the first of them is confirmed. Merge them then: the alias insert will collide, and the collision is your signal. ## Step 4: subscribe when it is time Subscriptions can be created up to seven days before departure. A daily job is all it takes: ```ts for (const flight of await flightsDepartingWithin(7 /* days */, { unsubscribed: true })) { const res = await fetch("https://api.flightbyte.dev/v1/subscriptions", { method: "POST", headers: { "x-api-key": process.env.FLIGHTBYTE_KEY!, "content-type": "application/json", "idempotency-key": `watch:${flight.id}`, }, body: JSON.stringify({ carrier: flight.carrier, flightNumber: flight.number, date: flight.localDate, origin: flight.origin, webhookUrl: "https://api.yourapp.com/hooks/flights", secret: process.env.FLIGHTBYTE_WEBHOOK_SECRET, delayThresholdMinutes: 10, metadata: { watchedFlight: flight.id }, }), }); const sub = await res.json(); await saveSubscription(flight.id, sub.id, sub.status, sub.flightKey); } ``` Three details carry weight: * **The idempotency key** makes the job safe to rerun. The same key returns the same subscription. * **`metadata`** comes back in every webhook. It is how a delivery finds its row without a lookup by flight number. * **`status`** may be `pending`. If the airline has not published the flight yet, the subscription waits, is checked on a slow schedule, and sends `subscription.activated` with the snapshot the moment it appears. That event is where an unconfirmed flight becomes confirmed: write the display fields, insert the aliases, merge any duplicate the aliases reveal. If the date passes and the flight never appears, `subscription.completed` arrives without a flight. Tell the user the booking could not be matched rather than showing a flight that silently never updates. ## Step 5: keep the itinerary honest From here the receiver does the work. Each `flight.updated` delivery carries the full snapshot plus the list of changes, so the trip screen can be updated from the same event that triggers the push. Two rules keep the itinerary from lying: * **Display times come from `times.*.local`.** Never convert a UTC value on the way to the screen; the snapshot already carries the airport-local time and the zone abbreviation. * **The newest snapshot wins.** Retries can arrive after a newer event. Compare `flight.fetchedAt` before overwriting. The fan-out from one delivery to every device on the flight is [its own guide](https://flightbyte.dev/guides/flight-push-notifications-fan-out). Connections, where the interesting fact lives between two flights, are [another](https://flightbyte.dev/guides/missed-connection-alerts). ## What this costs A watched flight is one subscription, counted when it is created. It is not a user and not a booking. With the alias table doing its job, the number that reaches your bill is the number of distinct aircraft your users board in a month: | Distinct flights a month | Plan | Monthly | | ------------------------ | ------------------------------- | ------- | | 100 | Hobby | $9 | | 500 | Starter | $19 | | 2,000 | Growth | $49 | | 5,000 | Scale | $99 | | 10,000 | Scale plus 5,000 extra at $0.02 | $199 | Lookups are separate, cached, and included by the tens of thousands, so confirming a flight at import and refreshing the trip screen do not move that number. The full table is on the [pricing page](https://flightbyte.dev/pricing). --- # How adaptive polling works: 3 minutes at the gate, 12 hours a week out > The function that decides when FlightByte next asks about a flight, as it runs in production. Why the phase decides after departure and the clock before it, what a flight costs in polls, and the safety nets around the queue. Source: https://flightbyte.dev/guides/how-adaptive-polling-works Airlines do not push. Under every flight webhook anyone sells there is a poll, and the whole product is the answer to one question: *when do we ask next?* Ask too often and a flight three weeks out costs as much as one at the gate. Ask too rarely and a gate change reaches the passenger after they have walked to the wrong pier. This is the function that answers it, as it runs in FlightByte today, with the numbers and the reasons those numbers exist. If you are building your own poller, take it. If you are deciding whether to, [the previous guide](https://flightbyte.dev/guides/why-polling-a-flight-api-costs-more-than-you-think) is the argument. ## The shape of the problem A flight's information changes at a rate that depends on where it is in its life: * **A week out**, the schedule changes rarely and matters little. Nobody is at the airport. * **The day of**, gates, terminals and delays appear and move. The cost of being an hour late is small; the cost of being ten minutes late is not yet real. * **The last hour before departure**, everything moves and somebody is standing in a queue reading a board. * **In the air**, little changes until the approach, when the arrival estimate firms up. * **After landing**, there is a gate to learn, then a baggage belt, then nothing ever again. A fixed interval is wrong at both ends: wasteful early, late when it counts. The fix is not a better interval. It is a function of the flight's state. ## The decision function Trimmed of imports and logging, this is `decideNextPoll` from the poller. It takes the latest snapshot and the clock, and returns the delay before the next poll or `null` to stop watching. ```ts title="poll-policy.ts" export const decideNextPoll = (snapshot, now, postArrivalGraceMinutes) => { const phase = snapshot.status.phase; if (phase === "cancelled") { // Cancellations are occasionally reversed, so keep a light watch briefly. return { delayMs: 30 * MINUTE, reason: "cancelled: light watch for reinstatement" }; } if (phase === "arrived") { const arrivedAt = snapshot.arrival.times.actualGate?.utc; const sinceArrival = arrivedAt ? (now - Date.parse(arrivedAt)) / MINUTE : Infinity; if (sinceArrival >= postArrivalGraceMinutes) { return { delayMs: null, reason: "arrived: grace window elapsed" }; } // The baggage belt is often published after the gate arrival. return { delayMs: 5 * MINUTE, reason: "arrived: watching for baggage claim" }; } if (phase === "landed") return { delayMs: 2 * MINUTE, reason: "landed: awaiting gate arrival" }; if (phase === "diverted" || phase === "redirected") { return { delayMs: 5 * MINUTE, reason: "diverted: tracking closely" }; } if (phase === "en_route") { const eta = snapshot.arrival.bestTimeUtc; const minutesToArrival = eta ? (Date.parse(eta) - now) / MINUTE : Infinity; if (minutesToArrival <= 45) return { delayMs: 3 * MINUTE, reason: "en route: approaching arrival" }; return { delayMs: 10 * MINUTE, reason: "en route" }; } if (phase === "departed" || phase === "boarding") { return { delayMs: 3 * MINUTE, reason: "off gate: awaiting takeoff" }; } // Still scheduled: before departure, only the clock can decide. const departure = snapshot.departure.bestTimeUtc; if (!departure) return { delayMs: 30 * MINUTE, reason: "no departure time known" }; const msToDeparture = Date.parse(departure) - now; if (msToDeparture < -6 * HOUR) { // Departure long past but the status never advanced: the feed is stale. return { delayMs: null, reason: "stale: departure over 6h ago with no status change" }; } if (msToDeparture <= 45 * MINUTE) return { delayMs: 3 * MINUTE, reason: "departure imminent" }; if (msToDeparture <= 2 * HOUR) return { delayMs: 10 * MINUTE, reason: "within 2h of departure" }; if (msToDeparture <= 6 * HOUR) return { delayMs: 20 * MINUTE, reason: "within 6h of departure" }; if (msToDeparture <= 24 * HOUR) return { delayMs: HOUR, reason: "within 24h of departure" }; if (msToDeparture <= 48 * HOUR) return { delayMs: 6 * HOUR, reason: "within 48h of departure" }; return { delayMs: 12 * HOUR, reason: "far future" }; }; ``` Two rules of thumb are hiding in the order of those branches. **Before departure, the clock decides. After it, the phase does.** Until the aircraft moves, the only thing we know is the departure time, so the interval is a function of distance to it. Once the status advances, time-to-departure is meaningless and the phase takes over: three minutes off the gate, ten in cruise, three again on approach, two once it has landed. The approach case reads the *arrival* estimate; a flight is more interesting in its last 45 minutes than its first. **Terminal states are not quite terminal.** A cancelled flight is watched every 30 minutes for a while, because cancellations get reversed and `flight.reinstated` is a real event. An arrived flight is watched every five minutes until the baggage grace window, 45 minutes by default, has passed, because the belt is usually published after the gate. Only then does the function return `null`, the subscription becomes `completed`, and nothing about that flight is ever fetched again. There is one more `null`: a flight whose departure time is more than six hours in the past and whose status never moved. That is not a flight, it is a stale record, and polling it forever would be paying for nothing. ## What it decides, in a table The `reason` strings are not decoration. They are stored with every poll and shown in the admin panel, so "why was this flight last checked forty minutes ago" has an answer that is not a log file. | Situation | Next poll | Reason recorded | | ------------------------------------------ | --------- | ------------------------------------------ | | More than 48 h to departure | 12 h | `far future` | | 24 – 48 h | 6 h | `within 48h of departure` | | 6 – 24 h | 1 h | `within 24h of departure` | | 2 – 6 h | 20 min | `within 6h of departure` | | 45 min – 2 h | 10 min | `within 2h of departure` | | Under 45 min | 3 min | `departure imminent` | | Boarding or departed | 3 min | `off gate: awaiting takeoff` | | En route | 10 min | `en route` | | Under 45 min to arrival | 3 min | `en route: approaching arrival` | | Landed | 2 min | `landed: awaiting gate arrival` | | Arrived, belt not yet known | 5 min | `arrived: watching for baggage claim` | | Arrived, grace window over | stop | `arrived: grace window elapsed` | | Diverted or redirected | 5 min | `diverted: tracking closely` | | Cancelled | 30 min | `cancelled: light watch for reinstatement` | | No departure time in the record | 30 min | `no departure time known` | | Departure over 6 h ago, status never moved | stop | `stale: departure over 6h ago…` | Whatever the function returns is clamped: never faster than once a minute, never slower than once every twelve hours. The clamp is there so a bug in the policy cannot become a bill. ## Flights that do not exist yet Subscriptions can be created a week ahead, and airlines publish the operational record a few days out. Until then the upstream has no row for the flight, and there is no snapshot to read a departure time from. A second, smaller function handles that, keyed on the subscription's date: | Situation | Next check | Reason recorded | | ------------------------------- | ---------- | ----------------------------------------------- | | Date more than 48 h away | 6 h | `flight not published yet` | | Within 48 h | 1 h | `flight not found yet, within 48h` | | Today, within 6 h of end of day | 15 min | `flight not found yet, date is today` | | Date passed by more than 12 h | stop | `flight never appeared and the date has passed` | While this runs the subscription is `pending`. When the flight appears it becomes `active`, you receive `subscription.activated`, and the main function takes over. If the date goes by without the flight ever appearing, `subscription.completed` says so. > **A flight a week out is not a fault** > > The upstream answers a `500` for any date it holds no record of, which is every date more than two days ahead. Early on that was treated like any other fault: retried, and counted against the per-provider circuit breaker. One subscription seven days ahead produced enough of them to trip the breaker for every other flight. A date outside the published window is now a normal outcome with its own cadence, which is what the table above is. ## What one flight costs Adding the intervals up over a two-hour flight gives the poll count by how far ahead the subscription was created: | Subscription created | Polls, roughly | Where they go | | -------------------- | -------------- | ------------------------------------------------------------------------------------------------------- | | 7 days out | 110 | 14 in the far future, 18 hourly on the day before, the rest in the last six hours and the flight itself | | 1 day out | 95 | | | 3 hours out | 70 | | | 45 minutes out | 55 | 20 around departure, 25 in the air and on approach, 15 on the ground | A five-minute loop over the same six hours makes 72 requests and is still five minutes late at the gate. The adaptive schedule spends about the same on a same-day subscription, is three times as fresh where it matters, and costs almost nothing for the week before. The number that matters more: **subscribers on the same leg share one poll.** Fifty bookings on tonight's TK1848 are one job in the queue, not fifty. Adding a subscriber never adds upstream load, which is why plans are priced by watched flights rather than by subscriptions. ## How it runs The policy is a pure function; the machinery around it is where production lives. * **One delayed job per flight key.** The job id is the flight key, so a second subscription on the same leg finds the job already there. The next poll is scheduled from the job's *completed* event, replacing any queued job, so the newest decision always wins. * **A reconciler every five minutes.** Delayed jobs survive restarts, but a job that exhausts its retries would otherwise silently stop a flight from ever being polled again. The reconciler asserts that every watched flight has a job in the queue and re-creates the ones that do not. Its first run after a deploy is staggered by a few random seconds so a rolling restart does not stampede the upstream. * **Retries with backoff, and a breaker.** A failed poll retries with exponential backoff from ten seconds. All upstream calls go through one client with a shared token bucket and a per-provider circuit breaker that opens after eight consecutive failures, so an upstream outage degrades into a pause rather than a flood. * **Then the diff.** The new snapshot is compared with the previous one and turned into typed changes. Each subscription gets only the change types it asked for, with delay changes filtered by its own `delayThresholdMinutes`, and one delivery per subscription goes to the webhook queue with its own retries and log. ## Lookups are not polls `GET /v1/flights/…` reads from a cache whose lifetime also follows the flight: thirty minutes a day out, five minutes on the day, sixty seconds within three hours of departure or in the air. A page that shows the current state on load is cheap and included in the plan. Polls, by contrast, always go upstream; that is their purpose, and it is why they are the thing you pay for. If you need an answer that is guaranteed fresh, `refresh=true` on a lookup skips the cache. If you need to be told, subscribe. ## What you cannot tune, and why There is no per-subscription interval. The cadence is the product: it is what makes a watched flight cost what it costs and arrive when it arrives, and a knob that let one subscriber poll every minute for a week would be a knob that let one subscriber set everyone's bill. The knobs you do have are the ones that shape what you hear: `events` for which change types, and `delayThresholdMinutes` for how much a delay has to move before you are told. If the policy above is wrong for a case you have, [tell us](https://flightbyte.dev/contact) with the flight. Every row in that table came from one. --- # Missed connection alerts from two subscriptions > A tight connection is not a property of either flight; it lives in the gap between them. How to compute that gap from two watched flights, choose a threshold per airport, handle terminals, early departures, cancellations and diversions, and keep the alert from firing every time an estimate moves by a minute. Source: https://flightbyte.dev/guides/missed-connection-alerts "Your connection is at risk" is the most valuable sentence an itinerary app can send, and the easiest to get wrong. Send it late and it is useless. Send it on every wobble of an estimate and the user stops reading. And no flight status API can send it for you, because the fact does not belong to a flight. It belongs to two flights and one passenger. This guide builds it from two subscriptions and about eighty lines of state. ## The quantity For a passenger arriving on flight A and leaving on flight B from the same airport: ```text layover = B.departure.bestTimeUtc − A.arrival.bestTimeUtc ``` `bestTimeUtc` is the best time known right now: actual if it has happened, else estimated, else scheduled. Both are gate times, which is what you want. A passenger is not connecting until the door opens, and is not on board after the door closes. Compare the layover with a threshold you choose. Call the difference the **margin**. Positive margin, the connection is fine. Negative, it is not. The interesting band is the one in between. > **FlightByte reports flights, not journeys** > > It does not know these two flights are one person's trip, and it does not publish minimum connection times. You bring the pairing and the threshold; it keeps both halves current. [Connection, layover and MCT](https://flightbyte.dev/glossary/connection-vs-layover) has the vocabulary. ## Finding the pairs A connection exists in your data when two flights on one trip satisfy all of these: * The arrival airport of the first is the departure airport of the second. Compare `arrival.airport.iata` with `departure.airport.iata`. * The second is scheduled to leave after the first is scheduled to arrive, by less than a day. * They belong to the same traveller. Some cities need care. A passenger landing at one London airport and leaving from another has a connection your airport comparison will miss, and no status feed will help with the coach between them. Decide whether you support that, and if you do, treat it as a pairing with a very large threshold. Store the pair: ```sql create table connections ( id text primary key, trip_id text not null, inbound text not null references watched_flights(id), outbound text not null references watched_flights(id), threshold_min int not null, state text not null default 'ok', -- ok | tight | at_risk | broken announced_min int -- layover at the last alert ); ``` ## Choosing a threshold Published minimum connection times vary from twenty-five minutes at a small domestic airport to two hours for an international transfer between terminals at a large one. They are not in the snapshot. A workable default table is short: | Situation | Threshold | | ---------------------------------------------------------------- | --------- | | Same terminal, both domestic or both within one border-free area | 45 min | | Same terminal, one leg international | 75 min | | Different terminals | 90 min | | Separate tickets, bags to recheck | 120 min | Read the terminals from `arrival.terminal` on the inbound and `departure.terminal` on the outbound. They are often absent until the day; start with the same-terminal assumption and re-evaluate when either appears. Override per airport as you learn which ones are slow. Whatever you choose, show the user the layover in minutes, not just your verdict, so that a traveller who knows the airport can disagree. ## Subscribing for it Both flights are probably already watched for their own sake. Make sure these change types are in each subscription's `events`: | Flight | Change types that move the margin | | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Inbound | `arrival.estimated.changed`, `arrival.actual.changed`, `delay.increased`, `delay.decreased`, `arrival.terminal.changed`, `flight.cancelled`, `flight.diverted`, `flight.reinstated` | | Outbound | `departure.estimated.changed`, `departure.scheduled.changed`, `departure.terminal.changed`, `departure.gate.changed`, `flight.cancelled`, `flight.reinstated`, `flight.departed` | Keep `delayThresholdMinutes` low on flights that are part of a connection, five rather than fifteen. A twelve-minute slip that nobody would push to a passenger on its own can be the whole margin of a fifty-minute connection. ## The evaluator Run it whenever a delivery touches either flight of a pair. It reads the two stored snapshots, so it does not matter which flight the event was about or in what order events arrived. ```ts type State = "ok" | "tight" | "at_risk" | "broken"; const TIGHT_BAND = 20; // minutes above the threshold that still deserve a heads-up const STEP = 10; // re-announce only after this much further movement export function evaluate(conn: Connection, inbound: Snapshot, outbound: Snapshot) { if (inbound.status.cancelled || inbound.status.diverted) return verdict(conn, "broken", null, "inbound"); if (outbound.status.cancelled) return verdict(conn, "broken", null, "outbound"); const arrive = Date.parse(inbound.arrival.bestTimeUtc); const depart = Date.parse(outbound.departure.bestTimeUtc); const layover = Math.round((depart - arrive) / 60_000); const margin = layover - thresholdFor(conn, inbound, outbound); const state: State = margin < 0 ? "at_risk" : margin < TIGHT_BAND ? "tight" : "ok"; return verdict(conn, state, layover); } function verdict(conn: Connection, state: State, layover: number | null, cause?: string) { const worse = rank(state) > rank(conn.state); const better = rank(state) < rank(conn.state); const moved = layover !== null && conn.announcedMin !== null && Math.abs(layover - conn.announcedMin) >= STEP; // Speak when it gets worse, when it recovers, or when a bad situation moves a lot. const speak = worse || (better && conn.state !== "ok") || (state !== "ok" && moved); return { state, layover, cause, speak }; } ``` Three rules are doing the work, and they are the same three that keep a [delay notifier](https://flightbyte.dev/guides/building-a-flight-delay-notification-system) quiet: * **A band, not a line.** `tight` exists so the first message is "this is getting close", sent while the user can still do something, rather than "you have missed it". * **A step.** Inside a bad state, say nothing until the layover has moved another ten minutes. Estimates drift by one or two minutes all afternoon. * **Recovery is news.** When an announced risk goes away, say so, once. People rebook on the strength of your first message. ## The cases that bite * **The outbound leaves early.** It happens, particularly on short sectors with a slot to catch. The formula handles it, because `departure.bestTimeUtc` moves earlier. What breaks is an implementation that only listens to the inbound. Listen to both. * **The inbound has landed.** Once `arrival.times.actualGate` exists, the arrival side is a fact. From then on only the outbound can change the margin, and the alert should switch from "at risk" to directions: gate, terminal, minutes. * **The outbound has departed.** `flight.departed` on the outbound while the inbound is not yet `arrived` is the end of the story. Mark it `broken`, stop evaluating, and change the screen from a warning to help. * **Diverted inbound.** The arrival airport is no longer the connection airport. `status.diverted` is true and `diverted` names where the aircraft went. No arithmetic applies; hand the user to a human or a rebooking flow. * **Reinstated.** Cancellations are sometimes reversed. `flight.reinstated` should put the pair back through the evaluator rather than leaving it `broken` for ever. * **Terminal appears late.** A terminal published two hours before departure can move the threshold from 45 to 90 and flip a comfortable connection to tight with no change in any time. Re-evaluate on `arrival.terminal.changed` and `departure.terminal.changed` as well as on times. * **Three legs.** Evaluate each adjacent pair independently. A late first leg propagates by itself, because the second leg's risk is computed from real estimates, not from your guess about the first. ## What to say | State change | Message | | ------------------------- | --------------------------------------------------------------------------------------------------------------- | | ok to tight | Your connection in Istanbul is down to 58 min. TK1848 now lands 13:58; TK1 leaves 14:56 from the same terminal. | | tight to at risk | Connection at risk: 32 min in Istanbul. TK1 is still scheduled for 14:56. | | at risk, moved | Now 18 min. Speak to the crew before landing; they can call ahead. | | at risk to ok | Good news: TK1 is delayed to 15:40. You have 1 h 16 min. | | broken, inbound cancelled | TK1848 is cancelled, so your connection to TK1 will not work. Tap for options. | Times in those sentences come from `times.*.local` on each flight, which is the clock at that airport. Never build them from `bestTimeUtc` and a guess at the zone. ## Testing it Unit-test the evaluator with pairs of snapshots; it is a pure function. For the wiring, send signed fixtures to your local receiver with the script in the [example receivers repo](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples): an `arrival.estimated.changed` that eats twenty minutes, a second that eats two more and must be silent, then a `departure.estimated.changed` on the outbound that gives thirty back and must announce recovery. ## What it costs Nothing beyond the two flights. A connection is not a billable thing; it is arithmetic over two subscriptions you were already paying for, each counted once however many travellers and connections share it. --- # Verifying webhook signatures the right way: raw body, timing-safe compare, replay window > A webhook is an unauthenticated POST from the internet. The signature is what makes it yours. The three ways verifiers go wrong, working code for seven frameworks, how to rotate a secret without dropping a delivery, and a checklist. Source: https://flightbyte.dev/guides/verifying-webhook-signatures Your webhook endpoint is a URL on the public internet that accepts a POST and does something with it. Without verification, anyone who learns the URL can tell your system a flight landed. With a verifier that is subtly wrong, they still can, and you will not find out until it matters. Most wrong verifiers are wrong in one of three ways: they check a body that is not the one that was signed, they compare with `===`, or they ignore the timestamp. This guide is the scheme, the three mistakes with the fix in each framework, and the operational bits around secrets. ## The scheme Every FlightByte delivery carries this header: ```http x-flightbyte-signature: t=1789305971,v1=5f1c9a…e3b2 ``` `t` is the Unix time, in seconds, when the delivery was signed. `v1` is: ``` v1 = hex( HMAC-SHA256( secret, "" + "." + ) ) ``` The secret is the `whsec_…` string returned when the subscription was created (or the one you supplied). It is 24 random bytes, base64url-encoded, behind the prefix. Three design choices, each with a reason: * **The timestamp is inside the signed string.** An attacker who captures one valid delivery cannot change `t` to make it look fresh; a new `t` needs a new signature, which needs the secret. * **The body is signed as bytes**, not as JSON. JSON has many valid spellings of the same object; bytes have one. * **`v1=` is a version.** If the scheme ever changes, deliveries can carry `v1=` and `v2=` side by side while receivers migrate, the way Stripe did. The other five headers (`x-flightbyte-event-id`, `-event-type`, `-subscription-id`, `-delivery-attempt`, `-timestamp`) are conveniences. Read them **after** verifying, never as a reason to skip it: anything in a request from the internet is attacker-controlled until the signature says otherwise. ## Mistake one: verifying the wrong bytes The signature covers the exact bytes FlightByte sent. If your framework parsed the JSON and you re-serialise the object, you get different bytes (key order, whitespace, number formatting, unicode escapes) and every delivery fails verification. It looks identical to a wrong secret from the outside, which is why it costs people an afternoon. You need the raw body. Every framework can give it to you; each hides it somewhere different. **Express** ```ts // Mount the raw parser on this route only; keep express.json() for the rest. app.post("/hooks/flights", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body as Buffer; // bytes, not an object if (!verify(process.env.FLIGHTBYTE_WEBHOOK_SECRET!, req.header("x-flightbyte-signature") ?? "", raw)) { return res.status(401).end(); } const event = JSON.parse(raw.toString("utf8")); res.status(204).end(); // ... }); ``` **Fastify** ```ts // Fastify parses JSON before your handler runs; ask it to keep the bytes. import rawBody from "fastify-raw-body"; await app.register(rawBody, { field: "rawBody", global: false, runFirst: true }); app.post("/hooks/flights", { config: { rawBody: true } }, async (req, reply) => { const raw = req.rawBody as Buffer; if (!verify(secret, req.headers["x-flightbyte-signature"] as string, raw)) return reply.code(401).send(); reply.code(204).send(); // req.body is the parsed event }); ``` **Next.js** ```ts // app/hooks/flights/route.ts (App Router). Read text, do not call req.json(). export async function POST(req: Request) { const raw = Buffer.from(await req.text(), "utf8"); if (!verify(process.env.FLIGHTBYTE_WEBHOOK_SECRET!, req.headers.get("x-flightbyte-signature") ?? "", raw)) { return new Response(null, { status: 401 }); } const event = JSON.parse(raw.toString("utf8")); // Hand the work to a queue here; a serverless function ends when the response does. return new Response(null, { status: 204 }); } ``` **Flask** ```python @app.post("/hooks/flights") def hook(): raw = request.get_data() # bytes; request.json would have parsed them if not verify(SECRET, request.headers.get("x-flightbyte-signature", ""), raw): abort(401) event = json.loads(raw) return "", 204 ``` **Django** ```python @csrf_exempt @require_POST def hook(request): raw = request.body # bytes, available as long as nothing read request.POST first if not verify(SECRET, request.headers.get("x-flightbyte-signature", ""), raw): return HttpResponse(status=401) event = json.loads(raw) return HttpResponse(status=204) ``` **FastAPI** ```python @app.post("/hooks/flights", status_code=204) async def hook(request: Request): raw = await request.body() # not a Pydantic model parameter if not verify(SECRET, request.headers.get("x-flightbyte-signature", ""), raw): raise HTTPException(status_code=401) event = json.loads(raw) ``` **Go** ```go func hook(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(r.Body) // read once; json.NewDecoder(r.Body) would consume it if err != nil || !verify(secret, r.Header.Get("x-flightbyte-signature"), raw) { w.WriteHeader(http.StatusUnauthorized) return } var event Event _ = json.Unmarshal(raw, &event) w.WriteHeader(http.StatusNoContent) } ``` The rule underneath all seven: get the bytes first, verify, and only then parse. ## Mistake two: comparing with `===` A string comparison stops at the first byte that differs. That means a wrong signature whose first byte is right takes a fraction longer to reject than one whose first byte is wrong, and with enough requests the difference is measurable across a network. Byte by byte, an attacker can walk towards a valid signature without the secret. Whether that attack is practical against your endpoint is beside the point; the fix is one function call. * **Node.js**: `crypto.timingSafeEqual(a, b)`. It throws if the lengths differ, so check `a.length === b.length` first. Length is not secret. * **Python**: `hmac.compare_digest(a, b)`. * **Go**: `hmac.Equal(a, b)`. Compare like with like. Either decode both hex strings to bytes and compare the bytes, or compare the two lower-case hex strings; do not compare bytes on one side with a string on the other. ## Mistake three: ignoring `t` A verifier that checks only `v1` accepts a delivery it already accepted last week. That is a replay: capture one valid "flight landed" and send it again at a moment of your choosing. The timestamp inside the signed string is the defence, and it only works if you check it. Reject deliveries whose `t` is more than a few minutes from your clock, in either direction. FlightByte's own reference verifier uses **five minutes**; that absorbs clock skew between two servers on NTP and nothing more. Check it *before* computing the HMAC; there is no reason to spend the hash on a stale request. Two things the window does not do: * **It does not replace deduplication.** Within the window a replay is still valid. Retries are also replays with the same `id` and body, on purpose. Store processed event ids and treat a repeat as already done. * **It does not forgive a wrong clock.** A receiver whose clock drifts ten minutes will reject every delivery with a `401`, and FlightByte will log that as your endpoint refusing the event. Keep NTP running. ## The reference verifier All three checks in one function. Verify against the raw bytes, check the window, compute, compare in constant time. **Node.js** ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret: string, header: string, rawBody: Buffer, toleranceSeconds = 300): boolean { const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2))); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex"); return parts.v1.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)); } ``` **Python** ```python import hmac, hashlib, time def verify(secret: bytes, header: str, raw_body: bytes, tolerance: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) t, v1 = parts.get("t"), parts.get("v1") if not t or not v1: return False try: if abs(time.time() - float(t)) > tolerance: return False except ValueError: return False expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(v1, expected) ``` **Go** ```go func verify(secret, header string, rawBody []byte, tolerance time.Duration) bool { var t, v1 string for _, part := range strings.Split(header, ",") { if k, v, ok := strings.Cut(part, "="); ok { switch k { case "t": t = v case "v1": v1 = v } } } ts, err := strconv.ParseInt(t, 10, 64) if err != nil || time.Since(time.Unix(ts, 0)).Abs() > tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) return hmac.Equal([]byte(v1), []byte(hex.EncodeToString(mac.Sum(nil)))) } ``` These are the same functions as in the [Webhooks](https://flightbyte.dev/docs/webhooks#verifying-a-delivery) reference and in the [example receivers](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples), where they run against a signed test sender. ## Secrets: one or many, and rotating them By default every subscription gets its own secret, returned in full exactly once, in the `201`. After that the API shows a hint (`whsec_Y0Cp2…MA0u`); `GET /v1/subscriptions/{id}/secret` reveals it again to a caller with the API key. One secret per subscription is the safer default and the more annoying one: a receiver that serves many bookings has to look the secret up by subscription id before it can verify. The alternative is to supply your own with `secret` at creation, so every subscription is signed with the same key and the receiver reads one environment variable. Both are fine. Choose the shared one when one service handles all your flights; choose per-subscription when different teams or tenants receive different flights. **Rotating.** `POST /v1/subscriptions/{id}/secret/rotate` replaces the secret and returns the new one. From that moment deliveries are signed with the new key; a receiver still holding the old one answers `401`, and FlightByte retries with backoff (four seconds, doubling, eight attempts) for roughly eight minutes before giving the event up. To rotate without that window, let the verifier accept a list: ```ts const SECRETS = (process.env.FLIGHTBYTE_WEBHOOK_SECRETS ?? "").split(",").filter(Boolean); const ok = SECRETS.some((secret) => verify(secret, header, raw)); ``` Add the new secret to the list, deploy, rotate, then remove the old one on the next deploy. Nothing is dropped in either direction. > **Where the secret lives** > > An environment variable or a secrets manager, never the repository and never a log line. The signature header is safe to log; the secret is not, and a verifier that logs "expected X got Y" has just written the secret's fingerprint to disk. ## A second lock on the door Signatures prove the sender. Two cheap things make the endpoint harder to reach in the first place. * **Your own header.** A subscription's `headers` field is sent with every delivery. Put a bearer token in it and reject requests that lack it before you even read the body. It is not a substitute for the signature, but it stops random scanners at the door. * **HTTPS.** Use it. The signature does not encrypt anything; the flight, the gate and your metadata travel in the clear otherwise. On its side, FlightByte refuses webhook URLs that resolve to private, loopback or link-local addresses, so a subscription cannot be pointed at something inside our network. The same idea applies to yours: an endpoint that takes a URL from a user should never fetch it blindly. ## Testing the verifier A verifier you have only tested with valid input is untested. * The [example receivers repo](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples) has a sender that signs a fixture the way FlightByte does. `--tamper` flips a byte after signing, `--stale` signs with a ten-minute-old timestamp, and a wrong secret in the environment covers the third case. All three must answer `401`; the plain send must answer `204`. * `POST /v1/subscriptions/{id}/test` sends a `subscription.test` event through the real pipeline with the real secret. * `GET /v1/subscriptions/{id}/deliveries` shows what your endpoint answered, so a `401` from a wrong clock is visible from our side too. ## The checklist 1. Read the raw body bytes before anything parses them. 2. Parse the header into `t` and `v1`; reject if either is missing. 3. Reject if `|now − t|` is more than five minutes. 4. Compute `HMAC-SHA256(secret, t + "." + raw)` as lower-case hex. 5. Compare lengths, then compare in constant time. 6. Only now parse the JSON and read the other headers. 7. Look the event id up; if you have seen it, answer `204` and stop. 8. Answer `204`, then do the work. --- # Why polling a flight API costs more than you think > The loop every flight integration starts with, the five costs hiding in it, the arithmetic for 500 flights a month on request-priced APIs, and when polling is still the right call. Source: https://flightbyte.dev/guides/why-polling-a-flight-api-costs-more-than-you-think Every flight integration starts the same way. There is a status endpoint, there is `setInterval`, and by lunchtime there is a loop: ```js setInterval(async () => { for (const booking of await bookingsWithFlightsToday()) { const flight = await fetchStatus(booking.carrier, booking.number, booking.date); if (flight.status !== booking.lastStatus || flight.gate !== booking.lastGate) { await notify(booking, flight); await save(booking, { lastStatus: flight.status, lastGate: flight.gate }); } } }, 5 * 60 * 1000); ``` It works in the demo. This article is about what it costs once it is real, with numbers, and about the cases where it is still the right thing to write. ## Cost one: the requests A request-priced API charges for every time you ask, and a poller asks whether or not anything changed. Almost nothing changes, so almost every request buys nothing. How many requests a flight costs depends on two choices: how often you ask, and for how long. | Interval | Window | Requests per flight | A gate change can be this late | | -------- | -------- | ------------------- | ------------------------------ | | 15 min | last 6 h | 24 | 15 min | | 5 min | last 6 h | 72 | 5 min | | 2 min | last 6 h | 180 | 2 min | | 1 min | last 3 h | 180 | 1 min | | 3 min | full day | 480 | 3 min | Take the middle row, which is what most teams settle on: every five minutes for the six hours before arrival, 72 requests per flight. At 500 flights a month that is **36,000 requests**. On the request-priced APIs, checked in September 2026: | Provider | What 36,000 requests need | Monthly | | ------------------- | -------------------------------------------------------------------- | ------------ | | aviationstack | Basic is 10,000 requests; Professional is 50,000 | $149.99 | | AeroDataBox | A status call is two units, so 72,000 units; Starter stops at 40,000 | $99 (Growth) | | FlightAware AeroAPI | $0.005 per result set, on a tier with a $100 minimum | ≈ $180 | None of those is outrageous. The point is the shape: you are paying for 36,000 questions to get perhaps 2,000 answers that mattered, and the bill scales with your nervousness rather than with your bookings. ## Cost two: the staleness you agree to The table above has a column people skip. Polling every five minutes means a gate change is, on average, two and a half minutes old when you see it and up to five minutes old at worst. For a driver in the cell-phone lot that is fine. For a "your gate has changed" push to a passenger walking the wrong way, it is not. Shortening the interval fixes the lateness and multiplies the bill: one minute instead of five is five times the requests. The cost curve of a fixed-interval poller is a hyperbola, `cost ≈ 1 / staleness`, and the two ends are both bad. The way out is not a better interval, it is a schedule that follows the flight: nothing worth asking a day out, every few minutes at the gate. That is what a flight-tracking service does internally, and it is why FlightByte's own cadence looks like this: | Situation | Poll interval | | ------------------------------------ | --------------------------------------------------------------- | | More than 48 hours out | Every 12 hours | | 24 to 48 hours | Every 6 hours | | 6 to 24 hours | Hourly | | 2 to 6 hours | Every 20 minutes | | 45 minutes to 2 hours | Every 10 minutes | | Under 45 minutes, boarding, departed | Every 3 minutes | | In the air | Every 10 minutes, every 3 on approach | | Landed, waiting for the gate | Every 2 minutes | | At the gate | Every 5 minutes for 45 minutes, for the baggage belt, then done | That spends roughly 40 to 120 upstream polls per flight, tighter than a five-minute loop where it matters and far looser where it does not. You can write this schedule yourself; [the next guide](https://flightbyte.dev/guides/how-adaptive-polling-works) prints the function. It is the third cost. ## Cost three: the state The demo loop compares two fields. A real one compares a snapshot, and a snapshot has edges: * **Absent is not null.** An estimate that disappears because the airline withdrew it is a different event from an estimate of `null`. Your diff has to know which fields can vanish. * **Times come in pairs.** Scheduled, estimated and actual, for gate and for runway, in UTC and local. "The departure time changed" is six comparisons and a decision about which one the customer cares about. * **Delay is a derived number.** Estimated minus scheduled, gate not runway, clamped at zero on some feeds and negative on others. * **Flight numbers are not unique.** `TK1` is SAW → AMS in the morning and IST → JFK in the afternoon. A poller keyed on carrier, number and date will faithfully track the wrong aircraft for a booking that never told it the origin. * **Cancelled flights come back.** A reinstated flight is not a new flight, and a poller that deleted its row on `cancelled` will never notice. Each of these is a bug you find in production, once, with a customer attached. FlightByte's diff produces 23 typed changes with `from`, `to`, a `deltaMinutes` and a sentence, because the diff is the product. That number is the size of the state machine you would otherwise carry. ## Cost four: the operational tail The loop runs on a machine that restarts, against a provider that has bad afternoons, feeding a notifier that fails sometimes. * **Retries.** When your SMS provider returns a 500 at 03:10, the loop either loses the message or grows a queue. * **Rate limits.** aviationstack meters per minute; AeroAPI per second on the Standard tier. A loop that wakes every five minutes and fires 500 requests at once meets the limit on the first morning it matters. * **Provider outages.** Upstream returns 503 for twenty minutes. Does the loop back off, alert, or hammer? * **Deduplication.** The loop ran twice because the deploy overlapped. Two "your flight has landed" messages. * **The log.** A driver says the message never came. What was sent, when, and what did the phone provider answer? None of this is hard. All of it is time, and it is time spent on the part of the product nobody sees. ## Cost five: the flights you are not polling yet A poller only knows about flights that are already in its table. Bookings are made a week out, and airlines publish the operational record a few days before departure. Somebody has to decide when a booking becomes a flight to poll, and what to do when the lookup returns `404` because the flight does not exist yet. A subscription created at booking time waits as `pending`, checks on a slow schedule, and activates itself when the flight appears. The booking flow does not need to know the difference. ## When polling is the right call Honesty cuts both ways, and there are three cases where the loop above is exactly what you should write. **You need "now", on a page, for a handful of flights.** A departures board or a support tool that shows the current state when someone opens it does not need a schedule at all. Call the lookup when the page loads; on FlightByte it is cached and included in the plan. **You need the data, not the change.** Positions, tracks, history, airport statistics. A request-priced catalogue is the right product for that, and [AeroDataBox](https://flightbyte.dev/compare/aerodatabox) or [AeroAPI](https://flightbyte.dev/compare/flightaware-aeroapi) are the right vendors. **You already run the pipeline.** If you have a scheduler, a diff, a retry queue and a delivery log for other reasons, adding flights to it is cheap. Most teams do not, and build them for this one job. ## The arithmetic, once more The same 500 flights a month, priced by what you actually count: | | Requests | Plan | Monthly | Left to build | | ------------- | ------------------- | ------------ | ------- | ----------------------------- | | aviationstack | 36,000 | Professional | $149.99 | scheduler, diff, retries, log | | AeroDataBox | 72,000 units | Growth | $99 | scheduler, diff, retries, log | | AeroAPI | 36,000 result sets | Standard | ≈ $180 | scheduler, diff, retries, log | | FlightByte | 500 watched flights | Starter | $19 | a receiver | Or Growth at $49 for 2,000 flights of headroom. The polls, the diff, the retries and the log are inside that number, and the bill moves with your bookings rather than with your interval. > **What a watched flight is** > > One flight on one date, delivered to one URL, with your metadata attached. It is priced that way because that is what costs FlightByte upstream polls and what a customer counts: a booking is a flight. [Pricing](https://flightbyte.dev/pricing) has the four plans; [Flight landed notification in 20 minutes](https://flightbyte.dev/guides/flight-landed-notification-nodejs) has the receiver. --- # Baggage carousel assignment > The belt in the arrivals hall where a flight's checked bags will appear, published by the airport, usually after the aircraft reaches the gate. Also called baggage claim, belt or reclaim. Source: https://flightbyte.dev/glossary/baggage-carousel When a flight reaches its gate, the airport assigns a carousel in the baggage hall and publishes the number to the arrivals boards. Airports call it the belt, the carousel, the claim or the reclaim; airlines and APIs call it baggage claim. It is the last piece of information a flight produces, and for anyone meeting a passenger it is the most useful one. ## Why it matters The time between wheels-down and a passenger walking out of arrivals is mostly baggage. A passenger with hand luggage is out ten minutes after the gate; one with a checked bag is out thirty to forty-five minutes later at a large airport. The belt number does not shorten that wait, but its appearance is the clearest signal that the wait has begun, and it lets a message say "bags on belt 7" instead of "landed". ## When it appears Usually a few minutes after the gate arrival, occasionally before landing at airports that pre-assign, and sometimes never: not every airport publishes belts to the feeds, and domestic flights at small airports often have one hall and no number. Absent is normal and does not mean the bags are lost. ## In FlightByte | Field or event | Meaning | | ------------------------- | ------------------------------------------------------------------------------ | | `arrival.baggage` | The belt number as a string, or `null` when the airport has not published one. | | `arrival.baggage.changed` | Fired when the belt is assigned or reassigned, with `from` and `to`. | After the aircraft reaches the gate, FlightByte keeps polling every five minutes for 45 minutes specifically to catch the belt, then sends `subscription.completed`. A subscription that includes `arrival.baggage.changed` in its `events` will receive the belt in that window; one that does not will still see it in the `flight` snapshot of any later event. The [airport transfers](https://flightbyte.dev/for/airport-transfers) page shows a message built on it. --- # Codeshare flight > One aircraft sold under several flight numbers. The airline that flies it is the operating carrier; each airline that sells seats on it under its own number is a marketing carrier. Source: https://flightbyte.dev/glossary/codeshare-flight A codeshare is a commercial agreement: airline A flies the aircraft and airline B sells seats on it under B's own flight number. A Lufthansa passenger can hold a ticket for `LH8320` and board an aircraft painted for United, operating as `UA906`. Alliances make this routine; a busy long-haul flight can carry five or six numbers. Two words keep it straight: * **Operating carrier**: the airline whose crew and aircraft fly the leg. Airport boards usually show its number. * **Marketing carrier**: any airline selling the flight under a different number. The passenger's ticket shows this one. ## Why it matters to an integration The number on the booking is often a marketing number, and the number on the airport board is the operator's. A system that only knows one of them cannot match what the passenger sees to what the airport publishes, and a status lookup keyed on the wrong number returns nothing or, worse, a different flight. ## In FlightByte Look up or subscribe with the number the passenger holds. The snapshot names the operator when it differs and lists every other number the same aircraft flies under: | Field | Meaning | | -------------- | -------------------------------------------------------------------------------- | | `carrier` | The airline of the number you asked for. | | `operatedBy` | The operating airline's name, when it is not `carrier`. Absent otherwise. | | `codeshares[]` | Every other number for this leg, each with `carrier`, `name` and `flightNumber`. | The `codeshares.changed` event fires when that list changes, which is rare and usually happens days before departure. One subscription covers the aircraft, whichever number you subscribed with; there is no need to subscribe to each codeshare separately. The snapshot fields are described under [Flights → The snapshot](https://flightbyte.dev/docs/flights#the-snapshot). --- # Connection, layover and minimum connection time > A connection is a change of aircraft on one journey, a layover is the time spent waiting for it, and the minimum connection time is the shortest gap an airport considers workable. Status data gives you the two flights; the gap between them is yours to compute. Source: https://flightbyte.dev/glossary/connection-vs-layover A **connection** is a point in a journey where the passenger leaves one aircraft and boards another. The **layover** is the time between the first flight reaching its gate and the second leaving its gate. A long layover, usually more than 24 hours on international tickets, is called a **stopover** and is priced differently; to a status system it is simply two unrelated flights. The **minimum connection time**, MCT, is the shortest layover an airport and its airlines consider workable. It depends on the airport, on whether the two flights are domestic or international, on whether the terminals differ, and sometimes on the airline pair. Airlines will not sell a single ticket that breaks it. Two separate tickets have no such protection, which is why self-connecting passengers are the ones who most need a warning. ## What changes on the day A connection that was legal when it was sold can become impossible in an afternoon: * The inbound flight is late, and the layover shrinks minute for minute. * The outbound flight moves to another terminal, and the walk becomes a train ride. * The outbound flight leaves early, which happens more often than passengers expect. * The inbound flight diverts or is cancelled, and there is no connection left to make. None of these is a property of one flight. Each is a relationship between two. ## In FlightByte FlightByte reports flights, not journeys. It does not know that two flights are one passenger's connection, and it does not publish MCT values. What it gives you is both halves, kept current: | You need | Read | | --------------------------------- | ------------------------------------------- | | When the inbound reaches its gate | `arrival.bestTimeUtc` on the first flight | | When the outbound leaves its gate | `departure.bestTimeUtc` on the second | | Whether the terminals differ | `arrival.terminal` and `departure.terminal` | | Whether either is off the rails | `status.cancelled`, `status.diverted` | The layover right now is the second value minus the first. Subscribe to both flights, recompute on every delivery that touches either time, and compare against a threshold you choose per airport. [Missed connection alerts from two subscriptions](https://flightbyte.dev/guides/missed-connection-alerts) has the state machine, including the rules that stop it from crying wolf. The three kinds of time behind `bestTimeUtc` are explained under [scheduled, estimated and actual times](https://flightbyte.dev/glossary/scheduled-estimated-actual-times). --- # Flight number vs flight ID > A flight number is a label an airline reuses every day, sometimes twice in one day. A flight ID names one leg on one date. Confusing the two is how a system watches the wrong aircraft. Source: https://flightbyte.dev/glossary/flight-number-vs-flight-id `TK1848` is not a flight. It is the name of a route in an airline's timetable, flown by a different aircraft every day, and it will be flown tomorrow too. Worse, some numbers are flown more than once on the same day: `TK1` is Istanbul Sabiha Gökçen to Amsterdam in the morning and Istanbul Airport to New York in the afternoon, two aircraft, two crews, one number. A **flight ID** is what identifies one of those: a specific leg, on a specific date, between a specific pair of airports. Every serious flight data source has one; FlightAware calls it `fa_flight_id`, the airline's operations system has its own, and the passenger never sees any of them. ## Why it matters to an integration Anything keyed on a flight number alone has two failure modes. It watches today's instance when the booking was for tomorrow, or it watches the morning leg when the passenger is on the afternoon one. Both are silent: the status is real, it is just someone else's flight. ## In FlightByte | Identifier | Shape | Use | | ------------------ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | Flight number | `TK` + `1848` | What you look up with, together with the local departure date. | | `flightKey` | `TK1848:2026-09-12`, then `TK1848:2026-09-12@ATH-IST` once the leg is resolved | The canonical key for one leg on one date. Appears in every webhook and subscription. Store it with the booking. | | `providerFlightId` | An opaque string such as `1407278900`, listed under `alternates[]` | The exact escape hatch: pass it as `flightId` to pick a leg when airport codes are not enough. | When a number operates more than one leg on the date, a lookup lists them under `alternates` and a subscription is refused with a `400` naming them, because watching the wrong aircraft is worse than an error. Pass `origin` (or `destination`), which a passenger always knows, and the key gains its `@ATH-IST` suffix. The full rules are under [Flights → Leg selection](https://flightbyte.dev/docs/flights#leg-selection). --- # Flight phase > Where a flight is in its life, as one of ten values from scheduled to arrived, derived from the airline's status code and the times that exist. The field to branch on. Source: https://flightbyte.dev/glossary/flight-phase Airlines publish a status code and a label: `A` and "Active", `L` and "Landed", or a sentence like "Arrived / Gate Arrival". Codes differ between feeds, labels are for people, and neither says whether the aircraft has left the gate yet. A phase is the same information as a small, fixed vocabulary that code can branch on. ## The phases | Phase | Meaning | | ------------ | --------------------------------------------------------------------- | | `scheduled` | Not yet departed. The default from publication until something moves. | | `boarding` | Gate activity reported. | | `departed` | Left the gate, not yet airborne. | | `en_route` | Airborne. | | `landed` | On the runway, not yet at the gate. | | `arrived` | At the gate. The watch continues briefly for the baggage belt. | | `cancelled` | Cancelled. Can revert; `flight.reinstated` covers that. | | `diverted` | Landing at an airport other than the planned one. | | `redirected` | Re-routed before departure to a different destination. | | `unknown` | The upstream said nothing usable. Rare, and usually early. | ## How a phase is derived The upstream's letter codes map to phases, with the times filling in what the code leaves out. `A` ("active") is `departed` until an actual runway time exists, then `en_route`. `L` ("landed") is `landed` until an actual gate time exists, then `arrived`. `S` is `scheduled`, `C` is `cancelled`, `D` is `diverted`, `R` is `redirected`. That derivation is why the phase says more than the code: the same letter covers two things a passenger experiences as different. ## In FlightByte | Field | Meaning | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `status.phase` | One of the ten values above. Branch on this. | | `status.code`, `status.label` | The upstream's letter and wording, for display and debugging. | | `status.delayed`, `status.cancelled`, `status.diverted` | Booleans for the three questions asked most often. Delay is a flag on a phase, not a phase. | `status.changed` fires whenever the code or the phase changes, carrying `from` and `to`. The lifecycle events `flight.departed`, `flight.arrived`, `flight.cancelled`, `flight.diverted` and `flight.reinstated` are the phase transitions most integrations act on, named so a subscription can ask for just those. See [Flights → Phases](https://flightbyte.dev/docs/flights#phases). --- # IATA vs ICAO codes > Two code systems for the same airports and airlines. IATA codes are the short ones on tickets and boards (IST, TK); ICAO codes are the four-letter and three-letter ones used by air traffic control and flight plans (LTFM, THY). Source: https://flightbyte.dev/glossary/iata-vs-icao-codes Every airport and every airline has two codes, issued by two organisations for two audiences. | | IATA | ICAO | | ---------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | | Issued by | International Air Transport Association, the airlines' trade body | International Civil Aviation Organization, the UN agency | | Airport | Three letters: `IST`, `LHR`, `JFK` | Four letters, the first one or two say where in the world: `LTFM`, `EGLL`, `KJFK` | | Airline | Two characters, sometimes a digit: `TK`, `BA`, `3U` | Three letters: `THY`, `BAW`, `CSC` | | Flight ident | `TK1848` | `THY1848` | | Where you see it | Tickets, boarding passes, airport boards, booking sites | Flight plans, air traffic control, ADS-B feeds, tracking sites | IATA codes are the ones passengers know, and the ones FlightByte takes as input. ICAO codes are unambiguous in a way IATA codes are not: two-letter airline codes are reused after an airline folds, and a few are shared between carriers in different regions, which is why tracking-oriented APIs prefer the ICAO ident. ## In FlightByte Lookups and subscriptions take the IATA airline code and flight number: `TK` and `1848`. The `origin` and `destination` leg selectors take an IATA airport code, and also accept the code the data upstream uses when it differs (Istanbul Airport is `IST` to IATA and appears as `ISL` in some feeds; both resolve to the same leg). Airport objects in the snapshot carry both codes, and the carrier carries both plus its name: ```json { "carrier": { "iata": "TK", "icao": "THY", "name": "Turkish Airlines" }, "departure": { "airport": { "iata": "ATH", "icao": "LGAV", "name": "Athens International Airport" } } } ``` Store the ICAO codes when you need a key that will not be reused; show the IATA ones to people. Details of the lookup path are in [Flights → Looking a flight up](https://flightbyte.dev/docs/flights#looking-a-flight-up). --- # Operating carrier vs marketing carrier > The operating carrier flies the aircraft; a marketing carrier sells seats on it under its own flight number. A booking usually shows the marketing number, an airport board the operating one, and a status system has to know they are the same aircraft. Source: https://flightbyte.dev/glossary/operating-vs-marketing-carrier Every flight has exactly one **operating carrier**: the airline whose aircraft and crew fly the leg, and whose operations centre decides when it leaves. It can have any number of **marketing carriers**: airlines that sell seats on that same aircraft under flight numbers of their own, through a [codeshare](https://flightbyte.dev/glossary/codeshare-flight) agreement. A passenger who buys from Lufthansa may hold `LH8320`. The aircraft at the gate is United's, the board says `UA906`, and the crew announce a United flight. Both numbers are correct. They name one aircraft. ## Where each number shows up | Place | Number it usually shows | | ----------------------------------------------- | -------------------------------------------------- | | Booking confirmation, e-ticket, itinerary email | Marketing, often with "operated by" in small print | | Boarding pass | Marketing, sometimes both | | Airport departure and arrival boards | Operating, with the others rotating through | | Airline status feeds | Operating first, codeshares as a list | | Baggage belt displays | Operating | ## Why it matters to an integration Anything built from booking emails starts with marketing numbers. Anything a passenger reads at the airport is the operating number. Three problems follow: * **Matching.** A user who tells support "my flight is UA906" and an itinerary that says `LH8320` are describing the same leg. Without the link, the app looks wrong. * **Counting.** Two travellers on one aircraft, one booked through each airline, look like two flights. A system that watches both pays twice and sends two sets of alerts that say the same thing. * **Who decides.** Delays, gates and cancellations are the operating carrier's to announce. The marketing carrier's own channels often lag. ## In FlightByte Look up or subscribe with whichever number the passenger holds; either resolves to the same leg. The snapshot tells you about the rest: | Field | Meaning | | -------------- | -------------------------------------------------------------------------------- | | `carrier` | The airline of the number you asked with. | | `operatedBy` | The operating airline, present only when it is not `carrier`. | | `codeshares[]` | Every other number for this leg, each with `carrier`, `name` and `flightNumber`. | To avoid watching one aircraft twice, keep the set of numbers a watched leg answers to, your own plus everything in `codeshares[]`, and check a new booking against that set before subscribing. [From a booking email to a watched flight](https://flightbyte.dev/guides/from-booking-email-to-watched-flight) walks through it. When the list itself changes, which is rare and usually days ahead, the `codeshares.changed` event says so. --- # Scheduled, estimated and actual times > Three answers to "when does the flight leave", each true at a different moment. Scheduled is the timetable, estimated is the airline's current expectation, actual is what happened. Source: https://flightbyte.dev/glossary/scheduled-estimated-actual-times A flight has one departure and one arrival, but a status feed carries several times for each, and they are not interchangeable. | Time | What it is | When it exists | | ------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------- | | **Scheduled** | The timetable. Set when the flight was planned, changed rarely and usually days out. | Always. | | **Estimated** | The airline's current expectation, revised as the day unfolds. | From a few hours before departure, sometimes earlier. | | **Actual** | What happened, reported after the fact. | Once the event has occurred. | A delay is the difference between estimated (or actual) and scheduled. A flight whose estimated time equals its scheduled time is on time, whatever the board says. ## Gate and runway Each of the three comes in two flavours, because a flight leaves twice. It leaves the **gate** (pushback, "out") and then the **runway** (take-off, "off"); it lands on the **runway** ("on") and then reaches the **gate** ("in"). Airline operations people call these OOOI times. Passengers experience gate times; everything a driver or a pickup cares about is a gate time too, because nobody can be met on the runway. ## In FlightByte Every time is present or absent, never guessed, and each has a UTC and a local form with the airport's time zone: | Field | Meaning | | -------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `departure.times.scheduledGate` | Timetable departure from the gate. | | `departure.times.estimatedGate` | Current expectation for leaving the gate. | | `departure.times.actualGate` | When it left the gate. | | `departure.times.scheduledRunway`, `estimatedRunway`, `actualRunway` | The same three for take-off. | | `arrival.times.*` | The same six for landing and reaching the gate. | | `departure.bestTimeUtc`, `arrival.bestTimeUtc` | Actual if known, else estimated, else scheduled. The one number to show. | | `departure.delayMinutes`, `arrival.delayMinutes` | Gate delay in minutes; negative when early. | Every change to one of the six gate and runway times is its own event: `departure.scheduled.changed`, `departure.estimated.changed`, `departure.actual.changed`, and the three `arrival.*` counterparts, each with `from`, `to` and `deltaMinutes`. `delay.increased` and `delay.decreased` fire when the gate delay crosses your subscription's threshold. See [Flights → The snapshot](https://flightbyte.dev/docs/flights#the-snapshot) and [Webhooks → Change types](https://flightbyte.dev/docs/webhooks#change-types). ## Two mistakes worth naming **Showing the scheduled time as if it were current.** After the estimate exists, the scheduled time is history. Read `bestTimeUtc` unless you have a reason to show all three. **Treating an absent time as midnight.** An estimate that is not there yet is `undefined`, not `null` and not `00:00`. Code that formats it blindly prints a delay of several hours.