Skip to content

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.

  1. Settings → API Tokens (your own user, not admin).
  2. Click Create Token.
  3. Fill in:
    • Name — purely for your reference. “Home Assistant”, “Backup script”, “AI agent for travel logs”.
    • Scope — read, write, or admin. Pick the smallest one that does what you need (see scope table below).
  4. The token is shown once, full plaintext. It is the prefix ts_pat_ followed by 64 hex characters, 71 characters in all:
    ts_pat_9f2c…(64 hex characters)…e41b
    Copy it now. Click away and you’ll never see it again — only hashes are kept on the server side.
  5. Use it as a Bearer token:
    Authorization: Bearer ts_pat_9f2c…

Each token carries one scope:

ScopeCan readCan writeCan 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.

  • read — backup scripts, dashboards, Home Assistant widgets, anything just observing your data
  • write — mailbox sweepers, boarding-pass scanners, AI agents that log flights you tell them about, bulk-correction scripts
  • admin — 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.

When you mint:

  1. The server draws 32 random bytes and formats them as ts_pat_<64 hex>.
  2. 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
  3. It returns the plaintext to you once.
  4. 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.

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.

Terminal window
TOKEN="ts_pat_9f2c…"
# List flights — the answer is an object, not a bare array
curl -fsS \
-H "Authorization: Bearer $TOKEN" \
https://travstats.example.com/api/v1/flights
# {"flights":[…],"total":812,"limit":100,"offset":0,"all":false}
# Create a flight
curl -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/flights

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

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.

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 cap
r = 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 flight
new_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"])
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);
#!/bin/bash
TOKEN="$(cat /etc/travstats/read-token)"
DATE=$(date +%F)
# all=true — without it the export stops at 500 rows and says nothing
curl -fsS \
-H "Authorization: Bearer $TOKEN" \
"https://travstats.example.com/api/v1/flights?all=true" \
| gzip > /var/backups/travstats/flights-$DATE.json.gz

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

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.

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: 3600

Anywhere you can speak HTTP, you can read TravStats.

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.

There’s no automatic rotation. To rotate manually:

  1. Mint a new token with the same scope.
  2. Update your script / integration with the new token.
  3. Verify the new one works.
  4. 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.

  • One token per integration. Easier to revoke when something goes wrong.
  • Smallest scope that works. read for dashboards, write for sweepers, admin only 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.