# API reference

The OpenAPI document at **[`/api/v1/openapi.yaml`](http://localhost:8081/api/v1/openapi.yaml)**
is canonical for endpoint shapes, request bodies, response schemas,
and error envelopes. Browse it interactively at
**[`/api/docs`](http://localhost:8081/api/docs)** (RapiDoc).

This file documents the bits OpenAPI doesn't cleanly express.

## Rate-limit headers

Public endpoints return `429 Too Many Requests` when the per-token
bucket is exhausted, with:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json

{"error":"rate_limited"}
```

`Retry-After` is always `1` (second) — the bucket refills at
`API_RATE_LIMIT_PER_SECOND` per second, so 1 s of wait restores at
least one token. Don't sleep longer than that on a 429; just back off
once and try again.

Admin endpoints aren't rate-limited; the UI's request volume is
bounded by human interaction.

## ETag semantics

`GET /api/v1/blocklist` is the only endpoint that serves ETags today.
Rules:

- The ETag is `SHA-256` over the rendered body, **excluding**
  `generated_at` (which changes every cache rebuild).
- Different `format=` values yield different ETags by design — the
  bytes differ, so the validator differs.
- The api accepts both strong (`"abc"`) and weak (`W/"abc"`) forms in
  `If-None-Match`, and the wildcard `*`.
- A 304 response carries no body and copies the `ETag` and the
  `X-Blocklist-*` headers from the would-be 200.

The cache TTL is `BLOCKLIST_CACHE_TTL_SECONDS` (default 30 s) per
policy. Mutations to `policies`, `manual_blocks`, or `allowlist`
invalidate the affected cache entries; cross-replica visibility lags
by up to that TTL when running multiple api replicas.

## Impersonation header

The api accepts `X-Acting-User-Id: <integer>` **only** in combination
with a `service` token. On any other token kind, the header is
silently ignored (it's not an error to send it; the api just doesn't
use it).

| Auth combination                                  | Result                          |
|---------------------------------------------------|---------------------------------|
| Service token, header present, user exists       | RBAC applied for the user; audited as `actor_kind=user, actor_id=<header>` |
| Service token, header missing                    | `400 Bad Request`               |
| Service token, header malformed (non-integer)    | `400 Bad Request`               |
| Service token, header points at unknown user     | `404 Not Found`                 |
| Service token, user is disabled                  | `403 Forbidden`                 |
| Admin/reporter/consumer token, header present     | header ignored; audited normally |
| Admin/reporter/consumer token, header missing    | normal path                     |

The Auth API (`/api/v1/auth/*`) is service-token-only and **does not
take the impersonation header** — those endpoints exist to *produce*
the user record the BFF would later impersonate. Sending the header
there is silently ignored.

## Response envelopes

Most endpoints return a domain object directly (`Reporter`,
`Consumer`, `Token`, etc.) for single-resource GETs and POSTs.
Paginated lists use `{items, page, page_size, total}`. There is no
top-level `data` wrapper today.

Reasonable assumption for **future** batched endpoints (not yet
built): a `meta` envelope alongside `items` would be added rather than
re-shaping existing responses. The contract is additive; we won't
introduce `{data: ...}` and break every existing client.

Token creation has one envelope variation: it returns the normal
`Token` shape **plus** a `raw_token` field that's the only place the
raw value ever appears. Audit and list endpoints never carry
`raw_token`. After the create response, the raw value can't be
recovered — only revoked + re-issued.

## Date formats

Every timestamp is UTC, ISO 8601 with `Z` suffix:
`2026-04-29T10:00:00Z`. Inputs accept anything PHP's `DateTimeImmutable`
parses (so `2026-04-29T10:00:00+00:00` works too), but outputs are
canonicalised. Microsecond precision is **not** guaranteed in `v1`;
the api stores `DATETIME(6)` on MySQL but only emits seconds.

## IP and CIDR formats

- IPv4: dotted quad. Trailing whitespace is rejected; embedded
  whitespace is rejected. Stored as 16-byte binary
  (IPv4-mapped-in-IPv6 prefix `::ffff:0:0/96`).
- IPv6: any RFC-4291 form. Canonicalised on output (no leading zeros,
  longest run of zeros compressed once). Zone identifiers
  (`fe80::1%eth0`) are rejected.
- CIDRs: network address + prefix. Non-canonical input is silently
  normalized, with `normalized_from` echoed on the response when the
  normalization changed the input.

URL path segments take an IP directly (`/api/v1/admin/ips/203.0.113.42`,
`/api/v1/admin/ips/2001:db8::1`). Slim's default segment regex
disallows colons; the api uses `/{ip:.+}` to allow IPv6 paths.

## Audit log shape

The on-disk schema column names are `target_type` and `target_id`;
the API surfaces them under the brief's vocabulary
**`entity_type`** and **`entity_id`** for clarity. Filter parameters
use the API names too. If you're inspecting the database directly,
remember the renaming.

`actor_kind` enum: `user`, `admin-token`, `reporter`, `consumer`,
`system`. `actor_id` is a string in the API for forward-compat (token
ids, reporter ids, user ids could diverge in shape over time); cast on
the client.

Audit emission failures are logged but never propagate. A successful
state-changing call always commits; the audit row is best-effort. If
you observe a state change without a corresponding audit row, check
the api's structured log for `audit_emit_failed`.

## Job triggering

`POST /api/v1/admin/jobs/trigger/{name}` whitelists the request body
to:

```json
{ "full": true, "max_rows": 10000, "reenrich": true }
```

Other keys are silently dropped. The intent is to prevent admins from
smuggling config-shaped values into the runner via shape mismatch.

The `refresh-geoip` job short-circuits with `412 no_credential` when
the configured provider is opt-in (MaxMind / IPinfo) and its
credential isn't set. The DB-IP default never returns 412.

```json
{
  "error": "no_credential",
  "provider": "maxmind",
  "missing": "MAXMIND_LICENSE_KEY"
}
```

## See also

- [`api-overview.md`](./api-overview.md) — base URL, auth summary, conventions, worked examples.
- [`auth-flows.md`](./auth-flows.md) — every auth path in detail.
- [`architecture.md`](./architecture.md) — system overview.
- [`frontend-development.md`](./frontend-development.md) — for replacement UIs.
