Skip to content

REST API reference

Base URL: /api/v1 · Auth: Authorization: Bearer <jwt> · Errors: {"error": "..."}

Authentication

POST /auth/login — public

curl -s -X POST localhost:8420/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username": "admin", "password": "secret"}'
{"access_token": "eyJhbG...", "token_type": "bearer", "username": "admin", "role": "admin"}

Token: JWT HS256, 60-minute expiry, signed with FESTIN_JWT_SECRET.

POST /auth/register — semi-public

Situation Result
Database has 0 users (no header needed) 201 — user created with role admin
Authenticated admin registers someone 201 — role viewer
Authenticated viewer tries 403
Anonymous with populated database 401
{"id": 2, "username": "alice", "role": "viewer"}

Health

GET /health — public

{"status": "ok", "pending": 2}

Stats

GET /stats

{
  "scan_count": 13,
  "findings": {"total": 2, "critical": 1, "high": 1, "medium": 0, "low": 0},
  "recent_scans": [
    {"day": "2026-09-07", "scans": 11, "buckets": 0, "findings": 0, "critical": 0, "high": 0},
    {"day": "2026-09-06", "scans": 1, "buckets": 3, "findings": 2, "critical": 1, "high": 1}
  ]
}

recent_scans covers the last 14 active days (UTC).

Projects

Method Path Auth Notes
GET /projects any list with domain_count, scan_count, findings_count, last_scan_at
POST /projects admin body {"name": "...", "description": "..."}; duplicate name → 409
GET /projects/{id} any {"project": {...counts}, "domains": [...]}
PATCH /projects/{id} admin partial name/description
DELETE /projects/{id} admin cascades domains, scans, findings, buckets; project 1 (default) → 400
POST /projects/{id}/domains admin {"domain_name": "example.com"}; global duplicate → 409
curl -s -X POST localhost:8420/api/v1/projects \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name": "prod-assets", "description": "Public origins"}'

Domains

Method Path Auth Notes
GET /domains?project_id={id} any filtered list
DELETE /domains/{id} admin cascades its scans/findings/buckets

Scans

Method Path Auth Notes
GET /scans?project_id=&status=&limit= any joined rows: domain_name, project_name
GET /scans/{id} any full bundle: {"scan": {...}, "findings": [...], "buckets": [...]}
DELETE /scans/{id} any deletes scan + its findings/buckets
POST /scans/run-scan any see below

POST /scans/run-scan

curl -s -X POST localhost:8420/api/v1/scans/run-scan \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"domains": ["example.com"], "project_id": 2}'

Response 202:

{"scan_id": 14, "job_id": "job-1725700000000", "status": "accepted"}

The scan record is created immediately (status pendingrunningcompleted/failed) and executed as a background task; buckets and findings are persisted when it finishes. project_id defaults to 1.

Findings & buckets

Method Path Auth Notes
GET /findings?severity=&limit= any keys: bucket, object, rule, severity, line, match (redacted)
GET /buckets?limit= any keys: name, objects_count, scan_id

Scheduled scans

Method Path Auth Notes
GET /queues/schedule any key is scheduled (frozen contract)
POST /queues/schedule admin {"domain": "example.com", "interval_minutes": 60}
DELETE /queues/schedule/{id} admin

Users (admin only)

Method Path Notes
GET /users {"users": [{"id", "username", "role"}]}
POST /users {"username", "password", "role": "viewer"\|"admin"}
PATCH /users/{id} {"role": "admin"}
DELETE /users/{id} 400 if deleting yourself or the last admin

Status codes

Code Meaning
200 OK
201 Created
202 Scan accepted (async)
400 Validation (bad body, deleting default project / self / last admin)
401 Missing or invalid token
403 Authenticated but not admin
404 Unknown id
409 Duplicate (project or domain name)