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”.
    • Scoperead, write, or admin. Pick the smallest one that does what you need (see scope table below).
  4. The token is shown once, full plaintext, looking like:
    ts_pat_a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdefABCD
    Copy it now. Click away and you’ll never see it again — only the bcrypt hash is kept on the server side.
  5. Use it as a Bearer token:
    Authorization: Bearer ts_pat_a1b2c3d4e5f6789…

Each token carries one scope:

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

  • 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, 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.

When you mint:

  1. Server generates 32 random bytes, formats as ts_pat_<base64url>
  2. 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
  3. Returns the plaintext to you once
  4. 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.

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

Terminal window
TOKEN="ts_pat_a1b2c3d4…"
# List flights
curl -fsS \
-H "Authorization: Bearer $TOKEN" \
https://travstats.example.com/api/v1/flights
# 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.

import os, requests, datetime
TOKEN = os.environ["TRAVSTATS_TOKEN"]
BASE = "https://travstats.example.com/api/v1"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# List all flights
r = 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 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
const 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);
#!/bin/bash
TOKEN="$(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.gz

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

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.