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, looking like:
Copy it now. Click away and you’ll never see it again — only the bcrypt hash is kept on the server side.ts_pat_a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdefABCD
- Use it as a Bearer token:
Authorization: Bearer ts_pat_a1b2c3d4e5f6789…
Scopes
Section titled “Scopes”Each token carries one scope:
| Scope | Can read | Can write | Can hit /admin/* |
|---|---|---|---|
read | ✓ | ✗ (returns 403 Forbidden) | ✗ |
write | ✓ | ✓ | ✗ |
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 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, rotating SMTP passwords, 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:
- Server generates 32 random bytes, formats as
ts_pat_<base64url> - Computes two hashes:
- Lookup hash (SHA-256) — used to find the token row in O(1) on every request
- Bcrypt hash (cost 10) — verified against the supplied token to confirm authenticity
- Returns the plaintext to you once
- Stores:
lookupHash,bcryptHash,name,scope,createdAt,lastUsedAt,userId
The plaintext token never touches disk. The bcrypt cost-10 hash means even if the database leaks, brute-forcing a token from its hash takes ~years per attempt.
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 limits match the per-user defaults (see API & Automation). You can tune them per token if needed in Settings → API Tokens → Rate limits (admin-only).
Using a token
Section titled “Using a token”TOKEN="ts_pat_a1b2c3d4…"
# List flightscurl -fsS \ -H "Authorization: Bearer $TOKEN" \ https://travstats.example.com/api/v1/flights
# 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.
Python
Section titled “Python”import os, requests, datetime
TOKEN = os.environ["TRAVSTATS_TOKEN"]BASE = "https://travstats.example.com/api/v1"HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# List all flightsr = requests.get(f"{BASE}/flights", headers=HEADERS)r.raise_for_status()for flight in r.json(): 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 flightsconst flights = await fetch(`${BASE}/flights`, { headers }).then(r => r.json());console.log(`Got ${flights.length} 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”Backup all flights nightly
Section titled “Backup all flights nightly”#!/bin/bashTOKEN="$(cat /etc/travstats/read-token)"DATE=$(date +%F)curl -fsS \ -H "Authorization: Bearer $TOKEN" \ "https://travstats.example.com/api/v1/flights?limit=10000" \ | gzip > /var/backups/travstats/flights-$DATE.json.gzMailbox 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; full sample script in the GitHub
scripts/
folder once it lands (TODO: this script is on the roadmap).
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.