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.
| Event | When |
|---|---|
flight.created | A 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.updated | A 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.deleted | A 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.updated | The pilot changed their switches (scopes or the sharing levels you see), or their plan changed |
connection.revoked | The 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.
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.
Every request has three headers:
| Header | Content |
|---|---|
webhook-id | A unique id for the message. The same on every retry. |
webhook-timestamp | Unix time in seconds |
webhook-signature | v1, 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.
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).
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")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.webhook-id is the same on every attempt. A message that still fails after its last attempt is dropped.webhook-id (or the event id) to ignore duplicates, and treat GET /v1/flights/{id} as the truth.updatedSince to catch up on anything you missed. See Flights and tracks.