# API overview > Audience: integrators (firewalls, fail2ban-style agents, monitoring) and > frontend authors. The OpenAPI document at `/api/v1/openapi.yaml` is the > source of truth for endpoint shapes; this file covers the surrounding > conventions OpenAPI doesn't express. ## Base URL & versioning Default compose deployment: **`http://localhost:8081`**. Production is typically behind a reverse proxy at `https://reputation-api.example.com`. Single major version `v1`. The contract is **additive-only within `v1`**: - new endpoints may be added at any time - new optional query parameters may be added - new optional fields may appear in responses - existing fields will not change shape, type, or semantics - breaking changes ship as a new major (`v2`) at a different path prefix Don't pin to undocumented behaviour (e.g. response field ordering); pin to the documented schema in OpenAPI. ## Authentication Every endpoint takes `Authorization: Bearer `. There are four token kinds, distinguishable by their prefix: | Token format | Kind | What it can do | |-------------------------|-------------|-----------------------------------------------------------------------------| | `irdb_rep_<32 base32>` | `reporter` | `POST /api/v1/report` only. Bound to a reporter row. | | `irdb_con_<32 base32>` | `consumer` | `GET /api/v1/blocklist` only. Bound to a consumer row. | | `irdb_adm_<32 base32>` | `admin` | All `/api/v1/admin/*` as itself, with a configured role. For automation. | | `irdb_svc_<32 base32>` | `service` | UI BFF only. Combined with `X-Acting-User-Id`, executes as that user. | Tokens are persisted as their SHA-256 hash; the raw value is shown **once** on creation and never echoed again. Revoke + re-issue if it leaks. Failure modes return a uniform `401 unauthorized` envelope — bad token, revoked token, expired token, wrong-kind token all look identical to the caller. Authorization failures (authenticated but RBAC-denied) return `403`. See [`auth-flows.md`](./auth-flows.md) for the full picture of how each flow plays out. ## Endpoint groups | Group | Path prefix | Audience | Documented in OpenAPI | |---------------|------------------------------|-----------------------------------|-----------------------| | Public | `/api/v1/report`, `/api/v1/blocklist` | machine clients | yes | | Admin | `/api/v1/admin/*` | UI BFF + admin-kind tokens | yes | | Auth | `/api/v1/auth/*` | UI BFF only | yes (`x-internal: true`) | | Internal jobs | `/internal/jobs/*` | scheduler | **no** — private contract | The internal jobs surface is bound to RFC1918 + loopback in the api's Caddyfile and authenticated with a dedicated `INTERNAL_JOB_TOKEN`. External callers get `404`. The admin proxy at `POST /api/v1/admin/jobs/trigger/{name}` is the public path for authorized human operators to invoke the same logic. ## Common conventions ### JSON shape Bodies are JSON unless explicitly noted (the blocklist endpoint returns `text/plain` by default). Successful responses use the shape the OpenAPI document describes; errors always look like: ```json { "error": "validation_failed", "details": { "ip": "must be a valid IPv4 or IPv6 address" } } ``` `details` is present on `400 validation_failed` only. Other errors (`401`, `403`, `404`, `409`, `412`, `429`, `500`) carry just `error` and sometimes a sibling diagnostic field (`provider` and `missing` for `412 no_credential`, `consumers` for `409 policy_in_use`, etc.). ### Pagination Paginated lists take `page` (1-indexed, default 1) and `page_size` (default 50, max 200). The response carries `{items, page, page_size, total}`. Don't compute total pages from `total / page_size` on the client; do `ceil`. The api returns `items: []` past the last page rather than 404. ### ETags `GET /api/v1/blocklist` returns an `ETag` (SHA-256 over the rendered body, excluding `generated_at`). Re-fetching with `If-None-Match: ""` returns `304 Not Modified` and an empty body. The `ETag` value is content-type-sensitive — `text/plain` and `application/json` produce different ETags for the same logical state. ### Rate limiting Public endpoints are rate-limited at **60 req/s per token** (configurable via `API_RATE_LIMIT_PER_SECOND`). Token-bucket; refill rate equals the configured per-second value, bucket size is `2x` the refill rate. On exhaustion: `429 Too Many Requests` with `Retry-After: 1` (seconds). Admin endpoints aren't rate-limited. ### IP normalization Every IP in a request body or path segment is normalized: - IPv4 like `203.0.113.42` is canonicalised and stored as a 16-byte IPv4-mapped-in-IPv6 binary (`::ffff:203.0.113.42`). v4 input is echoed back as v4 in responses. - IPv6 is canonicalised (`2001:DB8::1` → `2001:db8::1`). - Embedded zone identifiers (`fe80::1%eth0`) are rejected. - `"203.0.113.42 "` (whitespace) is rejected — strip on the client. CIDRs follow the same rules; the network address is canonicalised so `203.0.113.55/24` is silently normalized to `203.0.113.0/24` and the response includes `normalized_from: "203.0.113.55/24"` so the caller can see the change. ### Timestamps All timestamps are UTC, ISO 8601, with `Z` suffix: `2026-04-29T10:00:00Z`. Don't try to send local-time strings. ## Worked examples ### Posting an abuse report ```bash curl -X POST http://localhost:8081/api/v1/report \ -H "Authorization: Bearer $IRDB_REP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "ip": "203.0.113.42", "category": "brute_force", "metadata": {"url": "/wp-login.php", "ua": "Mozilla/5.0 (...)"} }' # 202 Accepted # {"report_id": 17, "ip": "203.0.113.42", "received_at": "2026-04-29T10:00:00Z"} ``` The score for `(203.0.113.42, brute_force)` is updated synchronously in `ip_scores`. Bulk decay is reapplied periodically by the `recompute-scores` job. ### Pulling a blocklist ```bash # Plain text — one IP or CIDR per line. curl http://localhost:8081/api/v1/blocklist \ -H "Authorization: Bearer $IRDB_CON_TOKEN" # 203.0.113.42 # 198.51.100.0/24 # JSON — richer per-entry data. curl 'http://localhost:8081/api/v1/blocklist?format=json' \ -H "Authorization: Bearer $IRDB_CON_TOKEN" # {"count":2,"generated_at":"2026-04-29T10:00:00Z","policy":"moderate","entries":[...]} # ETag round-trip — second request returns 304 if nothing changed. ETAG=$(curl -sI -H "Authorization: Bearer $IRDB_CON_TOKEN" \ http://localhost:8081/api/v1/blocklist | awk '/^etag:/i {print $2}' | tr -d '\r') curl -i -H "Authorization: Bearer $IRDB_CON_TOKEN" \ -H "If-None-Match: $ETAG" \ http://localhost:8081/api/v1/blocklist # HTTP/1.1 304 Not Modified ``` Drop-in shell wrappers for iptables, nginx, and HAProxy are in [`examples/consumers/`](../examples/consumers/). ### Admin: search IPs (via service-token impersonation) This is what the UI BFF does on every admin page. You'd only do this yourself if you're building a replacement BFF. ```bash curl 'http://localhost:8081/api/v1/admin/ips?q=203.0.113&page=1&page_size=25' \ -H "Authorization: Bearer $UI_SERVICE_TOKEN" \ -H "X-Acting-User-Id: 7" # {"items": [...], "page": 1, "page_size": 25, "total": 42} ``` The api validates the service token, looks up user `7`, applies that user's role to the request, and writes any audit row as `actor_kind=user, actor_id=7` (never the service token). ### Admin: search IPs (via admin-kind token) Direct path for automation that doesn't go through a BFF. ```bash curl 'http://localhost:8081/api/v1/admin/ips?q=203.0.113' \ -H "Authorization: Bearer $IRDB_ADM_TOKEN" # Same response shape; no impersonation header. ``` The audit row records `actor_kind=admin-token, actor_id=`. ## OpenAPI Canonical reference for endpoint shapes: - **Viewer**: `http://localhost:8081/api/docs` (RapiDoc) - **YAML**: `http://localhost:8081/api/v1/openapi.yaml` If anything in this document conflicts with the OpenAPI spec, the OpenAPI spec wins — file an issue against this doc. Discrepancies between docs and code are a hard CI failure (`scripts/check-doc-endpoints.sh`).