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.
GET /api/v1/flights/{id}/igc
Download the original IGC file of a single flight, byte for byte as it was uploaded.
Scope: flights:read
Path: id is the flight's _id hex (24 chars), as returned by the list.
Response: 200 with the raw file.
Content-Type: application/igcContent-Disposition: attachment; filename="<original file name>"ETag: the flight'shash. An IGC file never changes once stored, so a client that already holds a file with this hash does not need to fetch it again.
Returns 404 not_found if the flight doesn't exist or belongs to another
user. Available to pending applications (it is a read-only scope), which is
enough to test a sync against the owner's own logbook before approval.
Two-way sync: combine this endpoint with ?since on GET /api/v1/flights
to pull what changed, and with POST /api/v1/flights/upload to push. An
upload whose GPS fixes match an existing flight returns isNew: false, so
sending back a flight you downloaded from we-fly never creates a duplicate.
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.
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, code, wpt, cup or gpx. 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. ?format=wpt, cup or gpx for a file |
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/routes | tasks:read | Delta with ?since; page-based listing without it |
POST | /api/v1/routes | tasks:write | { name, waypoints[], league? } or { name, content, filename } |
GET | /api/v1/routes/{id} | tasks:read | Owner-or-public. ?format=wpt, cup or gpx for a file |
PUT | /api/v1/routes/{id} | tasks:write | Last-writer-wins over the whole route |
DELETE | /api/v1/routes/{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 |
GET | /api/v1/airspace/files | airspace:read | OpenAir files the pilot saved in the XC planner, newest first. ?version=1|2.1, paged. Kept 30 days |
GET | /api/v1/airspace/files/{id} | airspace:read | One saved file's metadata. 404 once expired |
GET | /api/v1/airspace/files/{id}/openair | airspace:read | The OpenAir text (text/plain), ETag = id. See saved airspace files |
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.