The OpenAPI document at /api/v1/openapi.yaml
is canonical for endpoint shapes, request bodies, response schemas,
and error envelopes. Browse it interactively at
/api/docs (RapiDoc).
This file documents the bits OpenAPI doesn't cleanly express.
Public endpoints return 429 Too Many Requests when the per-token
bucket is exhausted, with:
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.
GET /api/v1/blocklist is the only endpoint that serves ETags today.
Rules:
SHA-256 over the rendered body, excluding
generated_at (which changes every cache rebuild).format= values yield different ETags by design — the
bytes differ, so the validator differs."abc") and weak (W/"abc") forms in
If-None-Match, and the wildcard *.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.
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.
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.
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.
::ffff:0:0/96).fe80::1%eth0) are rejected.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.
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.
POST /api/v1/admin/jobs/trigger/{name} whitelists the request body
to:
{ "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.
{
"error": "no_credential",
"provider": "maxmind",
"missing": "MAXMIND_LICENSE_KEY"
}
api-overview.md — base URL, auth summary, conventions, worked examples.auth-flows.md — every auth path in detail.architecture.md — system overview.frontend-development.md — for replacement UIs.