REST API reference
The API is documented by the server that serves it. Every endpoint,
every field, every status code lives on your running TravStats
instance — and so does the count: this page used to quote a number
of operations, and it was stale within a release. Count them on your
own instance instead (curl -s …/api/v1/openapi.json | jq '[.paths[] | keys[]] | length').
- Swagger UI —
https://your-instance/api/v1/docs - OpenAPI 3.0 JSON —
https://your-instance/api/v1/openapi.json
Coverage is enforced: a test walks the live route table and fails the build the day an endpoint is served without a spec entry. That is why this page no longer carries a hand-curated list of endpoints — a list written here would drift from the server within a release, and it did. Read the spec; this page explains how, and the patterns that cost time when you don’t know them.
Reading the spec
Section titled “Reading the spec”Open /api/v1/openapi.json on your instance and look at three things:
paths— one entry per route, grouped by tag. Tags follow the domains and the settings areas:flights,cruises,lodging,places,trips,stats,import,parsers,auth,settings,admin, and so on.components.schemas— the request and response shapes, generated from the same Zod schemas the server validates against. A field the schema marksnullablereally can come backnull; a value that cannot be derived isnull, never zero.- The response shape per router — see below.
Feed the JSON to any OpenAPI-aware tool — a code generator, Postman, Bruno — or ask an assistant to read it. The spec is the contract; the Swagger UI is the same document with a Try it button.
The families added in 2.6.0, so you know what to look for:
/stats/passport,/stats/countries/:code,/stats/records,/stats/network,/stats/wrapped, and/stats/summarynow withdaysAwayper domain and in total/country-flags/:isoand/country-flags?codes=/parse-image(a photographed document), anddomain: "auto"on the mail and PDF parse routes/place-import/previewand/place-import/commit/xlsx-import(the spreadsheet round trip) and/import-batches/data-quality-flags(the inbox’s second half)/pairing/*(the Companion claim-code flow)/auth/2fa/*and/auth/passkeys/*/currenciesGET /tripscarrying_count.routesbeside flights, cruises and stays
Conventions
Section titled “Conventions”Base URL
Section titled “Base URL”https://<your-instance>/api/v1The version prefix is mandatory.
Authentication
Section titled “Authentication”Two paths, same credentials:
- Cookie session —
POST /auth/loginwith username + password. The response sets anHttpOnlycookie used on subsequent requests. An account with two-factor on answers a correct password with{requiresTwoFactor: true}and expectsPOST /auth/2fa/verify; an account signing in with a passkey goes through/auth/passkeys/login/optionsand/login/verifyand gets its session directly. Used by the web UI; rarely useful for scripts. - Personal Access Token —
Authorization: Bearer ts_pat_…header on every request. The recommended path for everything programmatic. See Personal Access Tokens.
Both paths return 401 Unauthorized for missing / invalid /
expired credentials. A deactivated account is refused a session on
every path that could mint one.
Two response shapes, one per router
Section titled “Two response shapes, one per router”The API answers in one of two shapes, and a router never mixes them:
- Bare — the object or array directly. Flights, stats, auth, settings, trips, achievements.
- Enveloped —
{ success, data, error }. Lodging, places, cruises, imports, and the newer routers.
Which family a router belongs to is fixed per router and pinned by a test; the spec’s response schema for each path shows which you get. Client code that reads a flight list and a lodging list needs two unwrapping steps, and that is intended rather than accidental.
Errors come back with a non-2xx status and a JSON body:
{ "error": "Validation failed", "details": [ { "path": ["departureLocal"], "message": "Invalid date format" } ]}The details array (Zod issue list) is present on validation
errors only. Messages meant for a person travel as message keys the
frontend translates; a script should branch on the status and, where
the body carries one, the kind.
Status codes
Section titled “Status codes”| Code | Meaning |
|---|---|
200 OK | Read or update succeeded |
201 Created | New resource created |
400 Bad Request | Validation failed (Zod issue list in details) |
401 Unauthorized | Missing or invalid auth |
403 Forbidden | Authenticated but lacks the scope — a read token tried to mutate, or a non-admin hit /admin/* |
404 Not Found | Resource doesn’t exist or you don’t own it. Also a valid answer from /country-flags/:iso |
409 Conflict | Duplicate detection, or a state that forbids the action (a backup that is not finished) |
429 Too Many Requests | Rate limit hit (response includes Retry-After) |
502 Bad Gateway | An external service the instance depends on answered badly, with its own words kept |
500 Internal Server Error | A bug in TravStats — please file an issue with the request that triggered it |
Caching
Section titled “Caching”Every /api response is Cache-Control: no-store by default. That is
a security boundary: a shared cache was once observed serving one
signed-in user’s response to another. The handlers that legitimately
cache — airline logos, country flags, the Immich asset proxy — set
Cache-Control: private, max-age=… and an ETag themselves. A proxy in
front of TravStats must not add a blanket cacheable header to /api.
Rate limits
Section titled “Rate limits”Every route carries a limiter or a documented reason it needs none. Limits that run after authentication count per user, not per address, so a household behind one reverse proxy does not share a bucket. Personal Access Tokens get their own bucket per token. The expensive routes — the Immich photo-journey scan, CSV preview and parse, uploads, the cruise geometry batch, the achievements leaderboard — are limited by cost.
Pagination
Section titled “Pagination”List endpoints accept limit and offset (or a cursor, where the
spec says so); the total comes back in a header or the envelope. A
bound is applied in the query with a total ordering, so a page never
skips or duplicates a row. GET /flights caps limit at 500 and
lifts the cap only on an explicit all=true, which is the switch a
full export wants. Two endpoints deliberately return everything:
/stats/network, because a truncated network is not a smaller globe
but a wrong one, and /country-flags?codes= up to 250.
Patterns worth knowing
Section titled “Patterns worth knowing”Times are (local, timezone) pairs
Section titled “Times are (local, timezone) pairs”Writes that carry a time take a local wall-clock string and an IANA
zone — departureLocal + depTimezone — not an ISO instant. The
server derives canonical UTC and stores both. A raw departureTime on
a write is rejected with 400.
?merge=true on flight creation
Section titled “?merge=true on flight creation”Fills missing fields on an existing matching flight — same flight number, same calendar day, same airports — instead of creating a duplicate. Curated fields are never overwritten. This is what the boarding-pass and mail re-imports use.
Money carries its currency
Section titled “Money carries its currency”An amount without a currency is refused rather than assumed to be euros. Flights and stays carry an exchange-rate snapshot taken on the day of the spend; cruises carry none, which is why cruise totals are reported per currency and never summed. See Money & currencies.
Abstention is a result
Section titled “Abstention is a result”A value that cannot be derived is null or absent. daysAway for a
domain with no dated entries is absent; a year’s favourite the year
cannot support is absent; a duration that cannot be derived is
null, and a total that left entries out says how many.
Documents announce themselves
Section titled “Documents announce themselves”POST /parse-email and POST /parse-pdf accept domain: "auto"; the
server returns the parsed result with the domain it decided on and the
runners-up, so a client can offer a switch instead of asking for the
file again. An explicitly named domain keeps its exact previous
behaviour. POST /parse-image takes a photograph through OCR into the
same pipeline. POST /parse-boardingpass reads the barcode first.
Batches and reverts
Section titled “Batches and reverts”POST /pending-updates/apply and /reject take a list of ids and
report an outcome per id. Imports commit as one batch under
/import-batches, and DELETE /import-batches/:id reverts the whole
run.
Beta gates are visibility, not authorisation
Section titled “Beta gates are visibility, not authorisation”The routes behind the beta switch — /pairing/*, /stats/passport,
the places routes, the trip route sections — stay reachable for an
authenticated user whatever the switch says. The switch hides the UI;
it does not fence the API. The Companion app relies on that.
Health check
Section titled “Health check”GET /healthReturns 200 with { "status": "ok", "timestamp": "…" } once the
database is reachable and migrations are applied; 503 while
starting, migrating, or when the database is unreachable. No auth
required — safe to expose to UptimeKuma, Statping, healthchecks.io.
Version + update info
Section titled “Version + update info”GET /api/v1/versionReturns the running version and, if GitHub was reachable in the last
six hours, the latest stable release for the update banner. No auth
required. On an air-gapped instance the update fields are null and
updateAvailable is false.
Versioning
Section titled “Versioning”The current API version is v1. Field additions to existing
endpoints are not breaking and land in v1 responses immediately.
Breaking changes would go to /api/v2/ with v1 kept alongside. The
CHANGELOG flags any API-affecting change in each release.