# Webhooks

> The delivery format, every event and change type, how to verify signatures, and what retries look like.

Source: https://flightbyte.dev/docs/webhooks

A webhook is a `POST` with a JSON body to your subscription's URL. It carries the event, the full flight snapshot at that moment, and the list of what changed since the previous delivery.

## Headers

```http
POST /hooks/flights HTTP/1.1
Content-Type: application/json
x-flightbyte-event-id: evt_2f0c8a4e-6f1d-4d0d-9a3b-0f1e2d3c4b5a
x-flightbyte-event-type: flight.updated
x-flightbyte-subscription-id: sub_7fd9fa92faf3475db99b
x-flightbyte-delivery-attempt: 1
x-flightbyte-timestamp: 1789305971
x-flightbyte-signature: t=1789305971,v1=5f1c9a...e3b2
```

Any `headers` you set on the subscription are sent as well.

> **Legacy header names**
>
> The same six values are also sent as `x-flightradar-*` for receivers written before the rename. Read the `x-flightbyte-*` names in new code; the old ones will be dropped in a future release.

## Payload

```json
{
  "id": "evt_2f0c8a4e-6f1d-4d0d-9a3b-0f1e2d3c4b5a",
  "type": "flight.updated",
  "createdAt": "2026-09-12T09:26:11.000Z",
  "subscriptionId": "sub_7fd9fa92faf3475db99b",
  "flightKey": "TK1848:2026-09-12@ATH-IST",
  "flight": { "...": "the full flight snapshot" },
  "changes": [
    {
      "type": "departure.gate.changed",
      "path": "departure.gate",
      "from": "A11",
      "to": "A22",
      "message": "Departure gate changed from A11 to A22"
    },
    {
      "type": "arrival.estimated.changed",
      "path": "arrival.times.estimatedGate",
      "from": "2026-09-12T10:45:00Z",
      "to": "2026-09-12T11:15:00Z",
      "deltaMinutes": 30,
      "message": "Arrival estimated gate time changed from 2026-09-12T10:45:00Z to 2026-09-12T11:15:00Z"
    }
  ],
  "previousFetchedAt": "2026-09-12T09:16:04.000Z",
  "metadata": { "bookingId": "BK-1042" }
}
```

| Field               | Meaning                                                                                                                                                                |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                | Stable across retries. Deduplicate on it.                                                                                                                              |
| `type`              | One of the [event types](https://flightbyte.dev/docs/webhooks#event-types).                                                                                                                                |
| `flight`            | The [snapshot](https://flightbyte.dev/docs/flights#the-snapshot) as of this event. Present on every event that has a resolved flight.                                                        |
| `changes`           | Empty on lifecycle events; otherwise one entry per change, each with a `path` into the snapshot, `from`, `to`, a human `message`, and `deltaMinutes` for time changes. |
| `previousFetchedAt` | When the snapshot these changes were computed against was taken.                                                                                                       |
| `metadata`          | Exactly what you stored on the subscription.                                                                                                                           |

## Event types

| Type                     | When                                                                                |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `subscription.created`   | Right after creation. Carries the flight if it could be resolved.                   |
| `subscription.activated` | A `pending` subscription's flight appeared upstream.                                |
| `flight.updated`         | Something about the flight changed. The only type with a non-empty `changes`.       |
| `subscription.test`      | You called the test endpoint.                                                       |
| `subscription.suspended` | Deliveries kept failing and polling stopped. Delivery of this one is attempted too. |
| `subscription.completed` | The watch ended: the flight arrived, or the date passed without it.                 |

## Change types

These are the values for a subscription's `events` filter and for `changes[].type`. A `flight.updated` event can carry several at once.

| Type                                                                                     | Fires when                                                         |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `status.changed`                                                                         | The status code or phase changed for any reason.                   |
| `flight.departed`                                                                        | The flight left the gate or the runway.                            |
| `flight.arrived`                                                                         | The flight reached the gate.                                       |
| `flight.cancelled`                                                                       | Cancelled.                                                         |
| `flight.diverted`                                                                        | Landing somewhere other than planned.                              |
| `flight.reinstated`                                                                      | A cancelled flight is back.                                        |
| `departure.gate.changed`, `departure.terminal.changed`                                   | Departure gate or terminal moved.                                  |
| `arrival.gate.changed`, `arrival.terminal.changed`                                       | Arrival gate or terminal moved.                                    |
| `arrival.baggage.changed`                                                                | Baggage carousel assigned or changed.                              |
| `departure.scheduled.changed`, `departure.estimated.changed`, `departure.actual.changed` | A departure time changed.                                          |
| `arrival.scheduled.changed`, `arrival.estimated.changed`, `arrival.actual.changed`       | An arrival time changed.                                           |
| `delay.increased`, `delay.decreased`                                                     | The gate delay grew or shrank by at least `delayThresholdMinutes`. |
| `aircraft.changed`, `aircraft.tail.changed`                                              | Aircraft type or the specific airframe changed.                    |
| `route.changed`                                                                          | Origin or destination changed.                                     |
| `codeshares.changed`                                                                     | The codeshare list changed.                                        |

## Verifying a delivery

The signature header is `t=<unix seconds>,v1=<hex>` where `v1` is `HMAC-SHA256(secret, "<t>.<raw body>")`. Verify against the **raw** bytes you received, before any JSON parsing, and reject timestamps older than a few minutes; FlightByte itself allows five.

**Node.js**

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, header: string, rawBody: Buffer, toleranceSeconds = 300): boolean {
  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)) > toleranceSeconds) return false;
  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));
}
```

**Python**

```python
import hmac, hashlib, time

def verify(secret: bytes, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or abs(time.time() - float(t)) > tolerance:
        return False
    expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)
```

**Go**

```go
func verify(secret, header string, rawBody []byte, tolerance time.Duration) bool {
	var t, v1 string
	for _, part := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(part, "="); ok {
			switch k {
			case "t":
				t = v
			case "v1":
				v1 = v
			}
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || time.Since(time.Unix(ts, 0)).Abs() > tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(v1), []byte(expected))
}
```

> **Frameworks that parse JSON for you**
>
> Express, Fastify, Django and friends usually hand you an object, not bytes. Configure the route to keep the raw body (`express.raw`, `rawBody: true` in Fastify, `request.body` in Django) and verify that; re-serialising the parsed object will not reproduce the exact bytes that were signed.

## Retries and suspension

A delivery succeeds when your endpoint answers any `2xx` within ten seconds. Otherwise:

* Network errors, timeouts and `5xx` responses are retried up to **8 times** with exponential backoff, starting at four seconds.
* `408` and `429` are retried the same way.
* Any other `4xx` is treated as your endpoint rejecting the event and is **not** retried.
* Every attempt is written to the [delivery log](https://flightbyte.dev/docs/subscriptions#deliveries-and-events) with its status and latency.

After **20 consecutive failed deliveries** the subscription is `suspended` and polling stops. Fix the endpoint, then `POST /v1/subscriptions/{id}/resume`; use the events log to see what you missed.

## Writing a receiver that behaves

* **Answer fast, work later.** Acknowledge with `204`, then process. Ten seconds is generous until your database is slow.
* **Deduplicate on `id`.** A retry carries the same `id` and the same body. Keep a small table of processed ids.
* **Do not assume order.** Retries can arrive after a newer event. `createdAt` and `flight.fetchedAt` tell you which snapshot is newer; when in doubt, the `flight` in the latest event is the truth.
* **Use `metadata`, not the URL.** One endpoint can serve every subscription; `metadata` tells you which booking it is about.
* **Fail loudly in development.** Log signature failures with the reason; a wrong secret and a re-serialised body look identical from the outside.

Three receivers that do all of the above, in Node.js, Python and Go, with a script that sends signed test events, are in [flightbyte-webhook-examples](https://github.com/batuhanthedeveloper/flightbyte-webhook-examples).
