The privacytracker HTTP API lives under 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

Self-hosted users should substitute their own base URL (and host header — see the CSRF section). The OpenAPI spec defaults to localhost; change the 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.
Before any other check, a request whose effective Host isn’t allowlisted is rejected — GET included:
with status 400. The allowlist defaults to loopback; operators extend it with 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.
Every mutating verb (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:
From curl, set the header explicitly:
If you’re behind a reverse proxy, the proxy must forward the original Host header. See Troubleshooting → Reverse-proxy CSRF rejection.
When 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:
Failed attempts are recorded in the audit_log table with requester IP + user agent.
Reads are gated too on a network-exposed instance. Once the operator sets 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:
Without it they return 401 { "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.id is 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/restore sends code: "untrusted_backup" on a 409); those are documented per-endpoint.
  • Streamed responses (POST /api/wayback/import-all?stream=1) emit NDJSON with a kind field 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/active to 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 a Retry-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: rateLimited activity row is written.
  • State and mutex are cleared cleanly, so the next 30-minute scheduler tick can retry fresh.
Tell them apart by where the 429 lands, not by 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:
Pages are ordered by app name (then id), so offsets are deterministic across requests. Add 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 underlying app/api/*/route.ts files in the source tree: