Skip to main content
reference 14 min read

Instrument integration

Tasks, waypoints, sites, screen layouts, live tracking, safety notifications, thermal hotspots and airspace — everything a flight instrument needs beyond the logbook.

Everything a flight instrument needs from we-fly beyond the logbook: tasks and waypoints, the takeoff database, screen layouts, live tracking, safety notifications, thermal hotspots and airspace.

The governing assumption throughout: your client is frequently offline and its access token is frequently expired. Every read below is pulled on the ground, cached, and used in the air with nothing overhead. Nothing here is designed as a live call, and the two endpoints that genuinely are live — /api/v1/live/buddies and /api/v1/airspace/activations — say so and degrade into something a client can reason about rather than into an empty answer.

  • Base URL: https://we-fly.cloud
  • Every endpoint is Bearer-authenticated and scope-gated, like the rest of /api/v1. See scopes.
  • Where an endpoint supports ?since=, it is the delta contract in incremental sync — opaque cursor, ETag, data / deleted / sync. One endpoint uses the word since for something else and is flagged loudly below.

Tasks and waypoints

MethodPathScope
GET/api/v1/taskstasks:read
POST/api/v1/taskstasks:write
GET/api/v1/tasks/{id}tasks:read
GET/api/v1/waypointstasks:read
POST/api/v1/waypointstasks:write
GET/api/v1/waypoints/{id}tasks:read
PUT/api/v1/waypoints/{id}tasks:write
DELETE/api/v1/waypoints/{id}tasks:write

Two read modes, and ?since is the switch

GET /api/v1/tasks behaves exactly like GET /api/v1/flights:

  • ?since=<cursor|0|now> — a delta over the tasks this pilot put in the library. This is what you mirror onto a device.
  • no ?since — a search of the shared library, with ?q, ?official, ?date, ?lat/?lon/?flightId, ?page, ?pageSize.

The split is not an accident. The tasks collection is global and deduplicated across every pilot, so a delta over the whole thing would push every competition task in the world at every device. A filtered delta is impossible under the sync contract — a filtered change feed has to express "this row left your filter" as a deletion, which means the server remembering every client's filter forever. So the mirror is per-pilot and the search is not a mirror.

Fetching one task is a one-shot

GET /api/v1/tasks/{id} has no change feed and refuses ?since with a 400 rather than ignoring it. Add ?format=xctsk for the real .xctsk file, or ?format=code for the XCTSK: string a QR encodes.

Writes are an OR-Set

POST /api/v1/tasks takes the file text and deduplicates on task geometry:

{ "content": "XCTSK:...", "filename": "task1.xctsk", "official": false }

Posting the same task twice converges on one row, and the response says reused: true. That is what makes tasks an OR-Set across a pilot's devices — element identity is the geometry, so two devices adding different tasks both land and neither replaces the other, and re-adding one is idempotent. A .fsdb competition database yields many tasks from one call, so data is always an array.

For waypoints the element identity is the collection id. Two devices adding different collections both land. Merging inside one collection is yours to do: PUT /api/v1/waypoints/{id} is last-writer-wins over the whole point list, and says so rather than pretending otherwise — the points live in a compressed blob with no per-point identity, so there is nothing for us to merge on.

POST/PUT accept either a literal list or a file:

{ "name": "Alps 2026", "waypoints": [{ "name": "Forclaz", "lat": 45.83, "lng": 6.44, "elev": 1240 }] }
{ "name": "Alps 2026", "content": "<.cup / .gpx / .kml / .wpt text>", "filename": "alps.cup" }

The waypoints delta caps ?limit far lower than other streams (default 10, max 50): a collection can carry ten thousand points, and 500 of those is a response no instrument on a mountain finishes downloading.


Sites

MethodPathScope
GET/api/v1/sitescommunity:read

The shared takeoff database. ?since= mirrors the whole thing for offline use; without it, ?bbox=west,south,east,north (≤30° span), ?near=lat,lng,radiusM, ?country=FR, ?q=, paged.

This is the only global delta stream: one change sequence shared by every pilot, because the data is the same for everybody and is already served to anonymous visitors at /takeoffs. Cursors on this stream are therefore interchangeable between accounts, which leaks nothing.

Landings. we-fly has no landing-site collection. A landing is a coordinate on a flight record, not a named community-maintained place the way a takeoff is. Every row here is a takeoff and says so in type, rather than the field being a lie we would have to keep telling. If you need landing points, they are on the flight records you already sync.


Screens (instrument layouts)

Naming. we-fly's /cockpit/* pages are its statistics dashboard. A screen layout is an unrelated thing, so on the wire it is called a screen. If your codebase calls it a cockpit internally, that is fine — just know the two words mean different things across this boundary.

MethodPathScope
GET/api/v1/screensscreens:read
GET/api/v1/screens/{id}screens:read
PUT/api/v1/screens/{id}screens:write
DELETE/api/v1/screens/{id}screens:write
POST/api/v1/screens/{id}/publishscreens:write
GET/api/v1/screens/gallerycommunity:read

The id is yours

{id} is client-chosen — 8–64 characters of [A-Za-z0-9._-] starting alphanumeric, so a UUID or a ULID both work. A pilot who builds a screen on a mountain with no signal must have a stable identity for it hours before the first PUT reaches us; otherwise two devices that both created "Racing" offline would arrive as two unrelated screens with nothing to merge on. PUT is therefore an upsert.

Widget pins are exact

{
  "name": "Racing",
  "device": "XCTrack on Pixel 8",
  "layout": {
    "grid": { "cols": 8, "rows": 12 },
    "widgets": [
      { "id": "w1", "pin": "vario@2.1.0+<64 hex>", "x": 0, "y": 0, "w": 4, "h": 3,
        "settings": { "units": "m/s" } }
    ]
  }
}

pin is <widgetId>@<semver>+<sha256> and the digest half is mandatory. Version ranges (^, ~, *, x, >=) are refused outright, not resolved: a range means the layout a pilot tested on the ground is not necessarily the layout that draws in the air, and for an instrument that is not a convenience trade worth making.

settings is stored and never interpreted, capped at 4 KB serialised per widget.

Conflicts: last writer wins, and the loser is kept

Send If-Match: W/"<id>.<revision>" with the revision you are editing.

If it does not match what we hold, the write still lands — an instrument that has been offline for a week must not be locked out of its own screen by a 412 it cannot resolve in the air. What we refuse to do is destroy the displaced revision: it is copied to a new screen first, and that copy syncs back to every one of the pilot's devices like any other screen. The response says:

{ "data": { … }, "conflict": true, "conflictCopyId": "racing-conflict-4" }

The pilot decides which one they want. We never decide for them, and nothing is silently lost. Omitting If-Match means "I know I might be overwriting something", and produces a conflict copy whenever the screen already existed.

The conflict-copy id is derived from the displaced revision, so replaying the same losing write does not pile up copies.

Publishing does not bump revision — it changes who can see the screen, not what it draws, and moving the revision would make every device think its copy had been overwritten. The delta stream's fingerprint covers published separately, so devices still learn about it. A conflict copy cannot be published.


Live tracking

MethodPathScope
POST/api/v1/live/positionslive:write
GET/api/v1/live/buddiescommunity:read
POST/api/v1/live/sharelive:write
GET/api/v1/live/sharelive:write
DELETE/api/v1/live/share/{token}live:write
GET/live/{token}public, no token

Posting positions is abandonable

{ "sessionId": "flight-2026-08-25-1",
  "positions": [
    { "t": "2026-08-25T11:02:03Z", "lat": 45.83, "lng": 6.44,
      "alt": 2140, "speed": 38, "heading": 210, "vario": 1.8 }
  ] }

Drop batches rather than retrying them. A failed live post must never delay or corrupt the IGC recording, which is the real product of a flight. We hold up our end:

  • Gaps are normal. Nothing downstream treats a missing stretch as an error.
  • A partly-bad batch is accepted, not rejected. Unusable fixes are counted in skipped and the good ones stored. Refusing the batch would discard good data nobody is going to send again.
  • Responses are 202. Up to 500 fixes per call.

Positions expire after 48 hours. This is a window, not an archive — the durable record of a flight is the IGC file the pilot uploads.

Buddies

Returns the most recent fix only for each pilot the caller follows whose profile is public, from the last 30 minutes. That is the same privacy gate the follow graph already uses, applied to more sensitive data rather than a second, weaker rule invented for it. A pilot has three existing ways to disappear from it: stop posting, revoke your token, or make their profile private.

A buddy list is "where is everyone", not "replay their morning" — the whole track is deliberately not available here.

POST /api/v1/live/share with an optional { "label": "retrieve", "ttlSeconds": 43200 } returns a URL for /live/{token}. It:

  • always expires — 24 h by default, 7 days maximum, no "forever" option. A link that outlives the flight it was made for is a tracking beacon the pilot has forgotten about;
  • carries 128 bits of entropy and is a bearer credential in a URL;
  • renders without authentication and exposes the track and nothing else — no email, no logbook, no other flights;
  • is noindex on both the page and the API it polls;
  • can be revoked early with DELETE /api/v1/live/share/{token}.

Safety notifications

MethodPathScope
GET/api/v1/contactsprofile:read
POST/api/v1/contactsprofile:write
DELETE/api/v1/contacts/{id}profile:write
POST/api/v1/notify/takeofflive:write
POST/api/v1/notify/landinglive:write
POST/api/v1/notify/emergencylive:write

Contacts live server-side deliberately: the phone that would send the message is the phone that is in the tree. You post the event; we-fly decides who gets told and sends it. The delivery channel is entirely our concern.

Today that channel is email. An emergency email to someone driving a retrieve is admittedly weak; SMS is a known gap, recorded rather than pretended away. The contact record carries a channel field so a second channel can be added without a schema migration or any change to this contract.

Contacts

{ "name": "Sam", "channel": "email", "address": "sam@example.com",
  "notify": { "takeoff": false, "landing": true, "emergency": true } }

landing and emergency default on; takeoff defaults off, because on would mail the contact every single flight. Maximum 10 contacts per pilot. Listings mask the address — a partner showing the pilot their own list does not need a harvestable one.

Adding a contact sends that person a one-time confirmation email. Until they confirm, routine take-off and landing notifications do not reach them; emergencies do. That asymmetry is deliberate: the pilot typed in somebody else's address, so ordinary mail needs their agreement, but refusing to pass on an emergency over a bookkeeping state would be the wrong way to fail. Every message we send carries a remove-me link.

Raising an event

{ "eventId": "flight-2026-08-25-1:landing",
  "at": "2026-08-25T14:02:00Z",
  "lat": 45.79, "lng": 6.29,
  "timezone": "Europe/Paris",
  "message": "landed in a field below the LZ" }

eventId is required and has no server-side default — it is your idempotency key, stable across retries of the same occurrence.

Retry the emergency call hard. It is the one endpoint in this API that should be, and it is idempotent by construction: the ledger row is written before anything is sent, so the second and later arrivals of the same eventId are no-ops. Retry until it succeeds; it will only ever send once.

The notification endpoints carry their own rate limit — 12 an hour, per pilot, shared across all three — because what needs guarding here is somebody else's inbox, not our capacity. It counts distinct events, not requests, so retrying an eventId we already hold costs nothing. That is deliberate: a limiter that fought your retry advice would win exactly when it matters most.

Every outcome is a 202, including "already handled" and "this pilot has no contacts" — you can only decide to retry from a transport failure, so neither of those may look like one. The body carries status, delivered and recipients.


Thermal hotspots

MethodPathScope
GET/api/v1/thermals?bbox=&months=&since=&limit=community:read

Aggregated climb locations derived from the archive, as H3 resolution-7 cells (~5 km²). You draw them; we aggregate them — that division is an architectural invariant on your side and we hold up our half of it.

bbox is required and capped at 10°: this layer is downloaded for a region on the ground, not queried per flight in the air.

?since here is NOT a sync cursor

This resource is filtered by bbox, and the delta contract takes no filters for the reason given at the top of this page. So since on this endpoint is a plain ISO 8601 timestamp — send back the meta.builtAt from your previous response. Hotspots are only ever added or refined, so there are no tombstones to miss. Passing an opaque cursor here is refused with a 400 rather than silently returning everything.

Cells are published only once they clear a floor — at least 3 climbs from at least 2 different pilots — and only flights from pilots with public profiles are aggregated at all. A cell built from one pilot's single climb is not an aggregate; it is that pilot's afternoon, published at a fine resolution. The floor is reported in meta so you can explain the gaps, and the published coordinate is the cell centre, never a raw fix.


Airspace

MethodPathScope
GET/api/v1/airspace/regionsairspace:read
GET/api/v1/airspace/activations?bbox=&from=&to=airspace:read

Server-authoritative and pull-only. There is no write side.

The snapshot block

Every response from both endpoints carries snapshot, so "your airspace data is 40 days old" is always something you can say:

{ "snapshot": { "version": "…", "lastModified": "2026-07-16T09:12:00.000Z",
                "checkedAt": "2026-08-25T10:00:00.000Z",
                "source": "OpenAIP (…)", "attribution": "© OpenAIP contributors" } }

version can be null, and that is an answer. we-fly does not host the airspace dataset — the geometry is OpenAIP vector tiles, proxied same-origin — so the only freshness signal that exists is the one the upstream publishes. When it publishes nothing, we report null rather than inventing a version from the deploy date. Show "unknown age": a fabricated version would let you display a confident, wrong number.

Regions

A "region" is a tile source descriptor — a tileUrl template, the source layer, zoom bounds, attribution — not a downloadable blob. Today there is one: worldwide OpenAIP, maxZoom 10 (the bucket's native detail level).

Activations

Temporary activations (NOTAMs) over an area and a window. bbox required, ≤12°; from/to default to now → +24 h, to at most 7 days ahead.

These are delivered per pilot, not from a central store. The underlying data is Eurocontrol EAD, which may not be redistributed under a shared account, so we fetch with the pilot's own connected autorouter credentials. A pilot who has not connected one gets:

{ "data": [], "snapshot": { … },
  "source": { "available": false, "reason": "autorouter_not_connected",
              "description": "…" } }

200, not 403 — and never a bare empty array. "There is no active airspace here" and "I could not find out" must never look the same to your client, and in an empty array they do. Confusing them in the air is how a pilot flies into a live danger area believing the map. An upstream failure returns the same shape with reason: "upstream_unavailable" and a 503.


What is not here

  • Widget registry hosting. Not requested; raise it separately if you need it.
  • A push channel. Every endpoint above is a pull you make when you have network, which is what an instrument wants.
  • A raised upload limit. /api/v1/flights/upload still caps at 3 MB. Worth discussing for long XC tracks; nothing above is blocked on it.