All paths are relative to https://we-fly.cloud.
Authentication: every /api/v1/* endpoint requires
Authorization: Bearer <token> where the token is an OAuth access
token (wf_at_…) or an API key (wf_key_…).
OAuth endpoints
POST /api/oauth/token
Mint or refresh tokens. Grant types: client_credentials,
authorization_code, refresh_token. See
Server-to-server and
User authorization.
Content-Type: application/x-www-form-urlencoded (recommended) or
application/json.
Client auth: HTTP Basic (Authorization: Basic …) or body params
(client_id, client_secret). For public clients on
authorization_code, client_id alone is acceptable.
POST /api/oauth/revoke
Revoke an access or refresh token. RFC 7009.
Body:
token(required) — the access or refresh tokentoken_type_hint(optional) —access_tokenorrefresh_token
Returns 200 OK regardless of whether the token was valid (per RFC).
POST /api/oauth/introspect
Check whether a token is active. RFC 7662. Only available to confidential clients introspecting tokens they themselves issued.
Body:
token(required)token_type_hint(optional)
Returns either { "active": false } or:
{
"active": true,
"scope": "flights:read profile:read",
"client_id": "wf_app_…",
"token_type": "access_token",
"sub": "65a1…",
"exp": 1748426400,
"iat": 1748422800
}
POST /api/oauth/device_authorization
Start an RFC 8628 device flow. Returns device_code, user_code,
verification_uri, and polling instructions. See
Device authorization grant.
Body:
client_id(required, or via HTTP Basic)client_secret(required for confidential, or via HTTP Basic)scope(optional)
GET /.well-known/oauth-authorization-server
RFC 8414 metadata document. Use it to discover endpoint paths instead of hardcoding.
GET /oauth/authorize
Browser-only. The OAuth consent screen. Parameters per User authorization.
API v1 endpoints
GET /api/v1
Public discovery shim. Lists endpoint paths.
GET /api/v1/flights
List the authenticated user's flights.
Scope: flights:read
Two read modes. ?since is the only switch between them; without it the
behaviour and response are exactly as they have always been.
Page-based — ?page (default 1, 1-indexed), ?pageSize (default 25,
max 100). Sorted flight_date descending.
Delta — ?since=<cursor|0|now>, ?limit (default 100, max 500). Returns
only what changed since a cursor, carries deletions, and answers 304 with an
empty body when nothing has. Adds ETag / If-None-Match, and the statuses
400 invalid_cursor and 410 cursor_expired. Full contract:
Incremental sync.
Page-based response:
{
"data": [
{
"id": "65a4f1bcdeadbeef12345678",
"hash": "f4b9a8c…",
"flight_date": "2026-05-15",
"date_created": "2026-05-15T18:42:11.000Z",
"original_file_name": "2026-05-15-XCT-001.igc",
"is_tandem": false,
"launch_type": "Self",
"season": 2026,
"scoring": {
"distance": 87.3,
"score": 130.95,
"type": "FAITriangle"
},
"flightInfo": {
"takeoff": {
"id": "65aaf0…",
"lat": 46.123,
"lng": 6.456,
"name": "Plaine Joux",
"country": "FR"
},
"metadata": {
"glider-type": "Ozone Zeolite GT"
},
"track": {
"duration": 14820,
"distance": 105230
}
}
}
],
"pagination": {
"page": 1,
"pageSize": 25,
"totalCount": 142,
"totalPages": 6
}
}
Field reference:
id— flight's MongoDB_idhex. Use with the detail endpoint.scoring.distance— XContest scored distance, kmscoring.score— XContest score (points)scoring.type— one ofFAITriangle,FlatTriangle,FreeFlight,OutAndReturnflightInfo.track.duration— flight duration, secondsflightInfo.track.distance— track length (sum of GPS-fix distances), metreslaunch_type—Self,Towed,Powered, orDriving
GET /api/v1/flights/{id}
Detail view of a single flight.
Scope: flights:read
Path: id is the flight's _id hex (24 chars).
Response: same shape as the list rows but with additional fields under
flightInfo.track.analysis:
thermals.summary— count, mean climb rate, ceiling, etc.phases— thermal/glide/cruise breakdownwind— estimated wind direction and speedceiling— top-of-climb statistics
Returns 404 not_found if the flight doesn't exist or belongs to
another user.
POST /api/v1/flights/upload
Upload an IGC file to the authenticated user's logbook.
Scope: flights:write
Accepts either:
multipart/form-datawith afilefield, ORapplication/octet-streamwith the raw IGC bytes; filename via theX-Filenamerequest header.
Max body: 3 MB (returns 413 payload_too_large otherwise).
The IGC must contain at least 10 B-records (400 invalid_request
otherwise).
Response:
{
"data": {
"hash": "f4b9a8c…",
"fileName": "2026-05-15-XCT-001.igc",
"isNew": true
}
}
isNew: true→201 Created. Flight was parsed and added.isNew: false→200 OK. The GPS-fix hash matches an existing flight; nothing changed.
DELETE /api/v1/flights/{id}
Permanently delete a flight (DB row + IGC file + cached gzipped analysis).
Scope: flights:delete
Response: 204 No Content on success, 404 not_found otherwise.
Pending applications cannot use this endpoint regardless of scope — destructive endpoints require an approved app.
GET /api/v1/profile
The authenticated user's profile.
Scope: profile:read. The email field additionally requires the
profile:email scope — without it, email is returned as null.
Response:
{
"data": {
"id": "65a1…",
"name": "Alice",
"email": "alice@example.com",
"image": "/api/profile-picture/…",
"publicProfile": true,
"emailVerified": true,
"earlyAdopter": false,
"club": "Parapente Chamonix",
"civlId": 12345
}
}
Instrument endpoints
Full guide with rationale, request bodies and the conflict rules: Instrument integration. Reference only here.
| Method | Path | Scope | Notes |
|---|---|---|---|
GET | /api/v1/tasks | tasks:read | Delta with ?since; shared-library search without it |
POST | /api/v1/tasks | tasks:write | { content, filename?, name?, official? }; deduped on geometry, data is always an array |
GET | /api/v1/tasks/{id} | tasks:read | One-shot; ?format=xctsk or ?format=code. Refuses ?since |
GET | /api/v1/waypoints | tasks:read | Delta with ?since (default limit 10, max 50); summary listing without it |
POST | /api/v1/waypoints | tasks:write | { name, waypoints[] } or { name, content, filename } |
GET | /api/v1/waypoints/{id} | tasks:read | Points decompressed; owner-or-public |
PUT | /api/v1/waypoints/{id} | tasks:write | Last-writer-wins over the whole point list |
DELETE | /api/v1/waypoints/{id} | tasks:write | 204 |
GET | /api/v1/sites | community:read | Delta with ?since (global stream); ?bbox / ?near / ?country / ?q without it |
GET | /api/v1/screens | screens:read | Delta with ?since |
GET | /api/v1/screens/{id} | screens:read | ETag, honours If-None-Match |
PUT | /api/v1/screens/{id} | screens:write | Upsert with a client-chosen id; If-Match drives the conflict fork |
DELETE | /api/v1/screens/{id} | screens:write | 204 |
POST | /api/v1/screens/{id}/publish | screens:write | { published }; does not bump revision |
GET | /api/v1/screens/gallery | community:read | Published layouts, ?q, paged |
POST | /api/v1/live/positions | live:write | 202; ≤500 fixes; partly-bad batches accepted |
GET | /api/v1/live/buddies | community:read | Newest fix per followed public pilot, last 30 min |
POST | /api/v1/live/share | live:write | { label?, ttlSeconds? }; always expires |
GET | /api/v1/live/share | live:write | The pilot's active links |
DELETE | /api/v1/live/share/{token} | live:write | Revoke early |
GET | /live/{token} | public | The page family opens. No auth, noindex |
GET | /api/v1/contacts | profile:read | Addresses masked |
POST | /api/v1/contacts | profile:write | Max 10; sends a confirmation email |
DELETE | /api/v1/contacts/{id} | profile:write | 204 |
POST | /api/v1/notify/takeoff | live:write | 202; confirmed contacts only |
POST | /api/v1/notify/landing | live:write | 202; confirmed contacts only |
POST | /api/v1/notify/emergency | live:write | 202; retry hard, idempotent on eventId; reaches unconfirmed contacts too |
GET | /api/v1/thermals | community:read | ?bbox required. ?since is an ISO timestamp, not a cursor |
GET | /api/v1/airspace/regions | airspace:read | Tile source descriptors + snapshot |
GET | /api/v1/airspace/activations | airspace:read | ?bbox required. Per-pilot autorouter credentials; source.available says whether we could look |
Three of these behave differently enough to be worth restating:
/api/v1/thermals?since=is a build-time freshness filter (an ISO 8601 timestamp — themeta.builtAtfrom your last response), not a sync cursor. The resource is filtered bybbox, and the delta contract takes no filters.PUT /api/v1/screens/{id}never returns412. A staleIf-Matchstill writes, and the displaced revision is preserved as a conflict copy named inconflictCopyId./api/v1/airspace/activationsreturns200withsource.available: falsewhen the pilot has not connected autorouter — an emptydataarray on its own would read as "no active airspace here".
Developer-portal endpoints
The /developer dashboard is backed by internal, cookie-authenticated
endpoints (and React Server Actions for mutations). These are not
part of the partner contract, are undocumented by design, and may
change without notice. Drive the dashboard through its UI; use the
OAuth and /api/v1/* endpoints above for everything programmatic.