API keys

Create a key in the app under Team → API keys (/app/team). Owners and admins can create and revoke keys, up to 10 per organisation. The key is shown once; we store only a hash, so a lost key is revoked and replaced, not recovered. Keys belong to the organisation, not to a person, and are read-only. If the organisation moves below Growth, requests return 403 plan_required but the keys are kept, and work again after upgrading.

Authentication and base URL

Send the key as a Bearer token over HTTPS. Keys look like afs_live_ followed by 43 characters.

curl -H "Authorization: Bearer $AFS_API_KEY" \
  "$AFS_API_BASE/findings"

$AFS_API_BASE is the base URL shown in the app's API keys card. It has the form https://<api-id>.execute-api.us-east-1.amazonaws.com/prod/v1. Every response is scoped to the key's organisation.

Endpoints

MethodPathQuery parametersReturns
GET/v1/findingshost, severity, module, kev=true, limit (default 500, max 1000), cursorCurrent findings across every host.
GET/v1/assetstype, state, domainThe asset inventory.
GET/v1/changesdays (1 to 365, default 30), importance, target, limit (default 200, max 1000)The change feed, newest first.
GET/v1/scans/{id}One scan in full, with framework references on every finding.
GET/v1/domainsDomains with verification status, monitored hosts and settings.

The API is read-only: every endpoint is GET. Scans are started, domains added and findings triaged from the app or the MCP server.

Response shapes

// GET /v1/findings
{
  "items": [
    {
      "host": "shop.example.com",
      "scanId": "…",
      "scannedAt": "2026-09-28T06:02:11.000Z",
      "id": "vulns.cve-2023-44487",
      "module": "vulns",
      "severity": "critical",
      "title": "…",
      "description": "…",
      "remediation": "…",
      "evidence": "…",
      "reference": "…",
      "tags": ["CISA-KEV", "EPSS:0.94", "CWE-400"],
      "triage": null,
      "frameworks": [{ "framework": "owasp", "id": "A06" }]
    }
  ],
  "total": 1,
  "nextCursor": null,
  "hostsAssessed": 12,
  "hostsNotYetScanned": 0
}

// GET /v1/changes
{ "items": [{ "changeId", "detectedAt", "target", "kind", "importance",
              "title", "detail", "before", "after", "scanId" }], "days": 30 }

// GET /v1/assets
{ "items": [ … ], "total": 57 }

// GET /v1/domains
{ "items": [{ "domain", "status", "verifiedBy", "createdAt", "verifiedAt",
              "client", "hosts": [{ "host", "source" }], "settings" }] }

/v1/findings is paginated: pass nextCursor back as cursor until it is null. hostsNotYetScanned counts monitored hosts with no completed scan yet, so an empty list is never mistaken for a clean estate.

Examples

Findings on the CISA KEV list

curl -s -H "Authorization: Bearer $AFS_API_KEY" \
  "$AFS_API_BASE/findings?kev=true" \
  | jq '.items[] | {host, id, severity, title}'

Critical changes in the last 7 days

curl -s -H "Authorization: Bearer $AFS_API_KEY" \
  "$AFS_API_BASE/changes?days=7&importance=critical"

Every finding, following the cursor

cursor=""
while :; do
  page=$(curl -s -H "Authorization: Bearer $AFS_API_KEY" \
    "$AFS_API_BASE/findings?limit=1000${cursor:+&cursor=$cursor}")
  echo "$page" | jq -c '.items[]'
  cursor=$(echo "$page" | jq -r '.nextCursor // empty')
  [ -z "$cursor" ] && break
done

Rate limits

120 requests per minute per key and 600 per minute per organisation. Over the limit, the API returns 429 with a Retry-After header in seconds.

Errors

Errors share one shape:

{ "error": { "code": "plan_required", "message": "…" } }
StatusCodeMeaning
401unauthorizedMissing, malformed or revoked key.
403plan_requiredThe organisation's plan does not include the REST API.
404not_foundNot found, or not in this organisation (the two are indistinguishable by design).
405method_not_allowedAnything other than GET.
429rate_limitedRate limited. Wait for Retry-After and retry.
500internalOur fault. Retry with backoff.