This document describes the HTTP API surface, the OpenAPI-first workflow that defines it, and how routes are registered, generated, and secured. For authentication mechanics (tokens, 2FA, WebAuthn, lockout), see AUTHENTICATION.md.
api-spec/openapi.yaml is the single source of truth for the HTTP contract
(OpenAPI 3.1). Server-side Go types and route registration are generated from it; you do
not hand-write request/response structs or routing tables.
flowchart TD
SPEC["api-spec/openapi.yaml<br/><i>edit this first</i>"]
SPEC -->|"scripts/generate-server-types.sh (oapi-codegen)"| GEN["internal/api/generated/"]
GEN --> T["types.go — request/response models"]
GEN --> S["server.go — ServerInterface + RegisterHandlersWithOptions"]
GEN --> SP["spec.go — embedded spec"]
GEN --> G["generate.go — //go:generate directive"]
internal/api/generated/generate.gocarries//go:generate bash ../../scripts/generate-server-types.sh, sogo generate ./...(ormake generate) regenerates the package.- The generator first transpiles the 3.1 spec to a 3.0-compatible temporary file
(because
oapi-codegendoes not support some 3.1 constructs such astype: [string, 'null']), then runsoapi-codegen. - Never hand-edit files in
internal/api/generated/. Change the spec and regenerate.
API changes affect the separate ninerlog-frontend repository, which generates its own
client from the same spec. After changing api-spec/openapi.yaml, regenerate both:
# in ninerlog-api
make generate # or: bash scripts/generate-server-types.sh
# in ninerlog-frontend
bash scripts/generate-api-client.shinternal/api/generated/server.go declares a ServerInterface with one method per
OpenAPI operation. internal/api/handlers.APIHandler implements that interface — each
operation is a method on APIHandler (organised across files like flight.go,
auth.go, license.go, …). APIHandler aggregates every service, so handlers stay thin:
extract userID from context, bind/validate the request, call a service, map the result
or sentinel error to a status code.
Routes are wired in cmd/api/main.go:
api := router.Group("/api/v1")
api.Use(middleware.AuthMiddlewareWithState(jwtManager, /* public path allow-list */, authService.AccessTokenState))
api.Use(generalRateLimit) // every route
api.Use(middleware.RateLimitByPath(expensiveRateLimit, /* exports, previews */))
api.Use(middleware.RateLimitByPathWithQueryParam(searchRateLimit, "/flights", "q"))
api.Use(middleware.RateLimitByPath(authRateLimit, /* /auth paths */))
api.Use(middleware.RateLimitByPath(adminRateLimit, /* /admin paths */))
generated.RegisterHandlersWithOptions(api, apiHandler, generated.GinServerOptions{...})That generated call is the entire route table. Every route is declared in
api-spec/openapi.yaml and served under /api/v1 — including the ones that are not JSON
APIs, such as the browser-facing OIDC redirects (GET /auth/oidc/authorize,
GET /auth/oidc/callback), which are declared with their 302 and Location header. The
frontend and the iOS app both generate their client from the spec, so a route missing from it
is a feature no app can reach.
make route-check (scripts/check-routes.py, also a CI job) enforces this: it resolves every
route registered outside internal/api/generated/ and fails if one is absent from the spec or
mounted outside /api/v1. Only GET /health and GET /metrics sit outside, and they are
listed there with the reason.
- All business endpoints are under
/api/v1. - Non-versioned operational routes registered directly on the router:
GET /health— liveness/readiness check (used by the Docker healthcheck).GET /metrics— Prometheus metrics (when metrics are enabled). See METRICS.md.
-
JWT bearer authentication (
bearerAuthin the spec). Clients send the access token in the HTTPAuthorizationrequest header using theBearerscheme;middleware.AuthMiddlewarevalidates the token and stores theuserIDon the Gin context. It also checks the token's session against the database on every request: a revoked session, a disabled account and a deleted account each answer401, and a state that cannot be read answers503rather than being mistaken for a revocation. See SESSION_CONTRACT.md. -
Public allow-list — auth endpoints (register, login, refresh, password reset, email verification) and a few read-only lookups (airport search/lookup, public announcements) are exempt from auth via the allow-list passed to the middleware.
-
Rate limiting — layered, and all of it skipped when
DISABLE_RATE_LIMIT=true:Limiter Budget Applies to Keyed by general120/min every /api/v1routeuser ID (IP when unauthenticated) searchSEARCH_RATE_LIMIT_PER_MINUTE, default 60/minGET /flightswith aqparameteruser ID expensive15/min /exports/pdf,/custom-currency/preview,/imports/*, and writes to/…/files*user ID file_readFILE_READ_RATE_LIMIT_PER_MINUTE, default 90/minreads of /…/files*user ID auth10/min login, register, refresh, password reset, 2FA, WebAuthn client IP admin30/min /admin/*and user state changesclient IP sign20/min /sign/*public signing linksclient IP signature_email10/min /signatures,/resendclient IP Limiters stack: a request can be rejected by any that covers it, so plain
GET /flightscarries onlygeneralwhileGET /flights?q=…carries bothgeneralandsearch. Rejections are reported per limiter — see METRICS.md. -
Admin authorization — admin endpoints additionally require the caller to be an admin (configured via
ADMIN_EMAIL). -
Trusted proxies & forwarded IPs are configured so client IPs are read correctly behind a reverse proxy.
-
Security headers are added to every response.
Errors are returned as JSON with an appropriate status code; internal error details are never leaked to clients.
Every authenticated POST, PUT, PATCH and DELETE accepts an optional
Idempotency-Key request header. It exists for clients that queue writes while offline:
when a POST /flights commits and the response is lost, the client cannot distinguish
"not applied" from "applied but unacknowledged", and retrying blindly produces a
duplicate logbook entry.
It is opt-in per request. Without the header a request takes exactly the path it took before the feature existed, which is why the current frontend is unaffected.
middleware.IdempotencyMiddleware implements it, backed by service.IdempotencyService
and the idempotency_keys table (see DATA_MODEL.md). It is registered
after AuthMiddleware (records are keyed per user) and after the rate limiters (a
throttled request must not consume a key). Like auth and rate limiting, it is a
cross-cutting transport concern, so it is described in the spec's info.description
rather than repeated as a parameter on all ~60 mutating operations — which would change
every generated handler signature, and every generated client method, for a header the
handlers never read.
| Situation | Response |
|---|---|
| No header | Unchanged behaviour; nothing is stored |
| First request with a key | Executes normally; status + body are stored for 24 h |
| Retry, same key and payload | Original status and body verbatim, plus Idempotency-Replayed: true |
| Retry while the first is still running | 409 with Retry-After: 1 |
| Same key, different payload | 422 |
| Retry of a request whose response was too large to store | 409 |
| Malformed key (empty, >255 chars, non-printable-ASCII) | 400 |
| Replay store unreachable | 503, request not executed |
Details worth knowing:
- Scope. Keys are per user, so two pilots can pick the same client-side key. Unauthenticated endpoints (login, registration, password reset) ignore the header: there is no user to key a record by, and repeating them is not a logbook-integrity problem. A client may therefore set the header unconditionally.
- Fingerprinting. The stored record carries a SHA-256 of the method, path+query and
request body, which is what makes the
422above possible. Bulk payloads — multipart uploads,POST /imports/json, anything sent chunked — are fingerprinted by method and path only; the replay guarantee is unaffected, only the mismatch diagnostic weakens. - 4xx responses are stored and replayed: a validation failure is a deterministic verdict on the request. 5xx responses and panics release the key, because a server error says nothing about whether the write landed and the client must stay free to retry.
- Failure mode is closed. If the store is unreachable the request is refused rather than executed without the guarantee the client asked for.
- Retention and recovery. Records expire after
IDEMPOTENCY_TTL(24 h) and are swept hourly. A claim whose request died without finalizing becomes claimable again afterIDEMPOTENCY_LEASE(60 s), so a crashed process cannot wedge a key for a whole day.
| Variable | Default | Meaning |
|---|---|---|
IDEMPOTENCY_TTL |
24h |
How long a key stays replayable |
IDEMPOTENCY_LEASE |
60s |
How long an in-progress claim is honoured before takeover (must exceed the 15 s request timeout) |
IDEMPOTENCY_MAX_RESPONSE_BYTES |
262144 |
Largest response body stored for replay |
Outcomes are counted in idempotency_requests_total — see
METRICS.md.
PUT/PATCH request bodies whose schema has nullable properties follow
RFC 7386 semantics field-by-field:
| Field in the request body | Effect |
|---|---|
| Omitted | Left unchanged |
null |
Cleared (set to database NULL) |
| A value | Set to that value |
This applies to PUT /flights/{flightId}, PATCH /aircraft/{aircraftId},
PATCH /credentials/{credentialId} and PATCH /licenses/{licenseId}/class-ratings/{ratingId} —
the update endpoints whose spec schema has properties typed [T, 'null']. Fields that are
required or non-nullable in the spec (e.g. aircraftReg, issueDate) are ordinary optional
partial-update fields: omitted leaves them unchanged, and they cannot be nulled because the
domain doesn't allow it.
Server-side, a nullable field is generated as nullable.Nullable[T]
(github.com/oapi-codegen/nullable) instead of *T, via x-go-type/x-go-type-import on the
property in api-spec/openapi.yaml. This is what lets the handler distinguish "absent" from
"present and null" — a plain *T cannot represent that distinction, since both unmarshal to
nil. internal/api/handlers/nullable.go has the shared applyNullable helper handlers use to
apply this to a model field.
Before this, null in a request body was silently treated as "omitted" (*T can't tell them
apart), so nullable fields could not be cleared through the API at all, and two per-field
workarounds existed instead: sending "" to a text field, and the literal string "null" for
Flight.launchMethod. Both are retired now that real null works; launchMethod accepts only
winch, aerotow or self-launch in the spec's enum, and null clears it like any other
nullable field.
listFlights, listAircraft, listContacts, listCredentials and listLicenses accept
an optional updatedSince query parameter — an RFC 3339 date-time. It returns only the
records whose updatedAt is strictly after that instant, so a client that has already
pulled a logbook can ask "what changed since?" instead of paging the whole thing again.
Like Idempotency-Key, it is opt-in: omit it and the endpoint behaves exactly as before.
GET /api/v1/flights?updatedSince=2026-08-05T10:08:45.123456Z&page=1&pageSize=100
- Strictly after, full precision. A client stores the highest
updatedAtit has seen and replays it as the next watermark; because the comparison excludes equality, that record is not returned a second time. The comparison uses the full timestamp, unlike theq=updatedAt>YYYY-MM-DDgrammar, which is day-granular and only exists onlistFlights. - Take the watermark from the records you received, not from a local clock — a client clock ahead of the server's would skip changes permanently.
- Composes with the other filters, which are ANDed as usual:
?updatedSince=…&aircraftReg=D-EABCis "changes to that aircraft's flights". - Pages as usual, and on the paginated endpoints (
/flights,/aircraft) thepagination.totalcounts the delta, not the whole collection. - Deletions are reported separately. A removed record simply stops appearing here;
GET /sync/deletions(below) is what tells a client the id is gone. - Accepted spellings. An RFC 3339 date-time, with or without sub-second precision and
in any offset (
…Z,…+02:00); or a bareYYYY-MM-DD, read as midnight UTC on that date. An empty?updatedSince=is treated as if the parameter were omitted. Anything else is rejected with400rather than ignored — silently returning the full list would look to a sync client exactly like "everything changed".
Queries are served by the (user_id, updated_at DESC) indexes added in migration
000053 (see DATA_MODEL.md).
updatedSince can only ever report records that still exist, so a deleted flight just
stops appearing and a client mirroring the logbook keeps it forever. This endpoint closes
that gap: it reports what was deleted, against the same watermark.
A full sync pass is therefore two calls per collection plus one:
GET /api/v1/flights?updatedSince=<watermark> → upserts
GET /api/v1/sync/deletions?since=<watermark> → removals
{
"data": [
{ "entity": "flight", "id": "660e8400-…", "deletedAt": "2026-08-05T11:12:13.456789Z" }
],
"pagination": { "page": 1, "pageSize": 100, "total": 1, "totalPages": 1 },
"retentionDays": 90,
"watermarkExpired": false
}- Covers flights, aircraft, contacts, credentials and licenses — everything a sync
client mirrors. Narrow with
entity=flight; an unrecognised value is a400, because an empty feed would read as "nothing was deleted". - Oldest first, so a client can page forward and advance its watermark as it goes.
sinceis strictly-after, matchingupdatedSince, so replaying the lastdeletedAtreturns nothing. It is required: a deletions feed with no watermark is unbounded. - Paged —
DELETE /flights/delete-allwrites one tombstone per flight. Default page size 100, maximum 500; the response echoes the size actually applied. - Retention is bounded (
TOMBSTONE_RETENTION, default 90 days). Whensincepredates the horizon, swept tombstones may be missing, so the response setswatermarkExpired: trueand the client must fall back to a full ID-set reconciliation. That flag is the whole reason bounded retention is safe — without it a client offline past the horizon would resync incrementally and silently keep deleted records.
Tombstones are written by AFTER DELETE triggers on the five tables (migration 000054),
not by the Go repositories. That is deliberate: deletions reach the database by several
independent routes — the multi-table wipe behind DeleteAllUserData
(UserContentRepository), the admin user delete, and ON DELETE CASCADE — and a
trigger cannot be forgotten by a future caller, nor can it
fail after a delete the client was told succeeded. Deleting a whole account records
nothing: there is no client left to inform. See DATA_MODEL.md.
The spec defines the operations below, grouped by tag. This is a high-level map — consult
api-spec/openapi.yaml for exact request/response schemas, parameters, and status codes.
Registration, email verification (+ resend), login, token refresh, session management, change/reset password, TOTP 2FA (setup/verify/disable/login), and WebAuthn (register/login options + verify, list and delete credentials).
Sessions are first-class: GET /auth/sessions lists the user's signed-in devices,
DELETE /auth/sessions/{sessionId} ends one, and DELETE /auth/sessions ends all but the
caller's. A user holds up to MAX_SESSIONS_PER_USER (default 5) concurrent sessions, and
POST /auth/refresh rotates within a session rather than starting a new one. The rules that
clients must follow — in particular that only a 401 means the session is over — are
binding and live in SESSION_CONTRACT.md.
GET /auth/providers is a public capability probe reporting which authentication mode
the server runs in. On a deployment with OIDC_ISSUER set, every local-credential
operation in this group answers 503 and the OIDC endpoints take over —
GET /auth/oidc/authorize, GET /auth/oidc/callback (both 302 redirects, opened in a
system browser rather than called from a generated client) and POST /auth/oidc/exchange. POST /auth/refresh and POST /auth/logout behave the
same in both modes. See OIDC.md.
GET/PATCH/DELETE /users/me, notification preferences and history, baseline
(GET/PUT/DELETE /users/me/baseline), personal statistics, and account-data deletion.
In OIDC mode PATCH /users/me refuses name and email with 403 (the provider owns
them) and DELETE /users/me confirms with confirmEmail instead of password.
PATCH /users/me also carries the display preferences (timeDisplayFormat, dateFormat,
decimalSeparator, preferredLocale, the recency toggles, and flightListColumnMode /
flightListColumns for the flights-list columns). An unrecognised value for any of these
is ignored rather than rejected, and the response always echoes what was stored.
CRUD on /licenses, per-license statistics and currency, and nested class ratings
(/licenses/{id}/ratings). GET /licenses accepts updatedSince.
CRUD on /aircraft. GET /aircraft is paginated and accepts updatedSince. registration
is normalised on write into the canonical notation of its state of registry (pkg/registration);
see AIRCRAFT_REGISTRATIONS.md.
pageSize defaults to 20 and accepts up to 500; a larger value is clamped rather than
rejected, and pagination.pageSize echoes the value actually applied. Pages are ordered by
registration ASC and bounded in SQL (LIMIT/OFFSET), so a page costs one bounded query
plus one COUNT, not a scan of the whole fleet. Clients that need the complete fleet — the
fleet list, an aircraft picker — must page until pagination.page reaches
pagination.totalPages; a single request returns at most one page, whatever the fleet size.
CRUD on /flights, plus DELETE /flights/delete-all and POST /flights/recalculate
(re-run auto-calculations respecting overrides). aircraftReg is normalised the same way
as registration on create/update. POST /flights/recalculate also canonicalises the
user's whole fleet first and reports the outcome as aircraftNormalized and
aircraftConflicts. Flight responses include the read-only
departureAirportName / arrivalAirportName, resolved per request from the airport
database and null when the stored location does not resolve; they are response-only and
are not accepted on create or update. GET /flights carries the filter, search, sort and
pagination parameters, plus updatedSince for delta sync. See
FEATURES.md.
POST /flights creates either a flight or an FSTD (simulator) session, chosen by
isSimulator. Which fields are mandatory depends on the kind, so they are conditionally
rather than unconditionally required and a mismatch is a 400:
isSimulator |
Required | Rejected |
|---|---|---|
absent / false |
aircraftReg, departureIcao, arrivalIcao, offBlockTime, onBlockTime, landings |
- |
true |
fstdType, simulatedFlightTime (> 0) |
aircraftReg, departureIcao, arrivalIcao, offBlockTime, onBlockTime, landings |
date and aircraftType are required for both. A session responds with 0 in every
flight-time field and never contributes to statistics, reports, the fleet list or currency
— session time is recorded separately and is never summed with flight time
(EASA AMC1 FCL.050). See DOMAIN.md.
Flight responses carry a read-only isPassenger. It is true when another person is
pilot-in-command and the operation carries no co-pilot seat the user may occupy — the
aircraft is not marked isMultiPilot, the user is not listed with the SafetyPilot crew
role, and no co-pilot seat was declared. Such a row keeps its route and block times and
responds with 0 in every flight-time field, contributing to no statistic, report or
currency window. Clients never send it; it is derived on save. Sending sicTime
explicitly declares the co-pilot seat and is honoured on any aircraft. Mark the aircraft
with isMultiPilot (on POST/PATCH /aircraft) and re-run POST /flights/recalculate to
re-derive existing flights. See DOMAIN.md.
Create and update accept the declared function times picusTime (PIC under supervision),
spicTime (student PIC), examinerTime and reliefTime, all integer minutes. They are
never auto-calculated; a declared PICUS/SPIC/relief value carves out of the derived
PIC/SIC/dual time, and the function times together must not exceed totalTime
(examinerTime overlays and is bounded by totalTime alone) — a violation is a 400.
Declaring any of them also declares the crew seat, so a PICUS sector on any aircraft is
never stored as a passenger flight. The four appear on flight responses, in
GET /users/me/baseline (picusMinutes, spicMinutes, examinerMinutes,
reliefMinutes), in GET /users/me/statistics and the per-license statistics, in the
totals of GET /reports/analytics, as custom-currency metrics
and as logbook query fields. See
DOMAIN.md.
CRUD on /credentials (medicals, language proficiency, clearances, and the German radio
certificates RADIO_BZF2/RADIO_BZF1/RADIO_AZF). GET /credentials accepts
updatedSince.
Reference photos, scans and PDFs attached to a licence or a credential:
| Method | Path |
|---|---|
GET |
/licenses/{licenseId}/files, /credentials/{credentialId}/files |
POST |
same paths — multipart/form-data with a file part and an optional caption field |
GET |
/licenses/{licenseId}/files/{fileId}, /credentials/{credentialId}/files/{fileId} — raw bytes |
DELETE |
same per-file paths |
- Authenticated like every other endpoint, downloads included. There is no
unauthenticated URL, so the bytes cannot be loaded straight into an
<img src>; fetch with theAuthorizationheader and render or download the blob. - JPEG, PNG and PDF, at most 5 MB and 5 files per document. The format is decided from
the file's own bytes — the declared part
Content-Typeis ignored. Oversized files get413; a document already at its cap gets409; anything unrecognised gets400.- Images must have a header that parses as the format they claim, and its declared dimensions are capped. Validation stops at the header, because proving every byte means a full decode and a full pixel allocation — the cost the dimension cap exists to avoid — so a valid header followed by trailing bytes is stored as-is.
- PDFs are checked for the
%PDF-signature and a%%EOFtrailer, and nothing more: no standard-library parser exists, and adding one for untrusted input would add attack surface. That catches truncated downloads and renamed archives, not malicious content.
- PDFs are always served with
Content-Disposition: attachment, images withinline. A PDF is an active format — scripts, embedded files — and nothing on the server has parsed it, so it is never rendered inside the application's own origin. The decision is the server's; a client cannot ask for inline. Every response also carries the sniffed content type behind the globalX-Content-Type-Options: nosniff. - The whole feature can be switched off with
DOCUMENT_FILES_ENABLED=false, in which case every one of these endpoints answers403— reads as well as writes, since serving the blobs is the bandwidth half of the abuse surface the switch exists to close. Stored rows are retained and become reachable again if it is switched back on. (DOCUMENT_IMAGES_ENABLED, the name this shipped under before PDFs, is still honoured.) - Listings return metadata only (
contentType,byteSize,width,height,filename,caption);width/heightare null for formats without intrinsic dimensions such as PDF. The payload only ever comes back from a single file's own URL.
GET /features — capability probe for optional features an operator can disable, with the
limits a client needs before uploading (documentFiles.enabled, maxBytes,
maxPerDocument, allowedContentTypes). Clients should call this once after sign-in and
hide the affected UI rather than discovering the 403 by trying.
GET /currency (all ratings) and GET /licenses/{id}/currency.
User-authored currency rules under /custom-currency — a rule is a declarative document (a
rolling window, optional filters selecting which flights count, and requirements
measured against aggregated flight metrics, all combined with AND) evaluated against the
user's flights by the same engine that runs the regulatory rules.
GET/POST /custom-currency— list (each rule bundled with its current evaluation) and create. An account holds at most 200 rules.GET/PUT/DELETE /custom-currency/{ruleId}— read, replace, remove.POST /custom-currency/preview— evaluate an unsaved definition while it is being written. Under theexpensiverate limit.PUT /custom-currency/{ruleId}/enabled— pause or resume. A paused rule is kept and listed but not evaluated and not surfaced as active currency.PUT /custom-currency/{ruleId}/notify— per-rule opt-in to expiry/lapse emails. Mail is only sent when the rule is enabled and the user has email notifications on.POST/DELETE /custom-currency/{ruleId}/share— enable or disable sharing. Enabling mints a share token (stable across disable/enable cycles).GET /custom-currency/shared/{shareToken}— the read-only projection of a shared rule, owner identity omitted.POST /custom-currency/shared/{shareToken}/importcopies it into the caller's account, recording provenance inimportedFrom.
Rules are stored server-side per user (custom_currency_rules), so they are available on
every device the pilot signs in on, and they travel in the JSON export.
Airport lookup/search, route and airport statistics, trends, and stats-by-class, plus the downloadable airport pack:
GET /airports/pack— the complete merged airport database as a gzip-compressed JSON envelope{etag, generatedAt, count, airports}withairportssorted by ICAO code, for clients that need offline nearest-airport matching (the iOS Share Extension).GET /airports/pack/status— the pack'setag,generatedAt,countandsizeByteswithout the body. Theetagis a hash over the airport data alone, so it survives refreshes that produce identical data; clients re-download only on a changedetag. Both endpoints answer 503 while the airport database has never loaded.
Also
GET /reports/analytics — the whole Reports page in one round trip (totals, monthly and
yearly series, breakdowns, patterns, records), scoped by months (0 = all time). Its
totals include the user's initial-hours snapshot whenever the timeframe reaches back to
the snapshot's cutoff date, so they agree with GET /users/me/statistics; the contribution
is reported separately as baseline. Per-month, per-aircraft and per-airport breakdowns
cover logged flights only — there is nothing to attribute a snapshot to.
CRUD and search on /contacts (reusable crew/instructor records). GET /contacts
accepts updatedSince.
Contacts are keyed by name — unique per user, case-insensitive, whitespace-trimmed — and
are created automatically for every crew name written by POST/PUT /flights, import
confirm and backup restore. So:
POST /contactsreturns 409 if the user already has that name. It is for adding email/phone to somebody the logbook already knows, not for a second row.PUT /contacts/{id}renaming a contact rewrites the crew entries of the user's unsigned flights to match, and reports how many inX-Crew-Entries-Renamed(listed in the CORSExposeHeaders, without which a browser client cannot read it). Flights with a completed instructor signature keep the name they were signed with. Renaming onto an existing name returns 409 — contacts are never merged implicitly.DELETE /contacts/{id}removes only the address-book entry. Crew entries keep their name and havecontactIdset to null, so the logbook is unchanged and the delete is allowed even for contacts on signed flights.
CSV/XLSX/JSON import (upload → preview → confirm, plus direct JSON import and import history) and export to CSV, JSON, PDF, and vCard.
GET /exports/json is the full-fidelity backup: flights (with crew), aircraft, licences and
class ratings, credentials, contacts, custom currency rules, notification preferences and the
carried-forward hours baseline. It is the same payload a cloud backup run writes
(cloudbackup.Payload is the single definition of both), and POST /imports/json restores
every section of it.
Restores are additive — nothing existing is deleted, aircraft are skipped when the registration already exists and contacts when the name does — with two exceptions that are single-row settings rather than collections: notification preferences and the flight baseline replace what the account currently has. All IDs are regenerated, so a backup restores into any installation including the one it came from. A custom currency rule's sharing state is never carried over: a restored rule is private until shared again.
Anything a user owns belongs in this payload. internal/service/cloudbackup/coverage_test.go
classifies every table in db/migrations as either exported (naming its payload section) or
exempt (with the reason — credentials, installation-bound records, operator content, and
transient state), and fails on any table that is neither.
GET /exports/vcard returns the address book as a vCard 3.0 .vcf attachment: name,
email, phone, notes, the contact's logged crew roles as CATEGORIES, and a stable UID
so a re-import updates existing cards instead of duplicating them.
GET /exports/pdf renders the logbook as a print-ready PDF:
format—easa(AMC1 FCL.050 columns, times in h:mm),faa(14 CFR § 61.51 / ASA-Jeppesen columns, decimal hours), orsummary(grand totals only). Defaulteasa.layout—spread(default) is a book-style two-page spread (left + right facing pages) for double-sided printing; intentionally-blank filler pages (one at the start, one before the totals summary) keep each spread on facing pages when printed duplex.singlecondenses all columns onto one landscape page per batch of flights, designed for single-page A4 landscape printing. Ignored forsummary.page_size—a4(default),a5, orletter; always landscape.rows_per_page— optional flight-row count per logbook page (5–60). Row height — and, for dense layouts, the body font — scales dynamically to fill the page: fewer rows print larger and airier, more rows denser. Clamped to what stays legible on the chosen page size; ignored forsummary.logbookLicenseId— restrict flights to the aircraft classes of one licence.
Every logged row is printed, co-pilot (SIC) flights included. Co-pilot time is part of total time of flight, and the EASA layout has a CO-PILOT column for it (AMC1 FCL.050 col 16), so the sheets and the totals summary both cover the whole logbook.
Instructor sign-offs print with the flight they attest. A flight locked by a
completed FlightSignature renders an endorsement block in the right-hand part of
its REMARKS AND ENDORSEMENTS cell: the instructor's captured ink, their name, and
No. <credential> · <signed date>, on a tinted panel with a gold edge marker; the
remark text keeps the space to its left. The block adapts to the column it is in —
a narrow layout drops the credential line, then abbreviates the given names
("Katrin Vogelsang" → "K. Vogelsang"), and where ink and name cannot both fit the
name wins, because an unattributed mark tells an inspector nothing. Voiding a
signature unlocks the flight and removes the block from the next export. The ink is
re-encoded down to print resolution and embedded once per signature; a raster that
cannot be decoded, or that exceeds the per-document embedding budget, costs the
image and not the signer's details. The summary format has no flight rows and so
carries no endorsements.
Every logbook page carries the three-row totals block (TOTAL THIS PAGE / TOTAL FROM PREVIOUS PAGES / TOTAL TIME) and a certification + signature strip ("I certify that the entries in this log are true[.and correct.]" with PILOT'S SIGNATURE and DATE rules), so each printed page — or the right-hand page of each spread — can be individually signed. Exports are capped at 10 000 flights per PDF.
A flight the pilot was carried on as a passenger (isPassenger) logs no flight
time, and is left out of every PDF format: it would print an empty logbook row and
contribute nothing to any total, including the summary page's flight count. An
FSTD session (isSimulator) likewise carries no total time, but still prints —
its session time belongs in the FSTD columns. CSV and JSON exports carry both.
Prior experience opens the balance. If the pilot has recorded an
initial-hours snapshot (PUT /users/me/baseline), those hours are carried
into the first TOTAL FROM PREVIOUS PAGES row and into every running total,
the TOTAL TIME rows and the summary page after it — the way a paper logbook
carries the previous book's closing totals forward. Without it the TOTAL TIME
row of a pilot who joined mid-career would understate their real total time.
A snapshot records fewer figures than a logbook sheet has columns: it holds no single-/multi-engine split, no FSTD session time, no FAA actual-vs-simulated instrument split and no approach or hold counts, so those columns count logged flights only rather than being given an invented breakdown. Both facts are disclosed on the document itself — a footer line on every page ("Totals include h:mm brought forward (as of date)") and a note under the summary table.
A logbookLicenseId-filtered export covers only part of the logbook, so the
career-wide snapshot is deliberately left out of it.
User management (list, disable/enable, unlock, reset 2FA, delete), platform stats, audit log, config, maintenance (token cleanup, SMTP test, trigger notifications, unverified-account sweep), email deliverability, and announcements.
Email deliverability: GET /admin/email/deliveries is the per-send log of what SMTP
said, GET /admin/email/suppressions lists addresses that refused mail permanently,
and DELETE /admin/email/suppressions/{email} lifts one. See
AUTHENTICATION.md for what each delivery
status means and what it does not claim.
Update availability: GET /admin/update answers from a cache the background release
check fills, so it never blocks on GitHub. Each component reports up_to_date,
update_available or unknown, and updateAvailable is true when any of them is
behind. The API's own version and commit come from its build stamps; the frontend
passes its own as ?frontendVersion= and ?frontendCommit=, since only the browser
knows which frontend image is serving.
channel says which comparison produced the state:
release— the build carries a semantic version, compared against the newest published release of its repository. This is what a deployment pinned to:v1.3.4gets.commit— the build carries only a commit, which is what the:latesttags are. It is compared against the head ofUPDATE_CHECK_BRANCH(defaultmain), andbehindByreports how many commits it is behind, withcompareUrllinking the diff. A build that has diverged from the branch — a fork, or a locally built image — reportsunknownrather than guessing.
unknown therefore means neither comparison was possible: no semantic version, no
commit, or nothing looked up yet. A commit reported for the first time is unknown
until the comparison lands, a moment later. Deployments that set
UPDATE_CHECK_ENABLED=false make no outbound request and report
checkEnabled: false.
List providers, manage destinations (CRUD), test/run a destination, and inspect run history. See FEATURES.md.
GET /sync/deletions — deletions since a watermark, for offline-capable clients. See
Deletions.
GET /announcements.
- JSON field names are
camelCase. - Resource ownership is enforced in services: a user can only read/modify their own data; violations return 403.
- Pagination is used on list endpoints that can grow large (e.g. flights); see the spec for parameter names.
- The list endpoints for flights, aircraft, contacts, credentials and licenses accept
updatedSincefor incremental sync — see Delta sync — and their deletions are reported byGET /sync/deletions.
When you add or change an endpoint, update
api-spec/openapi.yamlfirst, regenerate, implement the handler/service, add tests, and update this document and FEATURES.md if the feature surface changed.