Gaggle Logo
Gaggle Developers

Webhooks

Webhooks tell you when something changed, so you do not have to poll. They are sent for every plan. Your webhook URL and signing secret are set when we register you; to change the URL, email us.

Webhooks follow the Standard Webhooks specification, so any Standard Webhooks library can verify them.

Events

EventWhen
flight.createdA flight is added to the pilot’s log and is ready. A recorded or imported flight sends nothing until its track has been processed, and then flight.created; a manual log entry sends flight.created as soon as it is created.
flight.updatedA flight you can see was changed: the pilot edited it, or changed its sharing. Recorded flights send this for changes after the track is ready.
flight.deletedA flight was deleted, taken under an event organiser’s control (those are never shared), or its sharing level changed to one your connection cannot see
connection.updatedThe pilot changed their switches (scopes or the sharing levels you see), or their plan changed
connection.revokedThe connection ended: the pilot disconnected, deleted their account, you revoked it, or its refresh token went unused for 90 days (a daily check removes it within a few days). Always sent.

flight.* events go only to connections with the flights scope enabled, only for flights inside the pilot’s history window, and only at the sharing levels the pilot allows you to see. The windows are the same as in the API: Free only gets flights that reached Gaggle after the pilot connected, Frequent Flyer the last 30 days, Adventurer the whole logbook. Flights that were already in the pilot’s log when they connected your app are not announced; read them with GET /v1/flights.

Each event is sent as a POST with Content-Type: application/json and a User-Agent of Gaggle-Webhooks/1. Redirects are not followed.

Payloads

The payloads are thin. Fetch what you need through the API.

{
  "id": "evt_01JAQ3Z8M2V6N4K9X7R5T1C0BD",
  "type": "flight.created",
  "createdAt": "2026-10-18T11:31:56.000Z",
  "data": {
    "userId": "7c1f0a5e9b3d4e28a6f1c0d2e8b94a37",
    "flightId": "8Vq2kLw0TbXn4YhZ"
  }
}

userId is the same pilot id you get from /v1/me.

{
  "id": "evt_01JAQ6D2N8R4Y0B5J7G9H3K1XC",
  "type": "connection.updated",
  "createdAt": "2026-10-20T08:15:00.000Z",
  "data": {
    "userId": "7c1f0a5e9b3d4e28a6f1c0d2e8b94a37",
    "scopes": ["profile", "stats", "flights"],
    "plan": "frequentFlyer",
    "flightSharing": ["everyone", "groups", "friends"]
  }
}

scopes are the scopes you can use now.

On connection.updated, call GET /v1/me and re-sync the pilot’s flights. On connection.revoked, stop using the pilot’s tokens and delete their Gaggle data as your own terms require. connection.revoked has only userId in data.

Verify the signature

Every request has three headers:

HeaderContent
webhook-idA unique id for the message. The same on every retry.
webhook-timestampUnix time in seconds
webhook-signaturev1, followed by a base64 HMAC-SHA256 signature. It can hold several space-separated signatures.

The signature covers {webhook-id}.{webhook-timestamp}.{raw body}. The key is your webhook secret, which looks like whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw: drop the whsec_ prefix and base64-decode the rest.

Use the raw request body, not a re-serialised JSON object. Reject messages with an old timestamp (say, more than five minutes) to stop replays, and compare signatures in constant time.

Node.js

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

export function verifyGaggleWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = String(headers["webhook-signature"] ?? "").split(" ");
  if (!id || !timestamp) throw new Error("Missing webhook headers");

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) {
    throw new Error("Timestamp too old or too new");
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();

  const ok = signatures.some((entry) => {
    const [version, sig] = entry.split(",");
    if (version !== "v1" || !sig) return false;
    const given = Buffer.from(sig, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!ok) throw new Error("Invalid signature");

  return JSON.parse(rawBody);
}

With Express, use express.raw({ type: "application/json" }) on the webhook route so req.body is the raw buffer, then call verifyGaggleWebhook(req.body.toString("utf8"), req.headers, process.env.GAGGLE_WEBHOOK_SECRET).

Python

import base64
import hashlib
import hmac
import json
import time


def verify_gaggle_webhook(raw_body: bytes, headers: dict, secret: str) -> dict:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signatures = headers["webhook-signature"].split(" ")

    if abs(time.time() - int(timestamp)) > 5 * 60:
        raise ValueError("Timestamp too old or too new")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    for entry in signatures:
        version, _, sig = entry.partition(",")
        if version == "v1" and hmac.compare_digest(base64.b64decode(sig), expected):
            return json.loads(raw_body)

    raise ValueError("Invalid signature")

Responding and retries

  • Answer with any 2xx status to confirm. Do the real work afterwards (for example, queue a job that fetches the flight). Gaggle waits 10 seconds for your answer.
  • Any other answer (including a redirect), or no answer, is a failure. Gaggle tries each message up to 8 times: the first retry after 1 minute, then 2, 4, 8 minutes and so on (each delay doubles, to at most 12 hours), so about 2 hours in all. The webhook-id is the same on every attempt. A message that still fails after its last attempt is dropped.
  • If deliveries keep failing for 3 days without a single success, Gaggle switches your webhook off. Nobody is emailed, so watch your own endpoint. Fix the problem, then email us at hello@flygaggle.com and we switch it back on.
  • Delivery is at least once and not strictly ordered. Use webhook-id (or the event id) to ignore duplicates, and treat GET /v1/flights/{id} as the truth.
  • After an outage, run an incremental sync with updatedSince to catch up on anything you missed. See Flights and tracks.