"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 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:
- A threshold: nothing is said until the delay reaches T minutes.
- A step: after the first message, nothing more is said until the delay has moved by another S minutes in either direction.
- 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 ─▶ logFive boxes, and the first two are one HTTP call each. What you write is the rules, the templates and the notifier; the signature guide covers the receiver and the 20-minute guide has it in full.
The subscription
Ask for exactly the events the rules use, and let the threshold do the first filter:
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.
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.deltaMinutessays how much the delay moved this time;side.delayMinutessays 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.
delayMinutesgoes 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
delayMinutesbecomes 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.reinstatedexists 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
dateandflightKeycarry it. - The same number, twice.
TK1flies two legs on one day. A subscription withoutoriginis refused with a400and the legs; pass the airport the passenger leaves from. The next guide 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 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 page has the subscription and the delay fields on one screen, without the rules.