Limits are counted per minute:
| Limit | Per minute |
|---|---|
| Your app, across all pilots | 600 requests |
| Your app for one pilot | 60 requests |
The limits are approximate. When you go over, the API answers 429 with a Retry-After header (seconds):
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{"error":"rate_limited"}Retry-After is currently 60 seconds. Wait that long, then retry. Do not retry in a tight loop. Back off if you are still limited.
First-time track downloads are throttled separately from the limits above, so a backfill cannot flood Gaggle. If you ask for too many tracks that Gaggle has not served before, track.gpx answers 429 with Retry-After. Spread backfills out over time, and prefer webhooks to polling. Tracks you have downloaded before are not throttled this way.
updatedSince instead of listing everything.limit=100.304 is cheap for everyone.Every GET response has an ETag. Keep it with the cached copy and send it back exactly as you received it (quotes included, a single value):
curl -i https://connect.flygaggle.com/v1/stats \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'If-None-Match: "a1b2c3"'If nothing changed you get 304 Not Modified with no body, and you keep your copy. Otherwise you get 200 with a new ETag.
async function getJson(url, accessToken, cache) {
const headers = { Authorization: `Bearer ${accessToken}` };
const cached = cache.get(url);
if (cached) headers["If-None-Match"] = cached.etag;
const res = await fetch(url, { headers });
if (res.status === 304) return cached.body;
if (!res.ok) throw new Error(`Gaggle answered ${res.status}`);
const body = await res.json();
cache.set(url, { etag: res.headers.get("etag"), body });
return body;
}Errors from the API use this shape. message and scope are only present when they apply.
{ "error": "insufficient_scope", "scope": "tracks" }| Status | error | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request | A parameter is missing or invalid | Fix the request |
| 401 | invalid_token | The access token is missing, expired or revoked. A response without a token has an empty body and a WWW-Authenticate header. | Refresh the token. If that fails, the pilot must connect again. |
| 403 | insufficient_scope | The pilot has not enabled the scope in scope | Read /v1/me. Do not ask again; the pilot can switch it on later. |
| 403 | provider_disabled | Gaggle has switched your app off | Contact us |
| 404 | not_found | No such resource, or it is not visible to you | The flight may be deleted, at a sharing level you cannot see, or outside the history window. For track.gpx it may also have no track. |
| 405 | invalid_request | Anything other than GET or HEAD | Use GET |
| 429 | rate_limited | Too many requests, or too many first-time track downloads | Wait Retry-After seconds |
| 502 | bridge_unavailable | A track could not be fetched right now | Retry shortly |
The OAuth endpoints (/oauth/token, /oauth/revoke) use the standard OAuth error shape instead: {"error": "invalid_grant", "error_description": "..."}.
401async function gaggleFetch(path, session) {
let res = await fetch(`https://connect.flygaggle.com${path}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (res.status === 401) {
await refreshTokens(session); // POST /oauth/token with grant_type=refresh_token; save the NEW refresh token
res = await fetch(`https://connect.flygaggle.com${path}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
}
return res;
}403 insufficient_scopeThe pilot can switch a permission off at any time and a connection.updated webhook tells you when they do. Stop calling the endpoint that needs it, tell the pilot in your own product what is missing, and carry on with what is still allowed.