openapi: 3.1.0
info:
  title: Gaggle Connect API
  version: 1.0.0
  summary: Read a pilot's Gaggle stats, flight log and GPX tracks, with the pilot's consent.
  description: |
    The Gaggle Connect API lets an approved provider (a logbook, a coaching tool, a wing maker)
    read a pilot's Gaggle data after the pilot has signed in and approved access. Providers can
    also receive webhooks when a pilot records a new flight.

    **Access.** Providers are registered by Gaggle. To ask for access, email
    [hello@flygaggle.com](mailto:hello@flygaggle.com). You receive a client id, a client secret, and a
    webhook signing secret.

    **The pilot is in control.** The pilot chooses which scopes to share, which of their flights are shared
    (by sharing level: everyone, groups, friends, private), and can disconnect your app at any time. A scope the pilot switches off answers `403`
    on the next request. Read [`GET /v1/me`](#/Pilot/getMe) for what you can use right now, not the
    scope on the access token.

    **The plan sets the history and the detail of the log and stats.** Every scope works on every plan (Free,
    Frequent Flyer and Adventurer). The pilot's plan decides how far back flights go, which flight-log fields you get
    (distance, weather) and how detailed the stats are. Tracks are the same on every plan. Fields that depend on the
    plan are marked in the schemas below and are simply absent when the plan does not include them, so treat every
    optional field as optional.

    | | Free | Frequent Flyer | Adventurer |
    | --- | --- | --- | --- |
    | Flight history | new flights only (those that reach Gaggle after the pilot connects) | last 30 days | whole logbook |
    | Flight fields | date, duration, site, aircraft, take-off coordinates, flight type | + distance | + distance, weather |
    | Stats | lifetime flights and airtime | + lifetime distance, per year (with distance) | + per month, per aircraft |
    | Tracks | time, latitude, longitude, altitude | same | same |

    **AI assistants.** Pilots can also connect an AI assistant (Claude, ChatGPT and others) through a read-only MCP
    server at `https://connect.flygaggle.com/mcp`. It is not described in this file; see
    [Connect an AI assistant](https://flygaggle.com/developers/ai-assistants).

    **Conventions.** JSON field names are camelCase. Timestamps are ISO 8601 in UTC. Every `GET` response
    carries an `ETag`; send it back in `If-None-Match` and an unchanged resource answers `304`.
    Requests are rate limited (see the `429` response).
  contact:
    name: Gaggle developer support
    email: hello@flygaggle.com
    url: https://flygaggle.com/developers
servers:
  - url: https://connect.flygaggle.com
    description: Production
tags:
  - name: OAuth
    description: Sign the pilot in and get tokens. OAuth 2.1 authorization code flow with PKCE (S256).
  - name: Pilot
    description: Who the pilot is and what you may use.
  - name: Stats
    description: Flight and airtime totals.
  - name: Flights
    description: The flight log and flight tracks.
  - name: Webhooks
    description: |
      Messages Gaggle sends to your webhook URL, signed with Standard Webhooks.

      Answer any `2xx` status to acknowledge. Anything else (another status, a redirect, or no answer within 10
      seconds) is a failure and the message is retried: up to 8 attempts, the first retry after 1 minute and the delay
      doubling each time (capped at 12 hours), so about 2 hours in all. The `webhook-id` is the same on every retry.
      A message that is still failing after its last attempt is dropped; catch up with `GET /v1/flights?updatedSince=`.
      If deliveries keep failing for 3 days without a single success, the webhook is switched off until Gaggle
      switches it back on (email hello@flygaggle.com).
security:
  - gaggleOAuth: []
paths:
  /oauth/authorize:
    get:
      tags: [OAuth]
      operationId: authorize
      summary: Send the pilot to Gaggle to approve access
      description: |
        Redirect the pilot's browser here. Gaggle signs the pilot in if needed, shows what you ask
        for, and lets the pilot switch individual scopes (except `profile`) and choose which sharing levels of their flights you see.
        On approval the pilot is redirected to your `redirect_uri` with `code` and `state`. If the pilot
        denies, the redirect carries `error=access_denied` and your `state`. If the pilot's account cannot connect
        (for example an account owned by an event), Gaggle shows the pilot an error and does not redirect at all, so you
        receive nothing: treat a sign-in that never returns as abandoned.

        PKCE with `S256` is required. The `redirect_uri` must exactly match one registered for your client, and
        `state` is required.

        Problems are reported by redirecting to your `redirect_uri` with `error` (and `error_description`) and your
        `state`: `invalid_request` when PKCE with S256 or `state` is missing, `invalid_scope` for a scope your app may not ask for, and
        `access_denied` when the pilot declines, or when Gaggle has switched your app off
        (`error_description=provider_disabled`). If your app has reached its limit of connected pilots, the pilot
        simply cannot complete consent in Gaggle and you receive no redirect. Redirects also carry `iss`, the issuer
        (`https://connect.flygaggle.com`). If the client or redirect URI is not valid there is nobody safe to
        redirect to, so Gaggle answers `400` with a plain page instead.
      security: []
      parameters:
        - name: response_type
          in: query
          required: true
          schema: { type: string, const: code }
        - name: client_id
          in: query
          required: true
          schema: { type: string }
        - name: redirect_uri
          in: query
          required: true
          schema: { type: string, format: uri }
        - name: scope
          in: query
          required: false
          description: |
            Space-separated scopes you are asking for, for example `profile stats flights tracks`. `profile` is
            always included. You can only ask for the scopes your registration allows; any other scope is refused
            with `invalid_scope`.
          schema: { type: string, examples: ["profile stats flights tracks offline_access"] }
        - name: state
          in: query
          required: true
          description: An unguessable value you check when the pilot comes back. Required; without it the request is refused with `invalid_request`.
          schema: { type: string }
        - name: code_challenge
          in: query
          required: true
          description: Base64url (no padding) SHA-256 of your `code_verifier`.
          schema: { type: string }
        - name: code_challenge_method
          in: query
          required: true
          schema: { type: string, const: S256 }
      responses:
        "302":
          description: |
            Redirect to the Gaggle app to sign in and approve. For the problems listed above the redirect goes
            to your `redirect_uri` with an `error` instead.
          headers:
            Location:
              schema: { type: string, format: uri }
        "400":
          description: The client or the redirect URI is not valid. A plain HTML page, not JSON.
          content:
            text/html:
              schema: { type: string }
  /oauth/token:
    post:
      tags: [OAuth]
      operationId: token
      summary: Exchange a code or a refresh token for tokens
      description: |
        Authenticate as your client with HTTP Basic (`client_secret_basic`) or with `client_id` and
        `client_secret` in the form body (`client_secret_post`). Access tokens last 1 hour. A refresh token comes
        back with every authorization code exchange, whether or not you asked for `offline_access`. Refresh tokens
        rotate: every refresh returns a new refresh token; store it and discard the old one (the old one keeps working
        only until the new one is first used). A refresh token that is not used for 90 days expires and the
        connection is removed (you receive `connection.revoked`), so a provider that keeps syncing stays connected.

        The tokens are valid only while the pilot stays connected.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              oneOf:
                - $ref: "#/components/schemas/AuthorizationCodeGrant"
                - $ref: "#/components/schemas/RefreshTokenGrant"
            examples:
              authorizationCode:
                summary: Exchange the code from the redirect
                value:
                  grant_type: authorization_code
                  code: SplxlOBeZQQYbYS6WxSbIA
                  redirect_uri: https://example.com/callback
                  code_verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
              refresh:
                summary: Get a new access token
                value:
                  grant_type: refresh_token
                  refresh_token: ZWYyYTAxYjMtMzM0ZC00ZGY4_example_refresh_token
      responses:
        "200":
          description: Tokens.
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
              example:
                access_token: 3f9c1b7e2a8d4c6f_example_access_token
                token_type: bearer
                expires_in: 3600
                refresh_token: ZWYyYTAxYjMtMzM0ZC00ZGY4_example_refresh_token
                scope: profile stats flights tracks offline_access
                resource: https://connect.flygaggle.com/v1
        "400":
          description: The grant is invalid, expired or already used.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
              example:
                error: invalid_grant
                error_description: The authorization code is invalid or expired.
        "401":
          description: Client authentication failed (`invalid_client`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
  /oauth/revoke:
    post:
      tags: [OAuth]
      operationId: revoke
      summary: Revoke a token and disconnect the pilot
      description: |
        Revokes the pilot's grant for your app (RFC 7009). Revoking either token ends the whole connection:
        Gaggle removes it, the pilot sees it disappear from Connected apps in Gaggle, the pilot's tokens stop
        working and you stop receiving data. You also receive a `connection.revoked` webhook. Answers `200` even when the
        token is already invalid or does not belong to your app, and `400` when `token` is missing.
        Authenticate as your client like `/oauth/token`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  description: An access token or a refresh token.
                token_type_hint:
                  type: string
                  enum: [access_token, refresh_token]
      responses:
        "200":
          description: Revoked (or already invalid).
        "400":
          description: The `token` parameter is missing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: Client authentication failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
  /.well-known/oauth-authorization-server:
    get:
      tags: [OAuth]
      operationId: oauthMetadata
      summary: Authorization server metadata
      description: OAuth 2.0 Authorization Server Metadata (RFC 8414). Useful for OAuth libraries that discover endpoints.
      security: []
      responses:
        "200":
          description: The metadata document.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                properties:
                  issuer: { type: string, format: uri }
                  authorization_endpoint: { type: string, format: uri }
                  token_endpoint: { type: string, format: uri }
                  revocation_endpoint: { type: string, format: uri }
                  scopes_supported: { type: array, items: { type: string } }
                  code_challenge_methods_supported: { type: array, items: { type: string } }
              example:
                issuer: https://connect.flygaggle.com
                authorization_endpoint: https://connect.flygaggle.com/oauth/authorize
                token_endpoint: https://connect.flygaggle.com/oauth/token
                revocation_endpoint: https://connect.flygaggle.com/oauth/revoke
                scopes_supported: [profile, stats, flights, tracks, offline_access]
                response_types_supported: [code]
                grant_types_supported: [authorization_code, refresh_token]
                code_challenge_methods_supported: [S256]
  /v1/me:
    get:
      tags: [Pilot]
      operationId: getMe
      summary: The pilot, what you may use and their plan
      description: |
        Requires `profile`. This is the source of truth for what the pilot allows **right now**:
        `scopes` are the scopes the pilot currently has switched on, `plan` is the pilot's current plan and
        `historyFrom` is where the history window starts: on Frequent Flyer a UTC day start (so it moves once a day), on Free
        the exact moment the pilot connected, and `null` on Adventurer, which has the whole logbook. Do not rely on the `scope` of the access token, which
        is only the upper bound you asked for. Call this after connecting and whenever you receive
        `connection.updated`.
      x-required-scope: profile
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: The pilot.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
              examples:
                free:
                  summary: Free pilot (new flights only)
                  value:
                    id: 7c1f0a5e9b3d4e28a6f1c0d2e8b94a37
                    displayName: Sam Pilot
                    scopes: [profile, stats, flights, tracks]
                    plan: free
                    historyFrom: "2026-10-18T00:00:00.000Z"
                    flightSharing: [everyone, groups, friends]
                frequentFlyer:
                  summary: Frequent Flyer pilot
                  value:
                    id: 7c1f0a5e9b3d4e28a6f1c0d2e8b94a37
                    displayName: Sam Pilot
                    scopes: [profile, stats, flights, tracks]
                    plan: frequentFlyer
                    historyFrom: "2026-09-21T00:00:00.000Z"
                    flightSharing: [everyone, groups, friends]
                adventurer:
                  summary: Adventurer pilot
                  value:
                    id: 7c1f0a5e9b3d4e28a6f1c0d2e8b94a37
                    displayName: Sam Pilot
                    scopes: [profile, stats, flights, tracks]
                    plan: adventurer
                    historyFrom: null
                    flightSharing: [everyone, groups, friends, private]
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/stats:
    get:
      tags: [Stats]
      operationId: getStats
      summary: Flight and airtime totals
      description: |
        Requires `stats`. Stats always count the pilot's whole logbook, whatever the history window (also on Free). What is
        returned depends on the plan:

        | | Free | Frequent Flyer | Adventurer |
        | --- | --- | --- | --- |
        | `lifetime.flights`, `lifetime.airtimeMinutes` | yes | yes | yes |
        | `lifetime.distanceKm` | | yes | yes |
        | `years` (flights, airtime and `distanceKm` per year) | | yes | yes |
        | `years[].months` (with `distanceKm`) | | | yes |
        | `aircraft` | | | yes |

        Only flights at the sharing levels the pilot allows you to see are counted (see `flightSharing` in `/v1/me`;
        manual log entries are always counted). Deleted flights are not counted. `years` come newest first. Each year's `months` come in calendar order and only
        months with flights are listed. Distances are rounded to 0.1 km. `aircraft` is ordered by number of
        flights, most first.
      x-required-scope: stats
      parameters:
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: Stats for the pilot's plan.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stats" }
              examples:
                free:
                  summary: Free
                  value:
                    lifetime:
                      flights: 214
                      airtimeMinutes: 18420
                frequentFlyer:
                  summary: Frequent Flyer
                  value:
                    lifetime:
                      flights: 214
                      airtimeMinutes: 18420
                      distanceKm: 3120.4
                    years:
                      - { year: 2026, flights: 41, airtimeMinutes: 3980, distanceKm: 702.9 }
                      - { year: 2025, flights: 63, airtimeMinutes: 5710, distanceKm: 951.2 }
                adventurer:
                  summary: Adventurer
                  value:
                    lifetime:
                      flights: 214
                      airtimeMinutes: 18420
                      distanceKm: 3120.4
                    years:
                      - year: 2026
                        flights: 41
                        airtimeMinutes: 3980
                        distanceKm: 702.9
                        months:
                          - { month: 9, flights: 6, airtimeMinutes: 640, distanceKm: 118.2 }
                          - { month: 10, flights: 4, airtimeMinutes: 410, distanceKm: 77.5 }
                    aircraft:
                      - { name: Ozone Zeno 2, flights: 120, airtimeMinutes: 10300 }
                      - { name: Advance Sigma 11, flights: 94, airtimeMinutes: 8120 }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/flights:
    get:
      tags: [Flights]
      operationId: listFlights
      summary: List flights
      description: |
        Requires `flights`. Returns flight log entries inside the pilot's history window, in pages of up to 100. The
        window depends on the plan: Free has no history, you see only flights that first reached Gaggle after the
        pilot connected your app; Frequent Flyer sees flights from the last 30 days (by flight date) and Adventurer
        the whole logbook. The order is by flight
        `date`, newest first (not by `updatedAt`). Distance (Frequent Flyer and Adventurer) and weather (Adventurer)
        depend on the plan; see the `Flight` schema.

        **Syncing.** `updatedAt` is when Gaggle last changed the flight for you, so it only moves forward and is safe
        to sync on. Do a full sync by following `nextCursor` until it is `null`. Afterwards, pass the largest
        `updatedAt` you have seen as `updatedSince` (entries with `updatedAt` at or after it are returned) to get only
        what changed. Flights that were deleted, or that Gaggle no longer shares with you because their sharing level
        stopped being visible to your app (the flight was changed to another level, or the pilot narrowed the levels
        you see), come back as tombstones (`{id, deleted: true, updatedAt}`) for 30 days after the change, in the full
        listing too. When the pilot narrows the levels you see, `updatedAt` is the time of that change; remove them
        from your copy. Tombstones follow the same history
        window as flights (on Free, never for flights from before the pilot connected). A tombstone carries only
        `id`, `deleted` and `updatedAt`; the flight's details are gone.
        Flights that leave the history window (for example when the plan changes) simply stop appearing, with no
        tombstone; after a `connection.updated` webhook, re-sync.

        Flights appear only at the sharing levels the pilot allows you to see (`flightSharing` in `/v1/me`; by default
        everyone, groups and friends). Manual log entries are always visible. Imported flights are private by default
        in Gaggle, so you see them only if the pilot allows the `private` level. Recordings controlled by an event
        organiser are never shared.
      x-required-scope: flights
      parameters:
        - name: updatedSince
          in: query
          description: Only entries created, changed or deleted at or after this time (compared with `updatedAt`). An invalid date answers `400`.
          schema: { type: string, format: date-time }
        - name: cursor
          in: query
          description: The `nextCursor` of the previous page. Opaque; do not build or parse it. An invalid cursor answers `400`.
          schema: { type: string }
        - name: limit
          in: query
          description: Page size.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: A page of flights.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FlightPage" }
              example:
                flights:
                  - id: 8Vq2kLw0TbXn4YhZ
                    date: "2026-10-18T09:42:10.000Z"
                    durationMinutes: 96
                    site: Bell Hill
                    source: recording
                    hasTrack: true
                    updatedAt: "2026-10-18T11:31:55.000Z"
                    aircraft: { name: Ozone Zeno 2 }
                    takeOffCoordinates: { lat: -33.9722, lng: 18.4361 }
                    distanceKm: 23.4
                    flightType: PG
                    weather: { windSpeed: 4.2, windDirection: 190 }
                  - id: 2mNf8RcA1QsJz6Uk
                    deleted: true
                    updatedAt: "2026-10-19T07:02:11.000Z"
                nextCursor: eyJvIjoxMDB9
        "304": { $ref: "#/components/responses/NotModified" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/flights/{id}:
    get:
      tags: [Flights]
      operationId: getFlight
      summary: Get one flight
      description: |
        Requires `flights`. Returns the flight with the fields the pilot's plan includes (distance on Frequent Flyer and
        Adventurer, weather on Adventurer). Answers `404`
        when the flight does not exist, is deleted, is at a sharing level you cannot see, or is outside the history window
        (on Free: it was already in Gaggle when the pilot connected). A flight without a track (`hasTrack` is `false`) is still returned; only the track endpoint answers `404`.
      x-required-scope: flights
      parameters:
        - $ref: "#/components/parameters/FlightId"
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: The flight.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Flight" }
              examples:
                free:
                  summary: Free
                  value:
                    id: 8Vq2kLw0TbXn4YhZ
                    date: "2026-10-18T09:42:10.000Z"
                    durationMinutes: 96
                    site: Bell Hill
                    source: recording
                    hasTrack: true
                    updatedAt: "2026-10-18T11:31:55.000Z"
                    aircraft: { name: Ozone Zeno 2 }
                    takeOffCoordinates: { lat: -33.9722, lng: 18.4361 }
                    flightType: PG
                adventurer:
                  summary: Adventurer (Frequent Flyer is the same without weather)
                  value:
                    id: 8Vq2kLw0TbXn4YhZ
                    date: "2026-10-18T09:42:10.000Z"
                    durationMinutes: 96
                    site: Bell Hill
                    source: recording
                    hasTrack: true
                    updatedAt: "2026-10-18T11:31:55.000Z"
                    aircraft: { name: Ozone Zeno 2 }
                    takeOffCoordinates: { lat: -33.9722, lng: 18.4361 }
                    distanceKm: 23.4
                    flightType: PG
                    weather: { windSpeed: 4.2, windDirection: 190 }
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFoundFlight" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/flights/{id}/track.gpx:
    get:
      tags: [Flights]
      operationId: getFlightTrack
      summary: Download a flight track as GPX
      description: |
        Requires `tracks`. A plain GPX 1.1 file with one track and every recorded point, exactly as recorded on the
        device (or imported). The responses are gzip-compressed (`Content-Encoding: gzip`); most HTTP clients
        decompress them for you.

        Every point has `lat`, `lon`, `<ele>` (altitude in metres) and `<time>` (UTC), on every plan. Nothing else is
        included: compute speed, heading, climb rate and similar values from the points. A point without a recorded
        altitude has no `<ele>`.

        Answers `404` when the flight has no track (`hasTrack` is `false`), is deleted, is outside the history window
        or is not shared. If it turns out the flight has no track after all, you get `404` and `hasTrack` becomes `false`.
        The first download of a flight's track takes a little longer than later ones, and first
        downloads are throttled: if you ask for too many tracks that Gaggle has not served before in a short time
        (for example while backfilling a new pilot), you get `429` with `Retry-After`. Wait and retry. A `502` with
        `bridge_unavailable` means the track could not be fetched right now; retry shortly.

        The file is sent as an attachment (`Content-Disposition: attachment; filename="{id}.gpx"`). It has the flight
        date in `<metadata>` and the take-off site as the track name. The `ETag` changes when the track changes, and
        when the flight's date or site changes.
      x-required-scope: tracks
      parameters:
        - $ref: "#/components/parameters/FlightId"
        - $ref: "#/components/parameters/IfNoneMatch"
      responses:
        "200":
          description: The GPX file.
          headers:
            ETag: { $ref: "#/components/headers/ETag" }
            Content-Encoding:
              schema: { type: string, const: gzip }
            Content-Disposition:
              description: '`attachment; filename="{id}.gpx"`'
              schema: { type: string }
          content:
            application/gpx+xml:
              schema: { type: string }
              example: |
                <?xml version="1.0" encoding="UTF-8"?>
                <gpx version="1.1" creator="Gaggle" xmlns="http://www.topografix.com/GPX/1/1">
                <metadata><time>2026-10-18T09:42:10.000Z</time></metadata>
                <trk><name>Bell Hill</name><trkseg>
                <trkpt lat="-33.9722" lon="18.4361"><ele>412.3</ele><time>2026-10-18T09:42:10Z</time></trkpt>
                <trkpt lat="-33.9725" lon="18.4366"><ele>408.9</ele><time>2026-10-18T09:42:11Z</time></trkpt>
                </trkseg></trk>
                </gpx>
        "304": { $ref: "#/components/responses/NotModified" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFoundTrack" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "502":
          description: The track could not be fetched right now. Retry shortly.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: bridge_unavailable }
webhooks:
  flight.created:
    post:
      tags: [Webhooks]
      operationId: webhookFlightCreated
      summary: A flight was added
      description: |
        Sent when a flight appears in the pilot's log and is ready. Recorded and imported flights send nothing
        until their track has been processed: `flight.created` then, and `flight.updated` for later changes. A
        manual log entry sends `flight.created` when it is created. Flights that existed when the pilot connected
        your app are not announced; read them with `GET /v1/flights`. Sent only to connections with `flights`
        enabled, for flights inside the history window (the same windows as the API; Free only gets flights created after
        the pilot connected), and only at the sharing levels the pilot allows you to see.
        The payload is thin: fetch the flight with `GET /v1/flights/{id}`.
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FlightEvent" }
            example:
              id: evt_01JAQ3Z8M2V6N4K9X7R5T1C0BD
              type: flight.created
              createdAt: "2026-10-18T11:31:56.000Z"
              data:
                userId: 7c1f0a5e9b3d4e28a6f1c0d2e8b94a37
                flightId: 8Vq2kLw0TbXn4YhZ
      responses:
        "2XX":
          description: Any 2xx status acknowledges the delivery. Anything else is retried.
  flight.updated:
    post:
      tags: [Webhooks]
      operationId: webhookFlightUpdated
      summary: A flight changed
      description: |
        Sent when a flight you can see changes: the pilot edits it (site, flight type, aircraft), or changes its sharing
        level to one you can see. Same rules as `flight.created`.
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FlightEvent" }
            example:
              id: evt_01JAQ4B0H5W9D3F7G2S8E6Y1NA
              type: flight.updated
              createdAt: "2026-10-18T12:05:00.000Z"
              data:
                userId: 7c1f0a5e9b3d4e28a6f1c0d2e8b94a37
                flightId: 8Vq2kLw0TbXn4YhZ
      responses:
        "2XX":
          description: Any 2xx status acknowledges the delivery. Anything else is retried.
  flight.deleted:
    post:
      tags: [Webhooks]
      operationId: webhookFlightDeleted
      summary: A flight was deleted
      description: |
        Sent when a flight is deleted from the pilot's log, when it is taken under an event organiser's control
        (those flights are never shared), or when its sharing level changes to one your connection cannot see. Remove it from your copy. The flight also appears as a tombstone in `GET /v1/flights` for 30 days.
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FlightEvent" }
            example:
              id: evt_01JAQ5C9K1P7M3T6V8A2D4F0ZE
              type: flight.deleted
              createdAt: "2026-10-19T07:02:11.000Z"
              data:
                userId: 7c1f0a5e9b3d4e28a6f1c0d2e8b94a37
                flightId: 2mNf8RcA1QsJz6Uk
      responses:
        "2XX":
          description: Any 2xx status acknowledges the delivery. Anything else is retried.
  connection.updated:
    post:
      tags: [Webhooks]
      operationId: webhookConnectionUpdated
      summary: The pilot changed what they share, or their plan changed
      description: |
        Sent when the pilot switches scopes or changes which sharing levels you see, or when the pilot's plan changes.
        `data.scopes` lists the scopes you can use now, `data.plan` the current plan and
        `data.flightSharing` the sharing levels you can see. Read `GET /v1/me` and re-sync
        your flights: the history window and the fields you get can have changed.
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionUpdatedEvent" }
            example:
              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]
      responses:
        "2XX":
          description: Any 2xx status acknowledges the delivery. Anything else is retried.
  connection.revoked:
    post:
      tags: [Webhooks]
      operationId: webhookConnectionRevoked
      summary: The pilot disconnected your app
      description: |
        Always sent when a connection ends (the pilot disconnected in Gaggle, deleted their account, your
        app called `/oauth/revoke`, or the connection expired because its refresh token was not used for 90 days; a
        daily check removes expired connections within a few days). Tokens for this pilot no longer work. Delete the pilot's Gaggle data
        from your systems as your own terms allow.
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConnectionRevokedEvent" }
            example:
              id: evt_01JAQ7E5P3S9Z1C6K8M0V2W4QR
              type: connection.revoked
              createdAt: "2026-10-20T09:00:00.000Z"
              data:
                userId: 7c1f0a5e9b3d4e28a6f1c0d2e8b94a37
      responses:
        "2XX":
          description: Any 2xx status acknowledges the delivery. Anything else is retried.
components:
  securitySchemes:
    gaggleOAuth:
      type: oauth2
      description: |
        OAuth 2.1 authorization code flow with PKCE (S256). Send the access token as
        `Authorization: Bearer <token>`.
      flows:
        authorizationCode:
          authorizationUrl: https://connect.flygaggle.com/oauth/authorize
          tokenUrl: https://connect.flygaggle.com/oauth/token
          refreshUrl: https://connect.flygaggle.com/oauth/token
          scopes:
            profile: Know who the pilot is (a stable id for your app and the display name). Always granted.
            stats: Read the pilot's flight and airtime totals.
            flights: Read the pilot's flight log and receive flight webhooks.
            tracks: Download the pilot's flight tracks as GPX.
            offline_access: Accepted for OAuth clients that expect it. A refresh token is returned either way. Never shown to the pilot.
  parameters:
    IfNoneMatch:
      name: If-None-Match
      in: header
      description: |
        The `ETag` of a copy you already have, exactly as you received it (including the quotes; a single value).
        If it is still current the response is `304` with no body.
      schema: { type: string }
    FlightId:
      name: id
      in: path
      required: true
      description: The flight id from the flight log.
      schema: { type: string }
    WebhookId:
      name: webhook-id
      in: header
      required: true
      description: Unique id of this message. The same on every retry, so use it to ignore duplicates.
      schema: { type: string, examples: [msg_2KWPBgLlAfxdpx2AI54pPJ85f4W] }
    WebhookTimestamp:
      name: webhook-timestamp
      in: header
      required: true
      description: Unix time in seconds when the message was sent. Reject messages that are too old (for example more than 5 minutes).
      schema: { type: string, examples: ["1760945400"] }
    WebhookSignature:
      name: webhook-signature
      in: header
      required: true
      description: |
        `v1,<signature>` where the signature is the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`
        with your webhook secret (the base64 part of `whsec_...`). See the Standard Webhooks specification.
      schema: { type: string, examples: ["v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4="] }
  headers:
    ETag:
      description: Validator for this response. Send it back in `If-None-Match`.
      schema: { type: string }
    RetryAfter:
      description: Seconds to wait before trying again.
      schema: { type: integer, minimum: 1 }
  responses:
    NotModified:
      description: The copy you have is current. No body.
      headers:
        ETag: { $ref: "#/components/headers/ETag" }
    BadRequest:
      description: A parameter is missing or invalid.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: invalid_request, message: "limit must be between 1 and 100" }
    Unauthorized:
      description: |
        The access token is missing, expired or revoked. The response carries a `WWW-Authenticate` header (the body is
        empty when no token was sent). Refresh the token, or ask the pilot to connect again.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: invalid_token }
    Forbidden:
      description: |
        Either the pilot has not enabled the scope this endpoint needs (`insufficient_scope`, with the missing
        `scope`), or Gaggle has switched your app off (`provider_disabled`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            insufficientScope:
              summary: The pilot switched the scope off
              value: { error: insufficient_scope, scope: tracks }
            providerDisabled:
              summary: The app is disabled
              value: { error: provider_disabled }
    NotFoundFlight:
      description: No such flight, or it is not visible to you (deleted, at a sharing level you cannot see, or outside the history window).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: not_found }
    NotFoundTrack:
      description: |
        No such flight, or no track you can download: the flight is deleted, at a sharing level you cannot see, outside the
        history window, or has no track (`hasTrack` is `false`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: not_found }
    TooManyRequests:
      description: |
        Rate limited. Your app may make 600 requests a minute in total and 60 requests a minute per pilot, and
        first-time track downloads are throttled separately. Wait for `Retry-After` seconds (60), then retry.
        Using `If-None-Match` and webhooks keeps you well below the limits.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: rate_limited }
  schemas:
    Plan:
      type: string
      enum: [free, frequentFlyer, adventurer]
      description: The pilot's current Gaggle plan.
    Scope:
      type: string
      enum: [profile, stats, flights, tracks]
    SharingLevel:
      type: string
      enum: [everyone, groups, friends, private]
      description: The sharing setting of a recorded or imported flight in Gaggle.
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [invalid_request, invalid_token, insufficient_scope, provider_disabled, not_found, rate_limited, bridge_unavailable]
          description: |
            - `invalid_request` (400): a parameter is missing or invalid.
            - `invalid_token` (401): the access token is missing, expired or revoked.
            - `insufficient_scope` (403): the pilot has not enabled the scope in `scope`.
            - `provider_disabled` (403): Gaggle has switched your app off.
            - `not_found` (404): no such resource, or it is not visible to you.
            - `rate_limited` (429): too many requests; see `Retry-After`.
            - `bridge_unavailable` (502): a track could not be fetched right now; retry shortly.

            Anything other than `GET` or `HEAD` on `/v1` answers `405` with `invalid_request`.
        message:
          type: string
          description: A human-readable hint. Do not parse it.
        scope:
          type: string
          description: With `insufficient_scope`, the scope that is not enabled.
    OAuthError:
      type: object
      required: [error]
      description: Errors from the OAuth endpoints use the standard OAuth shape.
      properties:
        error:
          type: string
          examples: [invalid_grant]
        error_description:
          type: string
    AuthorizationCodeGrant:
      type: object
      required: [grant_type, code, redirect_uri, code_verifier]
      properties:
        grant_type: { type: string, const: authorization_code }
        code: { type: string }
        redirect_uri: { type: string, format: uri }
        code_verifier:
          type: string
          description: The PKCE verifier whose SHA-256 you sent as `code_challenge`.
        client_id:
          type: string
          description: Only when you do not use HTTP Basic.
        client_secret:
          type: string
          description: Only when you do not use HTTP Basic.
    RefreshTokenGrant:
      type: object
      required: [grant_type, refresh_token]
      properties:
        grant_type: { type: string, const: refresh_token }
        refresh_token: { type: string }
        client_id:
          type: string
          description: Only when you do not use HTTP Basic.
        client_secret:
          type: string
          description: Only when you do not use HTTP Basic.
    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token: { type: string }
        token_type:
          type: string
          const: bearer
          description: "Always `bearer`. Send the token as `Authorization: Bearer <token>`."
        expires_in:
          type: integer
          description: Seconds until the access token expires (3600).
        resource:
          type: string
          description: The API the token is for.
        refresh_token:
          type: string
          description: Always present. On a refresh it replaces the refresh token you sent.
        scope:
          type: string
          description: |
            The scopes of the grant, the upper bound of what the pilot can allow. Not what is enabled right
            now: call `/v1/me` for that.
    Me:
      type: object
      required: [id, displayName, scopes, plan, historyFrom, flightSharing]
      properties:
        id:
          type: string
          description: A stable id for this pilot, unique to your app. The same pilot has a different id in every app.
        displayName:
          type: string
          description: The pilot's name in Gaggle as it was when they connected your app.
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
          description: The scopes the pilot currently allows. Always includes `profile`.
        plan: { $ref: "#/components/schemas/Plan" }
        historyFrom:
          type: [string, "null"]
          format: date-time
          description: "Where the history window starts. Frequent Flyer: 30 days back (a UTC day start). Free: the exact moment the pilot connected, and Free only sees flights that first reached Gaggle after connecting. Adventurer: null, the whole logbook has no start limit."
        flightSharing:
          type: array
          items: { $ref: "#/components/schemas/SharingLevel" }
          description: |
            The sharing levels of the pilot's flights that you can see. A subset of `everyone`, `groups`, `friends` and
            `private`; the default is `everyone`, `groups` and `friends`, and it can be empty. Manual log entries have
            no sharing level and are always visible.
    Flight:
      type: object
      required: [id, date, durationMinutes, site, source, hasTrack, updatedAt]
      description: |
        A flight log entry. These fields are on every plan: `id`, `date`, `durationMinutes`, `site`, `source`, `hasTrack`,
        `updatedAt`, and (when recorded) `aircraft`, `takeOffCoordinates` and `flightType`. `distanceKm` comes with
        Frequent Flyer and Adventurer, `weather` with Adventurer, and they are absent otherwise. Tasks, notes, maximum altitude, take-off height, scores,
        achievements and images are never shared.
      properties:
        id: { type: string }
        date:
          type: string
          format: date-time
          description: Take-off time (UTC). For manual log entries it is the time the pilot typed in, read as UTC.
        durationMinutes: { type: integer, minimum: 0 }
        site:
          type: string
          description: Name of the take-off site.
        source:
          type: string
          enum: [recording, import, manual]
          description: Recorded in Gaggle, imported from a file, or typed in by hand.
        hasTrack:
          type: boolean
          description: Whether `/v1/flights/{id}/track.gpx` has a track for this flight.
        updatedAt:
          type: string
          format: date-time
          description: When Gaggle last changed this flight for you (not when the pilot flew it). It only moves forward, so it is safe for `updatedSince` syncing.
        aircraft:
          type: object
          required: [name]
          properties:
            name: { type: string }
            engineName:
              type: string
              description: For powered aircraft.
        takeOffCoordinates:
          type: object
          required: [lat, lng]
          properties:
            lat: { type: number, minimum: -90, maximum: 90 }
            lng: { type: number, minimum: -180, maximum: 180 }
        distanceKm:
          type: number
          description: Frequent Flyer and Adventurer. Distance flown in kilometres.
        flightType:
          type: string
          description: The kind of flying, for example `PG`, `PPG`, `HG` or `Other`. Treat unknown values as valid.
        weather:
          $ref: "#/components/schemas/Weather"
    Weather:
      type: object
      description: |
        Adventurer. Conditions captured with the flight. Every field is optional, and fields the source did not provide
        are left out. Flights entered by hand carry wind only.
      properties:
        temperature:
          type: number
          description: Degrees Celsius, to 0.1.
        pressure:
          type: number
          description: Hectopascals (hPa).
        humidity:
          type: number
          description: Percent.
        visibility:
          type: number
          description: Metres.
        windDirection:
          type: number
          description: Degrees, the direction the wind comes from.
        windSpeed:
          type: number
          description: Metres per second.
        gustSpeed:
          type: number
          description: Metres per second.
    Tombstone:
      type: object
      required: [id, deleted, updatedAt]
      description: A flight that was deleted, or that is no longer shared with you (its sharing level is not one you can see). Listed for 30 days after the change, inside your history window. Nothing else about the flight is kept.
      properties:
        id: { type: string }
        deleted: { type: boolean, const: true }
        updatedAt: { type: string, format: date-time }
    FlightPage:
      type: object
      required: [flights, nextCursor]
      properties:
        flights:
          type: array
          items:
            oneOf:
              - $ref: "#/components/schemas/Flight"
              - $ref: "#/components/schemas/Tombstone"
        nextCursor:
          type: [string, "null"]
          description: Pass as `cursor` for the next page. `null` on the last page.
    Stats:
      type: object
      required: [lifetime]
      description: Optional fields depend on the pilot's plan; see the endpoint description.
      properties:
        lifetime:
          type: object
          required: [flights, airtimeMinutes]
          properties:
            flights: { type: integer }
            airtimeMinutes: { type: integer }
            distanceKm:
              type: number
              description: Frequent Flyer and Adventurer.
        years:
          type: array
          description: Frequent Flyer and Adventurer. Newest year first.
          items:
            type: object
            required: [year, flights, airtimeMinutes]
            properties:
              year: { type: integer }
              flights: { type: integer }
              airtimeMinutes: { type: integer }
              distanceKm:
                type: number
                description: Frequent Flyer and Adventurer.
              months:
                type: array
                description: Adventurer.
                items:
                  type: object
                  required: [month, flights, airtimeMinutes]
                  properties:
                    month:
                      type: integer
                      minimum: 1
                      maximum: 12
                    flights: { type: integer }
                    airtimeMinutes: { type: integer }
                    distanceKm: { type: number }
        aircraft:
          type: array
          description: Adventurer.
          items:
            type: object
            required: [name, flights, airtimeMinutes]
            properties:
              name: { type: string }
              flights: { type: integer }
              airtimeMinutes: { type: integer }
    WebhookEvent:
      type: object
      required: [id, type, createdAt, data]
      properties:
        id:
          type: string
          description: Unique id of the event.
        type:
          type: string
          enum: [flight.created, flight.updated, flight.deleted, connection.updated, connection.revoked]
        createdAt: { type: string, format: date-time }
        data: { type: object }
    FlightEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEvent"
        - type: object
          properties:
            type:
              enum: [flight.created, flight.updated, flight.deleted]
            data:
              type: object
              required: [userId, flightId]
              properties:
                userId:
                  type: string
                  description: The pilot's id, the same as `id` in `/v1/me`.
                flightId: { type: string }
    ConnectionUpdatedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEvent"
        - type: object
          properties:
            type:
              const: connection.updated
            data:
              type: object
              required: [userId, scopes, plan, flightSharing]
              properties:
                userId: { type: string }
                scopes:
                  type: array
                  items: { $ref: "#/components/schemas/Scope" }
                plan: { $ref: "#/components/schemas/Plan" }
                flightSharing:
                  type: array
                  items: { $ref: "#/components/schemas/SharingLevel" }
    ConnectionRevokedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEvent"
        - type: object
          properties:
            type:
              const: connection.revoked
            data:
              type: object
              required: [userId]
              properties:
                userId: { type: string }
