Audience: integrators (firewalls, fail2ban-style agents, monitoring) and frontend authors. The OpenAPI document at
/api/v1/openapi.yamlis the source of truth for endpoint shapes; this file covers the surrounding conventions OpenAPI doesn't express.
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:
v2) at a different path prefixDon't pin to undocumented behaviour (e.g. response field ordering); pin to the documented schema in OpenAPI.
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 for the full picture of how each
flow plays out.
| 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.
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:
{ "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.).
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.
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.
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.
Every IP in a request body or path segment is normalized:
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.2001:DB8::1 → 2001:db8::1).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.
All timestamps are UTC, ISO 8601, with Z suffix:
2026-04-29T10:00:00Z. Don't try to send local-time strings.
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.
# 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/.
This is what the UI BFF does on every admin page. You'd only do this yourself if you're building a replacement BFF.
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).
Direct path for automation that doesn't go through a BFF.
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>.
Canonical reference for endpoint shapes:
http://localhost:8081/api/docs (RapiDoc)http://localhost:8081/api/v1/openapi.yamlIf 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).