Every error response has the shape:
{
"error": "code",
"error_description": "human-readable explanation"
}
The error value is a stable string — your code can match against
it without parsing English prose. Descriptions are best-effort and
may evolve.
HTTP status reference
| Status | Meaning | Typical error |
|---|---|---|
400 Bad Request | Your request was malformed, or the IGC file could not be parsed | invalid_request |
401 Unauthorized | Missing or invalid credentials | invalid_token / invalid_client |
403 Forbidden | Authenticated, but not allowed | insufficient_scope, unauthorized_client |
404 Not Found | Resource doesn't exist (or doesn't belong to you) | not_found |
410 Gone | A delta cursor is older than the change-retention window | cursor_expired |
413 Payload Too Large | IGC body over 3 MB | payload_too_large |
429 Too Many Requests | Rate limit exceeded | (no error code — see Retry-After) |
500 Internal Server Error | Bug on our side | server_error |
Delta-read errors (?since)
Two statuses, deliberately distinct, because the recovery actions are opposite
and a client that confuses them either loops or loses data. Both bodies carry
"recovery": "seed" so you can branch on one field.
| Status | Code | Meaning | What to do |
|---|---|---|---|
400 | invalid_cursor | Malformed, damaged, from another stream or account, or ahead of the stream | A bug on one side or the other. Log it, then reseed |
410 | cursor_expired | Older than the 90-day retention, or the stream's history was reset | Reseed and reconcile |
{
"error": "cursor_expired",
"error_description": "This cursor is older than the change-retention window, so a deletion may have aged out. Reseed with since=0 and delete every local row the seed does not return.",
"recovery": "seed"
}
Neither is ever answered with a full collection and a 200. Silently succeeding
is the one failure mode a client cannot detect. Reseeding means paging
?since=0 to completion and deleting local rows the seed did not return —
see Incremental sync.
OAuth-specific errors
These follow RFC 6749 §5.2. They appear in 4xx responses from
/api/oauth/token, /api/oauth/revoke, and /api/oauth/introspect.
| Code | Meaning |
|---|---|
invalid_request | Required parameter missing or malformed |
invalid_client | Client authentication failed (bad client_id/secret, or wrong auth method) |
invalid_grant | The code/refresh_token is expired, already used, revoked, or doesn't match redirect_uri/PKCE |
unauthorized_client | Your client isn't allowed to use this grant type, or your app is suspended |
unsupported_grant_type | We don't support that grant_type (we accept client_credentials, authorization_code, refresh_token) |
invalid_scope | A requested scope is unknown or not allowed for your app |
access_denied | The user clicked "Cancel" on the consent screen |
Device-code grant (RFC 8628 §3.5)
Returned from POST /api/oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code:
| Code | Meaning | What to do |
|---|---|---|
authorization_pending | The user has not yet entered the user_code | Keep polling at the advertised interval |
slow_down | You're polling faster than the advertised interval | Add 5 seconds to your interval, keep polling |
expired_token | The 10-minute code lifetime has elapsed | Restart the flow at /api/oauth/device_authorization |
access_denied | The user clicked "Cancel" | Surface to the user; don't restart automatically |
Bearer-token errors (API v1)
These appear with a WWW-Authenticate header per RFC 6750 §3.
WWW-Authenticate: Bearer error="insufficient_scope", error_description="…", scope="flights:write"
| Code | Meaning | What to do |
|---|---|---|
invalid_request | Missing or malformed Authorization header | Fix the request |
invalid_token | Token unknown, expired, or revoked | Refresh (OAuth) or re-issue (API key) |
insufficient_scope | Token doesn't carry the required scope | Re-authorise with the right scope |
Rate-limit errors
429 Too Many Requests is returned with:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1748426400
{
"success": false,
"error": "Rate limit exceeded. Please try again later.",
"retryAfter": 12
}
Sleep Retry-After seconds before retrying. Permanent loops will get
your app suspended.
Theft-detection cascade
If you present a refresh token that has already been rotated, all
of your tokens for that user are revoked — RFC 6819 §5.2.2.3. You
will receive invalid_grant immediately, and subsequent API calls
with previously-issued access tokens will return invalid_token. The
user must re-authorise from scratch.
This is non-overridable. Treat refresh tokens as single-use and atomically replace your stored copy with the new one before any retries.