Platform API — Reference
Base URL: /api/v1. All responses are JSON (Content-Type: application/json)
— a JSON object for a single resource, a JSON array for a collection (paginated
with limit/offset). Nothing is streamed or JSONL. Times are UTC ISO 8601.
Machine-readable contract. The server publishes a live OpenAPI 3 schema — use it to generate typed clients and validate payloads:
- Swagger UI:
/docs· ReDoc:/redoc· raw spec:/openapi.jsonThe examples and the "Data shapes & enums" section below mirror that schema.
Authentication (three keys)
Every request sends the master and admin keys as headers. Org-scoped requests additionally send the org key.
| Header | Required on | Meaning |
|---|---|---|
X-Master-Key | every request | global platform secret (server config) |
X-Admin-Key | every request | operator / UI-backend identity |
X-Org-Key | org-scoped requests | per-org key; selects the org |
Rules:
- A key is never placed in a URL. The org is selected by the
X-Org-Keyheader; admin routes address an org by the caller's owncustomer_id. - Keys are shown once at creation and stored only as salted hashes.
- Missing a required header →
422. Wrong master →401(or503if the server has no master key configured). Wrong admin/org key →401. - Rate limit:
300requests/minute per admin key →429when exceeded.
Errors
Standard FastAPI error envelope: {"detail": "<message>"} with the HTTP status
code. Common codes: 401 (bad key), 403 (module not enabled), 404 (not
found), 409 (duplicate customer_id), 422 (missing/invalid input), 429
(rate limited).
Admin endpoints (master + admin)
These manage admin keys, orgs, and the audit log. Orgs are addressed by your own
customer_id, never the platform UUID.
Admin keys (master key only)
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /admin-keys | {"name": "ui-backend"} | 201 admin key (plaintext, once) |
| GET | /admin-keys | — | list (prefixes only) |
| DELETE | /admin-keys/{id} | — | 204 revoke |
Audit (master key only)
| Method | Path | Query | Returns |
|---|---|---|---|
| GET | /audit | limit (1–1000) | recent mutating calls; key prefixes only |
Orgs
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /orgs | {customer_name, customer_id, domains?, modules?} | 201 org + org key (once) |
| GET | /orgs | — | list of orgs |
| GET | /orgs/{customer_id} | — | org detail (no key) |
| POST | /orgs/{customer_id}/rotate-key | — | new org key (once); old key stops working |
POST /orgs request:
{
"customer_name": "Acme Corp",
"customer_id": "CUST-100",
"domains": ["acme.com", "www.acme.com"],
"modules": ["easm"]
}
201 response (the only time the key is shown):
{
"id": "05a5e76e-05a3-4dd0-a20c-2f8f5a21352c",
"customer_id": "CUST-100",
"customer_name": "Acme Corp",
"slug": "cust_100",
"modules_enabled": ["easm"],
"is_active": true,
"api_key_prefix": "org_oGn5cmw8",
"created_at": "2026-06-09T08:00:00Z",
"org_api_key": "org_oGn5cmw8UOM8udxKjCLl4ezpODRgIo4xzjLLurWHIKc"
}
Org endpoints (master + admin + org key)
No id in the path — the X-Org-Key header selects the org. Resource ids
(domain_id, scan_id, finding_id) are non-secret UUIDs scoped to the org.
Self
| Method | Path | Returns |
|---|---|---|
| GET | /org | the authenticated org (no key) |
Domains
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /org/domains | — | list of {id, value, kind} |
| POST | /org/domains | {"domains": ["a.com","b.com"]} | 201 full domain list |
| PATCH | /org/domains/{domain_id} | {"value": "c.com"} | updated domain (value normalized to lowercase) |
| DELETE | /org/domains/{domain_id} | — | 204 |
Scans
| Method | Path | Body | Returns |
|---|---|---|---|
| POST | /org/scans | {"module":"easm","scanners":[],"params":{},"force":false} | 202 scan job (status pending) · 429/409 see below |
| GET | /org/scans | — | scan history (newest first) |
| GET | /org/scans/{scan_id} | — | full job: status, runs, stats |
| POST | /org/scans/{scan_id}/stop | — | cooperative cancel |
| GET | /org/scans/{scan_id}/progress | — | live progress |
| GET | /org/scans/{scan_id}/delta | — | reconciliation delta |
| GET | /org/scans/{scan_id}/feedback-validation | — | feedback outcome stats |
scanners empty = the full EASM pipeline. module (default "easm") must be in
the org's modules_enabled (else 403). params accepts scan options, e.g.
{"ua_mode": "browser"}. force (bool, default false) bypasses the scan
cooldown (see below); the bypass is audited.
Scan cooldown
To prevent accidental re-scanning, only one scan per (org, module) per
interval is allowed (default 14 days). EASM and DAST keep independent
clocks — running an EASM scan does not start the DAST cooldown, and vice versa.
The interval is configurable per org via the scan_interval_days setting
(default 14). Failed and cancelled scans do not count toward the cooldown,
and force: true bypasses it (the bypass is recorded in the audit log).
POST /org/scans request:
{ "module": "easm", "scanners": [], "params": {}, "force": false }
202 Accepted — queued (status pending):
{ "id": "07075497-...", "status": "pending", "scope_id": "...", "runs": [], "created_at": "..." }
429 — a scan for this module ran within the interval (still in cooldown):
{ "error": "scan cooldown active", "module": "easm",
"next_scan_allowed_at": "2026-06-24T08:00:00Z" }
409 Conflict — a scan for the same module is already in flight (pending or
running). Wait for it to finish (or stop it) before starting another.
GET /org/scans/{scan_id}/progress:
{
"scan_id": "07075497-...",
"status": "running",
"cancel_requested": false,
"stages_total": 11,
"stages_done": 6,
"current": "easm.httpx",
"assets": 1240,
"findings": 14,
"phase": "running"
}
POST /org/scans/{scan_id}/stop:
{ "scan_id": "07075497-...", "status": "running", "cancel_requested": true }
A pending job is cancelled immediately; a running job is flagged and stops at
the next stage boundary (the in-flight tool finishes its step), then reports
status: "cancelled".
GET /org/scans/{scan_id}/feedback-validation:
{
"scan_id": "07075497-...",
"confirmed_fixed": 8,
"regressed": 2,
"newly_found": 3,
"auto_resolved": 1,
"still_open": 12,
"accepted_risk": 4,
"false_positive": 2,
"status_breakdown": {"new": 9, "in_progress": 3, "resolved": 1, "...": 0}
}
Findings + feedback
| Method | Path | Query / Body | Returns |
|---|---|---|---|
| GET | /org/findings | severity, status, finding_class, include_inventory, include_duplicates, limit, offset | findings (newest first) |
| GET | /org/findings/{finding_id} | — | one finding |
| GET | /org/findings/{finding_id}/history | — | event timeline |
| PATCH | /org/findings/{finding_id} | {status, note?, actor?, suppress_similar?, suppress_match?} | updated finding |
| GET | /org/tasks | status, priority, limit, offset | remediation tasks |
| GET | /org/activity | action, actor_kind, target_type, outcome, limit, offset | the org's activity / audit trail (who/what/when/where/outcome) |
Default GET /org/findings view hides inventory-class detections and
false-positive findings. Each finding carries: title, severity, status,
finding_class, description, risk, remediation_steps (4), references,
cve_id/cwe_id/cvss_score, severity_reason (provenance), and evidence.
Feedback PATCH /org/findings/{finding_id}:
{ "status": "resolved", "note": "patched to 2.4.58", "actor": "customer", "suppress_similar": false }
Valid status values for customer feedback: in_progress, resolved,
accepted_risk, false_positive. With suppress_similar: true on
false_positive/accepted_risk, a rule is stored so matching findings stay
suppressed on future scans (suppress_match: template_id | cve | category
| title).
Data shapes & enums (for the UI)
Allowed enum values the UI should switch on:
| Enum | Values |
|---|---|
severity | info, low, medium, high, critical |
finding status | new, in_progress, resolved, confirmed_resolved, reopened, accepted_risk, false_positive (legacy: open, triaged) |
finding_class | inventory, expected_exposure, should_not_be_public, issue |
scan status | pending, running, partial, completed, failed, cancelled |
progress phase | running, done, cancelled |
task status | open, in_progress, blocked, done, wont_do |
task priority | low, medium, high, urgent |
domain kind | domain, ip_range, asn, keyword |
Finding object (full shape)
{
"id": "uuid",
"dedup_key": "string",
"title": "string",
"description": "string | null",
"severity": "info|low|medium|high|critical",
"source_severity": "string | null",
"severity_reason": ["base:medium (source)", "↑ EPSS 0.94"],
"status": "new|in_progress|resolved|confirmed_resolved|reopened|accepted_risk|false_positive",
"finding_class": "inventory|expected_exposure|should_not_be_public|issue",
"category": "string | null",
"risk": "string | null",
"remediation": "string | null",
"remediation_steps": ["step 1", "step 2", "step 3", "step 4"],
"cve_id": "CVE-2021-40438 | null",
"cwe_id": "CWE-200 | null",
"cvss_score": 9.0,
"references": ["https://..."],
"evidence": { "matched_at": "https://...", "port": 443, "...": "tool evidence" },
"asset_id": "uuid | null",
"scope_id": "uuid | null",
"first_seen": "2026-06-09T08:00:00Z",
"last_seen": "2026-06-09T08:00:00Z"
}
Scan job object (GET /org/scans/{scan_id})
{
"id": "uuid",
"scope_id": "uuid",
"status": "pending|running|partial|completed|failed|cancelled",
"requested_scanners": [],
"params": { "module": "easm" },
"stats": {
"assets": 1240, "findings": 14,
"progress": { "stages_total": 11, "stages_done": 11, "current": null,
"assets": 1240, "findings": 14, "phase": "done" },
"finalize": { "delta": { "new": 9, "reopened": 2, "confirmed_resolved": 8,
"still_open": 12, "auto_resolved": 1 } }
},
"error": null,
"started_at": "2026-06-09T08:00:00Z",
"finished_at": "2026-06-09T08:12:00Z",
"created_at": "2026-06-09T08:00:00Z",
"runs": [
{ "scanner_key": "easm.subfinder", "scanner_type": "oss_wrapper",
"status": "completed", "raw_count": 2523, "asset_count": 2523,
"finding_count": 0, "error": null,
"started_at": "2026-06-09T08:00:05Z", "finished_at": "2026-06-09T08:02:10Z" }
]
}
Scan delta (GET /org/scans/{scan_id}/delta)
{ "new": 9, "reopened": 2, "confirmed_resolved": 8, "still_open": 12, "auto_resolved": 1 }
Domains list (GET /org/domains)
[ { "id": "uuid", "value": "acme.com", "kind": "domain" },
{ "id": "uuid", "value": "www.acme.com", "kind": "domain" } ]
Task object (GET /org/tasks)
[ { "id": "uuid", "title": "Remediate: End-of-Life Apache HTTP Server",
"description": "string | null", "status": "open",
"priority": "low|medium|high|urgent", "assignee": null, "due_date": null,
"finding_id": "uuid | null", "created_at": "...", "updated_at": "..." } ]
Finding history (GET /org/findings/{id}/history)
[ { "id": "uuid", "event": "triaged", "old_status": "new",
"new_status": "resolved", "actor": "customer", "note": "patched",
"created_at": "2026-06-09T08:30:00Z" } ]
Admin key list (GET /admin-keys) and audit (GET /audit)
// GET /admin-keys (prefixes only)
[ { "id": "uuid", "name": "ui-backend", "key_prefix": "adm_AkfdbuOu",
"active": true, "created_at": "..." } ]
// GET /audit (key prefixes only, never full keys)
[ { "id": "uuid", "method": "POST", "path": "/api/v1/org/scans",
"status_code": 202, "admin_prefix": "adm_AkfdbuOu",
"org_prefix": "org_oGn5cmw8", "ip": "203.0.113.5", "created_at": "..." } ]
Error envelope (any non-2xx)
{ "detail": "Invalid org key" }