Skip to content

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.

Open /api/v1/openapi.json on your instance and look at three things:

  1. 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.
  2. components.schemas — the request and response shapes, generated from the same Zod schemas the server validates against. A field the schema marks nullable really can come back null; a value that cannot be derived is null, never zero.
  3. 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/summary now with daysAway per domain and in total
  • /country-flags/:iso and /country-flags?codes=
  • /parse-image (a photographed document), and domain: "auto" on the mail and PDF parse routes
  • /place-import/preview and /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/*
  • /currencies
  • GET /trips carrying _count.routes beside flights, cruises and stays
https://<your-instance>/api/v1

The version prefix is mandatory.

Two paths, same credentials:

  • Cookie session — POST /auth/login with username + password. The response sets an HttpOnly cookie used on subsequent requests. An account with two-factor on answers a correct password with {requiresTwoFactor: true} and expects POST /auth/2fa/verify; an account signing in with a passkey goes through /auth/passkeys/login/options and /login/verify and 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.

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.

CodeMeaning
200 OKRead or update succeeded
201 CreatedNew resource created
400 Bad RequestValidation failed (Zod issue list in details)
401 UnauthorizedMissing or invalid auth
403 ForbiddenAuthenticated but lacks the scope — a read token tried to mutate, or a non-admin hit /admin/*
404 Not FoundResource doesn’t exist or you don’t own it. Also a valid answer from /country-flags/:iso
409 ConflictDuplicate detection, or a state that forbids the action (a backup that is not finished)
429 Too Many RequestsRate limit hit (response includes Retry-After)
502 Bad GatewayAn external service the instance depends on answered badly, with its own words kept
500 Internal Server ErrorA bug in TravStats — please file an issue with the request that triggered it

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.

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.

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.

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.

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.

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.

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.

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.

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.

GET /health

Returns 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.

GET /api/v1/version

Returns 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.

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.