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 wordsincefor something else and is flagged loudly below.
Tasks and waypoints
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/tasks | tasks:read |
POST | /api/v1/tasks | tasks:write |
GET | /api/v1/tasks/{id} | tasks:read |
GET | /api/v1/waypoints | tasks:read |
POST | /api/v1/waypoints | tasks: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
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/sites | community: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.
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/screens | screens: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}/publish | screens:write |
GET | /api/v1/screens/gallery | community: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
| Method | Path | Scope |
|---|---|---|
POST | /api/v1/live/positions | live:write |
GET | /api/v1/live/buddies | community:read |
POST | /api/v1/live/share | live:write |
GET | /api/v1/live/share | live: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
skippedand 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.
Share links
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
noindexon both the page and the API it polls; - can be revoked early with
DELETE /api/v1/live/share/{token}.
Safety notifications
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/contacts | profile:read |
POST | /api/v1/contacts | profile:write |
DELETE | /api/v1/contacts/{id} | profile:write |
POST | /api/v1/notify/takeoff | live:write |
POST | /api/v1/notify/landing | live:write |
POST | /api/v1/notify/emergency | live: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
| Method | Path | Scope |
|---|---|---|
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.
?sincehere is NOT a sync cursorThis resource is filtered by
bbox, and the delta contract takes no filters for the reason given at the top of this page. Sosinceon this endpoint is a plain ISO 8601 timestamp — send back themeta.builtAtfrom 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 a400rather 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
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/airspace/regions | airspace: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/uploadstill caps at 3 MB. Worth discussing for long XC tracks; nothing above is blocked on it.