Personal Access Tokens
Personal Access Tokens (PATs) are how scripts and external tools authenticate against the TravStats API. They behave like long-lived session cookies but live in an HTTP header instead.
Introduced in v1.3.0, mintable from Settings → API Tokens.
Minting a token
Section titled “Minting a token”- Settings → API Tokens (your own user, not admin).
- Click Create Token.
- Fill in:
- Name — purely for your reference. “Home Assistant”, “Backup script”, “AI agent for travel logs”.
- Scope —
read,write, oradmin. Pick the smallest one that does what you need (see scope table below).
- The token is shown once, full plaintext. It is the prefix
ts_pat_followed by 64 hex characters, 71 characters in all:Copy it now. Click away and you’ll never see it again — only hashes are kept on the server side.ts_pat_9f2c…(64 hex characters)…e41b - Use it as a Bearer token:
Authorization: Bearer ts_pat_9f2c…
Scopes
Section titled “Scopes”Each token carries one scope:
| Scope | Can read | Can write | Can hit /admin/* and other admin-only routers |
|---|---|---|---|
read | ✓ | ✗ (returns 403 Forbidden) | ✗ |
write | ✓ | ✓ | ✗ |
admin | ✓ | ✓ | ✓ — and only if the token’s user is an admin |
admin does NOT come automatically with a user account that has
isAdmin: true — even if you’re an admin, your read token
can’t reach admin endpoints. This is deliberate; it means a leaked
token has the smallest possible blast radius. The reverse holds too:
an admin-scoped token minted by a non-admin user is still refused
by admin routers.
The write scope guard fired a real bug in v1.3.0: previously
requireWriteScope was defined but not wired in, so read tokens
could mutate. The middleware is now mounted on every mutation
router (POST/PUT/PATCH/DELETE on flights, trips, settings, parser
templates, achievements, analytics, uploads). Method-aware:
GET/HEAD/OPTIONS always pass through unconditionally on any
scope.
When to pick which scope
Section titled “When to pick which scope”read— backup scripts, dashboards, Home Assistant widgets, anything just observing your datawrite— mailbox sweepers, boarding-pass scanners, AI agents that log flights you tell them about, bulk-correction scriptsadmin— only when you’re automating admin tasks like creating users, pulling the server-side backup archives, exporting audit logs
Don’t reuse one token across multiple use cases. One token per script or app makes revocation a one-click operation when something goes wrong.
How the token is stored
Section titled “How the token is stored”When you mint:
- The server draws 32 random bytes and formats them as
ts_pat_<64 hex>. - It computes two hashes:
- Lookup hash (SHA-256) — finds the token row in O(1) on every request
- bcrypt hash — compared in constant time against the supplied token to confirm authenticity
- It returns the plaintext to you once.
- It stores:
lookupHash, the bcrypt hash,name,scope,createdAt,lastUsedAt,lastUsedIp,userId.
The plaintext never touches disk. If the database leaks, an attacker holds hashes of 256-bit random secrets: there is nothing to guess, and bcrypt makes each guess slow on top.
Per-token rate limits
Section titled “Per-token rate limits”Each PAT gets its own rate-limit bucket: pat:<token-id>. This
means:
- A misbehaving script eating its rate limit doesn’t affect your web UI session
- Different scripts running off different tokens have independent buckets
- Revoking a token drops the bucket — a fresh token gets a fresh bucket
Bucket sizes are not configurable per token — there is no such setting in the UI or the API. What a PAT gets is a fixed multiplier: on the limiters that carry it, a token-authenticated caller is allowed ten times the per-user ceiling (a 200-row spreadsheet import should not trip a limiter sized for a browser session). The base figures are on API & Automation.
Using a token
Section titled “Using a token”TOKEN="ts_pat_9f2c…"
# List flights — the answer is an object, not a bare arraycurl -fsS \ -H "Authorization: Bearer $TOKEN" \ https://travstats.example.com/api/v1/flights# {"flights":[…],"total":812,"limit":100,"offset":0,"all":false}
# Create a flightcurl -fsS -X POST \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "departure": {"iata":"FRA","lat":50.0379,"lon":8.5622}, "arrival": {"iata":"JFK","lat":40.6413,"lon":-73.7781}, "departureLocal":"2026-05-01T08:00", "depTimezone":"Europe/Berlin", "arrivalLocal":"2026-05-01T11:00", "arrTimezone":"America/New_York", "flightNumber":"LH400" }' \ https://travstats.example.com/api/v1/flightsNote the (local datetime, IANA timezone) pair — the API rejects raw
ISO datetime strings since v1.2.0. The server converts to canonical
UTC on save.
The flight list is paged
Section titled “The flight list is paged”GET /flights returns {flights, total, limit, offset, all}.
limit defaults to 100 and is capped at 500 — asking for
limit=10000 silently gives you 500. To get everything in one call,
pass all=true, which lifts the cap and ignores offset; or page
with limit and offset until offset + flights.length >= total.
Python
Section titled “Python”import os, requests
TOKEN = os.environ["TRAVSTATS_TOKEN"]BASE = "https://travstats.example.com/api/v1"HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# List ALL flights — all=true lifts the 500-row capr = requests.get(f"{BASE}/flights", params={"all": "true"}, headers=HEADERS)r.raise_for_status()body = r.json()print(f"{body['total']} flights")for flight in body["flights"]: print(f"{flight['flightNumber']} {flight['departureIata']} → {flight['arrivalIata']}")
# Create a flightnew_flight = { "departure": {"iata":"FRA","lat":50.0379,"lon":8.5622}, "arrival": {"iata":"JFK","lat":40.6413,"lon":-73.7781}, "departureLocal":"2026-05-01T08:00", "depTimezone":"Europe/Berlin", "arrivalLocal":"2026-05-01T11:00", "arrTimezone":"America/New_York", "flightNumber":"LH400",}r = requests.post(f"{BASE}/flights", json=new_flight, headers=HEADERS)r.raise_for_status()print("Created:", r.json()["id"])Node.js
Section titled “Node.js”const TOKEN = process.env.TRAVSTATS_TOKEN;const BASE = "https://travstats.example.com/api/v1";
const headers = { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" };
// List ALL flights — the body is {flights, total, limit, offset, all}const { flights, total } = await fetch(`${BASE}/flights?all=true`, { headers }).then(r => r.json());console.log(`Got ${flights.length} of ${total} flights`);
// Create a flight (with merge=true to enrich an existing matching flight)const newFlight = { departure: { iata:"FRA", lat:50.0379, lon:8.5622 }, arrival: { iata:"JFK", lat:40.6413, lon:-73.7781 }, departureLocal:"2026-05-01T08:00", depTimezone: "Europe/Berlin", arrivalLocal: "2026-05-01T11:00", arrTimezone: "America/New_York", flightNumber: "LH400",};const created = await fetch(`${BASE}/flights?merge=true`, { method: "POST", headers, body: JSON.stringify(newFlight),}).then(r => r.json());console.log("Created or merged:", created.id);Common script patterns
Section titled “Common script patterns”Export all flights nightly
Section titled “Export all flights nightly”#!/bin/bashTOKEN="$(cat /etc/travstats/read-token)"DATE=$(date +%F)# all=true — without it the export stops at 500 rows and says nothingcurl -fsS \ -H "Authorization: Bearer $TOKEN" \ "https://travstats.example.com/api/v1/flights?all=true" \ | gzip > /var/backups/travstats/flights-$DATE.json.gzTest it once against an account with more than 500 flights and
compare total in the file with the row count. For a copy of the
whole instance — every domain, every photo — use the server-side
backup instead; see Backups & Restore.
Mailbox sweeper
Section titled “Mailbox sweeper”A Python script that walks an IMAP folder, sends each unread booking
confirmation through /parse-email, marks the message as processed.
Take a look at the
/api/v1/parse-email schema
for the request shape. A ready-made sample script is planned for the
GitHub scripts/
folder; until then the endpoint reference above is enough to build your own.
Home Assistant sensor
Section titled “Home Assistant sensor”Add to configuration.yaml:
sensor: - platform: rest name: TravStats Total Flights resource: https://travstats.example.com/api/v1/stats/summary headers: Authorization: !secret travstats_read_token value_template: "{{ value_json.totalFlights }}" scan_interval: 3600Anywhere you can speak HTTP, you can read TravStats.
Revoking a token
Section titled “Revoking a token”Settings → API Tokens → Delete. Instant — the next request fails
with 401 Unauthorized. Already-running scripts get a clear error
on their next call.
If you suspect a token was leaked, revoke it before investigating the leak. Cheap operation, no downtime, no impact on your other tokens.
Rotating a token
Section titled “Rotating a token”There’s no automatic rotation. To rotate manually:
- Mint a new token with the same scope.
- Update your script / integration with the new token.
- Verify the new one works.
- Revoke the old one.
If you have many integrations using the same token, give each its own — rotation becomes a per-integration update instead of a flag day.
Best practices
Section titled “Best practices”- One token per integration. Easier to revoke when something goes wrong.
- Smallest scope that works.
readfor dashboards,writefor sweepers,adminonly when needed. - Store in a secret manager — Bitwarden, 1Password, Doppler, your homelab’s preferred tool. Don’t commit tokens to git, even private repos.
- Set a name that names the use case — “Backup script” not “Token #3” — the scope dropdown alone doesn’t tell you what’s safe to revoke.
- Audit the lastUsedAt field periodically — tokens that haven’t been used in a year aren’t doing anything; revoke them.