Guides · September 21, 2026 · 5 min read

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.

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 → 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

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:

PriorityChange typesTitle
1flight.cancelled, flight.diverted, flight.reinstatedTK1848 is cancelled
2departure.gate.changed, departure.terminal.changedGate changed to B14
3delay.increased, delay.decreasedNow departing 14:40, 35 min late
4flight.departed, flight.arrivedTK1848 has landed
5arrival.baggage.changedBags 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:

TransportHow
APNsapns-collapse-id header, 64 bytes at most
FCMandroid.collapse_key for delivery, and android.notification.tag to replace what is on screen
ExpoNo 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 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:

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:

EventDo
subscription.activatedThe airline published the flight. Fill in the trip screen: terminals, aircraft, local times.
subscription.suspendedYour endpoint failed twenty deliveries in a row. Page a human, fix it, call resume, then read the events log for what was missed.
subscription.completedThe 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.

More guides

Get started

Watch your first flight tonight.

Nine dollars, a hundred flights, every event, signed. The receiver above is the whole integration.

A free key in a minute, no card. On a paid plan? Request a key