app/api/** in the Next.js bundle. Every endpoint is a thin wrapper over a helper in lib/. Public request and response shapes are stable.
The interactive endpoint pages with Try it support are auto-generated from openapi.yaml — see the API Reference sidebar for per-endpoint detail. This page covers the cross-cutting concerns: authentication, CSRF, rate-limiting, and how to recognise the response shapes.
Base URL
Server dropdown in the playground to match your deployment.
Authentication
Three layers. Only the middle one is confined to mutations — reads pass the host allowlist like everything else, and on a network-exposed instance a list of sensitive read paths needs the admin token too.Host allowlist (always on, every method)
Host allowlist (always on, every method)
Before any other check, a request whose effective with status 400. The allowlist defaults to loopback; operators extend it with
Host isn’t allowlisted is rejected — GET included:PRIVACYTRACKER_ALLOWED_HOSTS. If your integration talks to the instance under a hostname the operator hasn’t listed, every call fails here regardless of method or credentials.Same-origin CSRF check (always on, mutations)
Same-origin CSRF check (always on, mutations)
Every mutating verb (From If you’re behind a reverse proxy, the proxy must forward the original
POST, PUT, PATCH, DELETE) under /api/ checks that the Origin header matches the request host, unless the request carries a valid admin token instead. Browser requests from your installed UI satisfy this automatically; cross-origin or curl-from-a-different-host calls won’t.Failed CSRF returns 403:curl, set the header explicitly:Host header. See Troubleshooting → Reverse-proxy CSRF rejection.Admin token (optional, opt-in)
Admin token (optional, opt-in)
When Failed attempts are recorded in the Reads are gated too on a network-exposed instance. Once the operator sets Without it they return 401
AUDITOR_ADMIN_TOKEN is set in the environment, destructive routes (POST /api/reset, DELETE /api/apps, POST /api/settings, DELETE /api/wayback/import-all, etc.) require the token on top of the CSRF check. Verification uses crypto.timingSafeEqual. Missing or wrong token returns 401:audit_log table with requester IP + user agent.PRIVACYTRACKER_NETWORK_EXPOSED, lists a non-loopback host, or binds to a specific non-loopback IP, GET requests under these prefixes also need the token:{ "error": "Admin token required for non-local API access" }. This is the usual cause of a surprise 401 on GET /api/backup/export from a LAN integration.Two of those reads don’t wait for exposure. GET /api/backup/export and GET /api/ai/debug-log check adminTokenConfigured() || isNetworkExposed() in the handler itself, so they return 401 { "error": "Admin token required" } as soon as AUDITOR_ADMIN_TOKEN is set — on a loopback-only install too.Cookie sessions. POST /api/auth/admin-token/login with { "token": "..." } exchanges the token for an 8-hour HttpOnly pt_admin_token cookie, which every gated route accepts in place of the header. POST /api/auth/admin-token/logout clears it; GET /api/auth/admin-token/status returns { configured, unlocked }. Login and logout enforce same-origin themselves and login is capped at 5 attempts per minute; status is a read, so neither its handler nor the CSRF layer origin-checks it — it still has to clear the host allowlist, and it never returns the token. Prefer the header for scripted callers; the cookie exists so the browser UI never holds the raw secret in JavaScript.The token is a single shared secret — there’s no built-in user/account system in privacytracker. The intent is to gate destructive routes when the app is exposed beyond a single-user trusted boundary (e.g., self-hosted on a LAN).Conventions
A handful of patterns hold across every endpoint:apps.idis Apple’s numeric track ID, extracted from/id<digits>/in the App Store URL — not a UUID. Snapshots, privacy rows, and notifications all key off it.- Timestamps are Unix milliseconds, not seconds and not ISO-8601. JavaScript
Date.now()-shaped. - Errors are
{ "error": "<message>" }with the appropriate 4xx / 5xx status. The message is human prose, not a stable machine code — switch on the status, not the string. A few routes add structured fields alongside it (POST /api/backup/restoresendscode: "untrusted_backup"on a 409); those are documented per-endpoint. - Streamed responses (
POST /api/wayback/import-all?stream=1) emit NDJSON with akindfield per line —batch-start,app-start,target,app-done,summary. - Mutation routes returning 409 mean a mutex is held by another in-flight run. Wait or check
GET /api/tasks/activeto see what’s running.
Rate limiting
A 429 from privacytracker has two possible causes, and they need different handling. privacytracker’s own limiter. Most routes are rate-limited per client IP. Some denials carry aRetry-After header in seconds — honour it when it’s there, but don’t depend on it. A clear majority of the 429 paths set it — 33 of the 56 rate-limited routes: all 23 behind the shared mutation guard, plus 10 direct callers such as /api/scrape, /api/search, and the token login. The rest return a bare 429 — including POST /api/reset and POST /api/backup/restore in the table below — so fall back to the route’s own window when the header is absent. Representative limits:
Note that unless the operator sets
PRIVACYTRACKER_TRUST_PROXY, forwarded-IP headers are ignored and every caller shares one bucket per route — so a busy sibling integration can consume your budget.
Apple’s 429, on the iTunes Search API and apps.apple.com, is the slower one. It surfaces through the bulk runners rather than as an HTTP status on your call:
- The runner bails out of its loop on the first 429.
- A
partial: rateLimitedactivity row is written. - State and mutex are cleared cleanly, so the next 30-minute scheduler tick can retry fresh.
Retry-After — the internal limiter sets that header on some routes only. An internal denial arrives as an HTTP 429 on the call you just made and clears within that route’s own window (under a minute for /api/scrape). If you’re seeing repeated partial: rateLimited in the activity log with no 429 on your own requests, that’s Apple, and the useful response is to slow the schedule rather than retry.
Backup bundle format
GET /api/backup/export and POST /api/backup/restore use a versioned JSON envelope carrying an integer version (currently 1) — the bundle format version, not the app version. The canonical implementation is lib/backup.ts. A bundle from an older format restores into a newer release; a newer one is refused outright rather than misparsed.
Envelopes are HMAC-signed with a key unique to the install that exported them. A bundle from a different install fails verification and gets 409 { "error": "...", "code": "untrusted_backup", "signaturePresent": true }. To restore it anyway, pass ?allowUntrusted=1 or the header x-allow-untrusted-backup: 1. There is no confirm parameter — the RESTORE typing step is browser-side only.
Private annotations (visibility = 'private') are unconditionally excluded from audit-bundle exports at the SQL level. There is no force-include path.
Pagination
GET /api/apps supports opt-in pagination for large fleets. Without
parameters it returns the complete fleet as a bare JSON array — stable
for existing consumers, but the response grows linearly with the fleet
(~0.7 KB per app). Passing limit (1–500, plus an optional offset)
switches the response to an envelope:
meta=grid to bundle the per-app maps the apps grid
renders (profile badges, user verdicts, pending-change breakdown, device
links), scoped to the returned page. For installs tracking more than
~1,000 apps, prefer the paginated form — it’s what the app’s own grid uses.
Calling the API from another app
Three patterns are common:Full route map
The OpenAPI spec covers the public-contract surface — the routes integrators most commonly hit. The complete route map (including dev-only and internal routes) is in the Architecture overview and the underlyingapp/api/*/route.ts files in the source tree: