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
FlightByte ──POST──▶ receiver ──▶ queue ──▶ fan-out worker ──▶ APNs / FCM / Expo
│ │
└─ 204 in milliseconds └─ reads flight → users → devicesTwo 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
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 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
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 explains why, and has the same receiver for other frameworks.
The worker
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.
delayThresholdMinutesstops 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 has the rule set and the reasons. - The last one.
arrival.baggage.changedis 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:
POST /3/device/{activityPushToken}
apns-push-type: liveactivity
apns-topic: com.yourapp.push-type.liveactivity
apns-priority: 10{
"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 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 has the table from fifty flights a month to ten thousand.