Push, not poll
Every flight off the board, delivered.
We already watch the arrival and departure boards at OTP, CLJ, RMO, TIA. Point us at an https endpoint, pick your airports, and each flight we fetch arrives as a signed POST — no polling loop, no credits spent.
Available now
The airports you can subscribe to
Exactly the boards we poll. We would rather publish four airports we genuinely watch than a list you cannot actually receive.
OTP
ROBucharest Otopeni
Bucharest, Romania
CLJ
ROCluj-Napoca
Cluj-Napoca, Romania
RMO
MDChișinău
Chișinău, Moldova
TIA
ALTirana
Tirana, Albania
/api/v1/airports returns the same catalogue, plus the event vocabulary. Unmetered, so a startup check costs nothing.
curl -sS "https://flight-api.dev/api/v1/airports" \
-H "Authorization: Bearer $FLIGHT_API_KEY"Adding an airport to this list never widens a subscription you already have — a webhook receives the airports it selected and nothing else.
What we send
Subscribe per type. flight.tracked alone gives you every flight on the board; the disruption types give you only what went wrong.
Flight entered the board
A schedule row appeared on a watched arrival or departure board for the first time. One per flight per direction — this is the event that carries every newly fetched flight, on time included.
Flight became delayed
The flight crossed from on time into delayed. Fires on the transition, not on every re-estimate.
Flight was cancelled
The board now reports the flight as cancelled. Fires once.
Flight was diverted
The flight is landing somewhere other than its filed destination. `data.flight.divertedTo` carries the airport when the board names one.
Flight left disruption
A flight that was delayed, cancelled or diverted is back to on time. Rare, and worth handling — it is how a false-positive disruption unwinds.
Delay estimate moved
The estimated time shifted by at least a whole minute. Subscribe to this only if you track a delay as it grows; `flight.delayed` alone is enough to know a flight is late.
Status string changed
The board's own status text changed — "Scheduled" to "Estimated 14:35" to "Landed 14:41". Highest volume of the set.
Divert target changed
The diversion airport was set, changed or cleared.
The payload
One shape for every type. The subscription is echoed back, so a shared receiver can route on airports without holding its own config.
{
"id": "whd_7Qw1pKcRr8mF3xTz",
"event": "clz9k2f9a0004qw2h8vjd1m0p",
"type": "flight.delayed",
"createdAt": "2026-09-03T11:42:07.318Z",
"webhook": {
"id": "clz8k2f9a0002qw2h",
"name": "ops",
"airports": [
"OTP",
"CLJ"
]
},
"airport": {
"iata": "OTP",
"name": "Bucharest Otopeni",
"city": "Bucharest",
"country": "Romania",
"timezone": "Europe/Bucharest"
},
"data": {
"flight": {
"id": "clz9k1v2b0001qw2h",
"flightNumber": "RO301",
"callsign": "ROT301",
"direction": "departure",
"airline": {
"name": "TAROM",
"iata": "RO"
},
"aircraftType": "B738",
"origin": {
"iata": "OTP"
},
"destination": {
"iata": "LHR"
},
"divertedTo": null,
"scheduledAt": "2026-09-03T12:50:00.000Z",
"estimatedAt": "2026-09-03T13:37:00.000Z",
"delayMinutes": 47,
"status": "Estimated departure 16:37",
"disruption": "delayed",
"isCancelled": false,
"isDiverted": false,
"runway": null,
"firstSeenAt": "2026-09-03T05:12:44.010Z",
"lastSeenAt": "2026-09-03T11:42:07.301Z"
},
"previous": {
"status": "Scheduled 15:50",
"estimatedAt": null,
"delayMinutes": null,
"disruption": "scheduled"
}
}
}X-Flight-Signature
HMAC-SHA256 over `{timestamp}.{body}`, as `t=<unix>,v1=<hex>`.
X-Flight-Event
Board event id. Shared by every subscriber to it.
X-Flight-Event-Type
Same value as `type` in the body.
X-Flight-Delivery
Delivery id — unique per endpoint. Same value as `id` in the body.
X-Flight-Webhook
The endpoint that was configured to receive this.
X-Flight-Attempt
1 on the first try, incrementing on each retry.
Every field, typed, is in the API reference and in the machine-readable export.
Signed, and replay-bound
HMAC-SHA256 over the timestamp and the raw body. Reject anything older than 5 minutes and a captured request stops being reusable.
The literal request body, prefixed with the timestamp and a dot. Sign the raw bytes you received — re-serialising the parsed JSON will not match.
It is inside the MAC, so it cannot be edited. Reject anything more than five minutes old and a captured request stops being replayable.
Compare in constant time. A byte-by-byte early exit leaks the signature one character at a time.
Rotating issues a new secret immediately; the old one stops verifying at once. Rotate from the dashboard when you are ready to deploy the new value.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp)) return false
// Bound the age first: the timestamp is inside the MAC, so an attacker
// cannot backdate a captured request to keep replaying it.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const a = Buffer.from(parts.v1 ?? '')
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}// Next.js route handler. Read the raw body — re-serialising the parsed
// JSON produces different bytes and the signature will not match.
export async function POST(request: Request) {
const raw = await request.text()
const header = request.headers.get('x-flight-signature') ?? ''
if (!verify(raw, header, process.env.FLIGHT_WEBHOOK_SECRET!)) {
return new Response('invalid signature', { status: 401 })
}
const event = JSON.parse(raw)
// Acknowledge first, work after. We time out at 10s and
// retry a non-2xx, so slow handlers turn into duplicate attempts.
void enqueue(event)
return new Response(null, { status: 204 })
}Delivery, stated plainly
The whole contract. Nothing here is best-effort prose — these are the constants the sender runs on.
Boards are polled every 30 minutes and every observed change is queued in the same pass. An event reaches you within a delivery cycle of the poll that saw it.
One delivery row exists per event and endpoint, unique in the database. A retry re-sends the same `id`; a redeploy or an overlapping pass cannot produce a second one. Key your handler on `id` anyway — that is what it is for.
Any 2xx inside 10 seconds. Redirects are not followed — a 3xx is a failure, so point the webhook at the final URL. The response body is discarded.
Up to 5 attempts, backing off 1, 5, 20, 60 minutes. After the last one the delivery is marked failed and is not retried again.
25 consecutive failures across all deliveries disables the endpoint and stops the queue. Any 2xx — including a manual test send — resets the counter.
Deliveries are queued in event order but sent concurrently, so they can arrive out of order. Every payload carries `data.flight.lastSeenAt`; use it to discard an older observation.
A webhook only receives the airports it selected. Adding an airport to the catalogue does not widen an existing subscription — you have to select it.
Questions
The ones that come up before the first deploy.
Which airports can I subscribe to?
Today: OTP (Bucharest), CLJ (Cluj-Napoca), RMO (Chișinău), TIA (Tirana). Both the arrival and the departure board of each. We only publish airports we actually poll, so the list is what you can really receive rather than a roadmap.
Do webhook deliveries cost credits?
No. Credits meter lookups against the flight status endpoint. Being told about a flight is free — you are only billed for asking.
How quickly do I hear about a delay?
Boards are polled every 30 minutes and each change is queued in the same pass, then sent within 2 minutes. Nothing is buffered overnight.
Can I receive the same event twice?
One delivery row exists per event and endpoint, enforced by a unique index, so a retry re-sends the same id rather than creating a second one. Key your handler on it anyway — that is what the id is for.
What happens if my endpoint goes down?
We retry 5 times, backing off 1, 5, 20, 60 minutes. A sustained outage eventually disables the endpoint and the dashboard says why; any successful delivery — including a manual test — resets it.
Point it at a URL and stop polling.
Register an endpoint, tick the airports, send a test event. Two minutes.