Guides · September 12, 2026 · 6 min read

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.

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; 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:

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.

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:

{
  "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.

server.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRET = process.env.FLIGHTBYTE_WEBHOOK_SECRET;

// x-flightbyte-signature: t=<unix seconds>,v1=<hex>
// 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:

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:

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.

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://<your-tunnel>/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:

FieldWhy it is there
secretYour own signing secret, so every subscription is verified by the one receiver. Omit it and FlightByte generates one per subscription and returns it once.
eventsThe six change types this job cares about. Leave it out and you get all 23.
metadataEchoed back in every webhook. Put your booking id here, never the driver's phone number; look that up on your side.
Idempotency-KeyRetrying 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:

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.

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

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.

Where next

  • The same receiver in Python and Go, with the signed test sender.
  • Webhooks: every event and change type, the retry schedule, and the checklist for a receiver that behaves.
  • For airport transfers: the product view of this exact job, including which plan fits how many transfers.

Updated October 16, 2026.

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