Gaggle uses the OAuth 2.1 authorization code flow with PKCE. You send the pilot to Gaggle, the pilot signs in and approves access in the Gaggle app, and Gaggle sends them back to you with a one-time code.
Everything runs on https://connect.flygaggle.com. You need a client id, a client secret and at least one registered redirect URI. Providers are registered by Gaggle; see how to request access.
| Endpoint | Purpose |
|---|---|
GET /oauth/authorize | Start the flow in the pilot’s browser |
POST /oauth/token | Exchange a code, or a refresh token, for tokens |
POST /oauth/revoke | Disconnect a pilot |
GET /.well-known/oauth-authorization-server | Discovery document for OAuth libraries |
Ask for the scopes you need. The pilot can switch any of them off except profile.
| Scope | Lets you |
|---|---|
profile | Know who the pilot is (an id that is unique to your app, and their display name). Always granted. |
stats | Read flight and airtime totals |
flights | Read the flight log and receive flight webhooks |
tracks | Download flight tracks as GPX |
offline_access | Accepted for OAuth clients that expect it. You get a refresh token either way. Never shown to the pilot. |
Every scope works on every plan. The plan decides how much detail comes back; see Permissions and plans.
Create a random code_verifier, hash it into a code_challenge (S256), and a random state. Keep the verifier and the state with the pilot’s session on your side.
import { randomBytes, createHash } from "node:crypto";
const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
const url = new URL("https://connect.flygaggle.com/oauth/authorize");
url.search = new URLSearchParams({
response_type: "code",
client_id: process.env.GAGGLE_CLIENT_ID,
redirect_uri: "https://example.com/gaggle/callback",
scope: "profile stats flights tracks",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
}).toString();
// Store { codeVerifier, state } in the pilot's session, then redirect to url.toString()Gaggle opens the Gaggle app (or the web app) where the pilot signs in, sees what you ask for, and switches individual permissions on or off and chooses which sharing levels of their flights you see. Pilots need a real Gaggle account; anonymous accounts cannot connect. state and PKCE with S256 are required, and you can only ask for the scopes your registration allows. A redirect_uri that is not registered, or an unknown client, gets a plain 400 page instead of a redirect.
On approval the pilot lands on your redirect URI:
https://example.com/gaggle/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=...The redirect also carries iss (https://connect.flygaggle.com). Check that state matches before you use the code.
If something is wrong you get ?error=...&state=... instead:
error | Meaning |
|---|---|
access_denied | The pilot declined. Or Gaggle has switched your app off (error_description=provider_disabled). |
invalid_request | PKCE with S256, or state, is missing |
invalid_scope | You asked for a scope that does not exist, or that your registration does not allow |
Some Gaggle accounts cannot connect apps (for example accounts owned by an event), and an app that has reached its limit of connected pilots cannot take new ones. In both cases the pilot sees an error in Gaggle and you receive no redirect at all, so treat a sign-in that never returns as abandoned.
Authenticate as your client with HTTP Basic, or send client_id and client_secret in the body.
curl -s https://connect.flygaggle.com/oauth/token \
-u "$GAGGLE_CLIENT_ID:$GAGGLE_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri="https://example.com/gaggle/callback" \
-d code_verifier="$CODE_VERIFIER"const res = await fetch("https://connect.flygaggle.com/oauth/token", {
method: "POST",
headers: {
Authorization: "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64"),
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://example.com/gaggle/callback",
code_verifier: codeVerifier,
}),
});
const tokens = await res.json();{
"access_token": "3f9c1b7e2a8d4c6f_example_access_token",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "ZWYyYTAxYjMtMzM0ZC00ZGY4_example_refresh_token",
"scope": "profile stats flights tracks",
"resource": "https://connect.flygaggle.com/v1"
}Treat the token strings as opaque. Store the refresh token securely; it is the long-lived credential. You always get a refresh token; offline_access is accepted as a scope but changes nothing. A failed client login answers 401 (invalid_client), and a bad or used code answers 400 (invalid_grant).
The scope in the token response is only the upper bound of what you asked for. The pilot may have switched some scopes off, and can change them later without reconnecting. Call GET /v1/me to see what you can use right now.
curl -s https://connect.flygaggle.com/v1/me \
-H "Authorization: Bearer $ACCESS_TOKEN"{
"id": "7c1f0a5e9b3d4e28a6f1c0d2e8b94a37",
"displayName": "Sam Pilot",
"scopes": ["profile", "stats", "flights", "tracks"],
"plan": "frequentFlyer",
"historyFrom": "2026-09-21T00:00:00.000Z",
"flightSharing": ["everyone", "groups", "friends"]
}id is unique to your app. The same pilot has a different id in every provider, and it is not their Gaggle account id.displayName is the pilot’s Gaggle name as it was when they connected your app.scopes are the scopes enabled now You get a 403 insufficient_scope if you call an endpoint outside them.plan is free, frequentFlyer or adventurer.historyFrom is where the flight history starts: 30 days back on Frequent Flyer (the start of a UTC day), and on Free the exact moment the pilot connected (Free has no history). It is null on Adventurer, which has the whole logbook.flightSharing lists the sharing levels of the pilot’s flights you can see: a subset of everyone, groups, friends and private (default: everyone, groups and friends). Manual log entries are always visible.Read /v1/me after connecting and whenever you receive a connection.updated webhook. See Permissions and plans.
Access tokens last 1 hour. Refresh before they expire, or when a request answers 401 invalid_token.
curl -s https://connect.flygaggle.com/oauth/token \
-u "$GAGGLE_CLIENT_ID:$GAGGLE_CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"Refresh tokens rotate: the response contains a new refresh token. Save it and discard the old one (the old one keeps working only until the new one is first used). A refresh token that goes unused for 90 days expires and the connection is removed, so a provider that keeps syncing stays connected and an abandoned connection lapses. A daily check removes expired connections within a few days, and you receive connection.revoked when it does. If a refresh fails with invalid_grant, the pilot has disconnected or the grant has expired; ask them to connect again.
To disconnect a pilot from your side, revoke their token. Gaggle removes the connection, the pilot sees it disappear from Settings, Integrations, Connected apps in the Gaggle app, and Gaggle stops sending you data.
curl -s https://connect.flygaggle.com/oauth/revoke \
-u "$GAGGLE_CLIENT_ID:$GAGGLE_CLIENT_SECRET" \
-d token="$REFRESH_TOKEN" \
-d token_type_hint=refresh_tokenRevoking the access token or the refresh token both end the whole connection. The call answers 200 even if the token is already invalid; a missing token is a 400. You also receive a connection.revoked webhook.
Pilots can also disconnect you at any time from the Gaggle app. You then receive a connection.revoked webhook, and your tokens stop working (401). Treat that as the end of the connection and delete the pilot’s Gaggle data from your systems as your own terms and the pilot’s expectations require.