# 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 <token>`. 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: "<etag>"`
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.** Real abuse on admin endpoints is
rare — the audience is humans plus the BFF, both authenticated with
tokens that can be revoked. Add a limit if you measure a problem. The
internal `/internal/jobs/*` API is also unrated; it sits behind a
network gate (RFC1918 only) plus a bearer token.

**Login throttling** (separate concern, lives in the UI): the
local-admin sign-in is gated by an in-process `LoginThrottle`. Failures
are bucketed by `(username, source_ip)` so an attacker spraying one IP
can't lock out a legitimate admin from another. Progression: 5 fails
→ 60 s lockout, 10 → 300 s, 15+ → 1800 s. A successful login resets
the bucket. Restart the `ui` container to clear all locks.

See [`security.md`](./security.md) for the full rate-limit and
brute-force-protection posture.

### 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=<token row 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`).
