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
| Method | Path | Query parameters | Returns |
|---|---|---|---|
GET | /v1/findings | host, severity, module, kev=true, limit (default 500, max 1000), cursor | Current findings across every host. |
GET | /v1/assets | type, state, domain | The asset inventory. |
GET | /v1/changes | days (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/domains | Domains 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": "…" } }
| Status | Code | Meaning |
|---|---|---|
401 | unauthorized | Missing, malformed or revoked key. |
403 | plan_required | The organisation's plan does not include the REST API. |
404 | not_found | Not found, or not in this organisation (the two are indistinguishable by design). |
405 | method_not_allowed | Anything other than GET. |
429 | rate_limited | Rate limited. Wait for Retry-After and retry. |
500 | internal | Our fault. Retry with backoff. |