# API overview
Source: https://docs.privacytracker.privacykey.org/api-reference/introduction

How to call the privacytracker HTTP API — authentication, conventions, and where to find the interactive playground.

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

```
http://localhost:3000
```

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.

**Host allowlist (always on, every method)**

Before any other check, a request whose effective `Host` isn't allowlisted is rejected — `GET` included:

```json
{ "error": "Host not allowed" }
```

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.

**Same-origin CSRF check (always on, mutations)**

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

```json
{ "error": "Cross-origin mutation rejected" }
```

From `curl`, set the header explicitly:

```bash
curl -X POST http://localhost:3000/api/reset \
  -H "Origin: http://localhost:3000"
```

If you're behind a reverse proxy, the proxy must forward the original `Host` header. See [Troubleshooting → Reverse-proxy CSRF rejection](https://docs.privacytracker.privacykey.org/troubleshooting#reverse-proxy-csrf-rejection).

**Admin token (optional, opt-in)**

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

```json
{ "error": "Admin token required" }
```

Failed attempts are recorded in the `audit_log` table with requester IP + user agent.

```bash
curl -X POST http://localhost:3000/api/reset \
  -H "Origin: http://localhost:3000" \
  -H "X-Auditor-Admin-Token: $AUDITOR_ADMIN_TOKEN"
```

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

```
/api/ai/debug-log
/api/backup/
/api/deployment/
/api/desktop/diagnostics
/api/diagnostics/
/api/export
/api/import/
```

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:

| Route | Limit |
|---|---|
| `POST /api/scrape` | 30 / minute |
| `POST /api/search` | 60 / minute |
| `POST /api/reset` | 30 / 10 minutes |
| `POST /api/backup/restore` | 3 / 10 minutes |
| `POST /api/auth/admin-token/login` | 5 / minute |

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:

```json
{ "apps": [...], "total": 5000, "limit": 500, "offset": 0 }
```

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:

```bash curl
curl -s "http://localhost:3000/api/apps?limit=500" | jq '.apps[] | {id, name, changeCount}'

curl -X POST http://localhost:3000/api/scrape \
-H "Content-Type: application/json" \
-H "Origin: http://localhost:3000" \
--data '{"urls":["https://apps.apple.com/us/app/spotify-music-and-podcasts/id324684580"]}'
```

```javascript fetch
const res = await fetch("http://localhost:3000/api/apps?limit=500");
const { apps, total } = await res.json();

await fetch("http://localhost:3000/api/scrape", {
method: "POST",
headers: {
  "Content-Type": "application/json",
  "Origin": "http://localhost:3000",
},
body: JSON.stringify({
  urls: ["https://apps.apple.com/us/app/spotify-music-and-podcasts/id324684580"],
}),
});
```

```python httpx
import httpx

async with httpx.AsyncClient(base_url="http://localhost:3000") as c:
  apps = (await c.get("/api/apps", params={"limit": 500})).json()["apps"]

  await c.post(
      "/api/scrape",
      headers={"Origin": "http://localhost:3000"},
      json={"urls": ["https://apps.apple.com/us/app/spotify-music-and-podcasts/id324684580"]},
  )
```

## 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](https://docs.privacytracker.privacykey.org/develop/architecture#api-surface) and the underlying `app/api/*/route.ts` files in the source tree:

| Group | Where in the source |
|---|---|
| Apps, scrape, search, changelog | `app/api/{apps,search,scrape,changelog}/route.ts` |
| Backup, snapshots, audit bundle | `app/api/backup/**/route.ts`, `app/api/{import,export}/audit-bundle/route.ts` |
| AI, policy versions, debug log | `app/api/{ai,policy}/**/route.ts` |
| Focus, flags, profile, prefs | `app/api/{focus,feature-flags,privacy-profile,accessibility-profile,preferences}/**/route.ts` |
| Notes, verdicts, notifications | `app/api/{annotations,verdicts,notifications}/**/route.ts` |
| Bulk task status | `app/api/tasks/active/route.ts`, `app/api/{sync,wayback,policy}/**/route.ts` |
| Stats, charts | `app/api/stats/**/route.ts` |
| Devices, device menu, device actions | `app/api/{devices,device-scope,device-sync,device-actions}/**/route.ts` |
| Health, deployment, admin | `app/api/{health,ready,deployment,admin,reset,dev}/**/route.ts` |
| Admin-token sessions | `app/api/auth/admin-token/{login,logout,status}/route.ts` |
