Skip to main content

REST API reference

The API-key-authenticated REST surface for scripts, CI/CD pipelines, and custom integrations. To drive scans from an AI assistant instead, see the MCP setup guide.

Base URL

Every endpoint below is relative to this base - e.g. POST /v1/scans/trigger means POST https://api.dev.flawpilot.com/v1/scans/trigger.

Base URL
https://api.dev.flawpilot.com

Authentication

Every endpoint except the two health checks and the scan-status poll requires a Bearer API key. Create and manage keys from the dashboard under Settings → API Keys. A key identifies a workspace (tenant) - there is no per-user auth on this surface, and the same key works for the MCP server too.

Authorization header
Authorization: Bearer YOUR_API_KEY

Missing or invalid keys get:

401 Unauthorized
{ "statusCode": 401, "message": "Unauthorized" }

Rate limits

ScopeLimitApplies to
Per-tenant, general60 req / minEvery authenticated endpoint, combined
Per-tenant, trigger scan5 req / minPOST /v1/scans/trigger only (on top of the general limit)
Per-IP, scan status2000 req / minGET /v1/scans/:id/status (unauthenticated, limited by IP)

Exceeding a limit returns 429 with a Retry-After header (seconds until the window resets):

429 Too Many Requests
{ "error": "Too many requests. Please retry shortly." }

Error shape

Authenticated and validated endpoints that fail return a consistent shape. There is no separate machine-readable error code - match on statusCode plus, if needed, the exact message text documented per endpoint.

Error response
{ "statusCode": 400, "message": "<human-readable message>" }
Known limitation

A few scan-creation failures from the scanning backend (e.g. the target domain failing DNS resolution, or the backend's own rate limit) are not yet mapped to their intended status code and currently surface as a generic 500. The endpoint docs below list intended-but-not-yet-guaranteed cases separately from confirmed ones.

Health checks

GET /ready

Readiness probe - checks the database and Redis are reachable (1s timeout each).

200 - both checks pass
{ "status": "ready" }

List projects

GET /v1/projects

Lists the tenant's projects. Use this to discover a projectId before triggering a scan. Auth required; general tenant rate limit only.

Query parameters

ParamTypeDefaultDescription
includeArchivedboolean (string "true")falseInclude archived projects in the results.

Response - 200

200 OK
[
{ "projectId": "PRJ-3TV1T", "name": "Marketing Site", "status": "ACTIVE" }
]
FieldTypeDescription
projectIdstringShort, stable identifier - pass to trigger_scan. Not a DB ID.
namestringProject display name.
statusstringACTIVE or ARCHIVED.

Example

curl
curl https://api.dev.flawpilot.com/v1/projects \
-H "Authorization: Bearer YOUR_API_KEY"

Trigger a scan

POST /v1/scans/trigger

Queues a scan for a URL against one of the tenant's projects. Auth required; general limit and the stricter 5/min trigger-scan limit.

Request body

FieldTypeRequiredNotes
projectIdstringYesFrom GET /v1/projects. null is rejected the same as omitting it.
urlstringYesMust be http:// or https:// and include the protocol. null is rejected the same as omitting it.
pillarsarray of SECURITY | PERFORMANCE | INFRASTRUCTURE | SEONoOmitted, null, or [] = full scan across all four pillars.
emailstring (valid email)NoSends the report here on completion. To skip it, omit the field or send null or "" - all three are equivalent. Does not accept the literal "skip".

With an email

Request body
{
"projectId": "PRJ-3TV1T",
"url": "https://example.com",
"pillars": ["SECURITY"],
"email": "jane@example.com"
}

Skipping the email - these three are equivalent:

email: null
{ "projectId": "PRJ-3TV1T", "url": "https://example.com", "email": null }
email: empty string
{ "projectId": "PRJ-3TV1T", "url": "https://example.com", "email": "" }
email omitted
{ "projectId": "PRJ-3TV1T", "url": "https://example.com" }

Example

curl
curl -X POST https://api.dev.flawpilot.com/v1/scans/trigger \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"projectId": "PRJ-XXXXX",
"url": "https://example.com",
"pillars": ["SECURITY"],
"email": "jane@example.com"
}'

Response - 202 Accepted

202 Accepted
{ "scanJobId": "b3f1...", "status": "QUEUED", "queuedAt": "2026-07-27T10:00:00.000Z" }

Errors (confirmed)

StatusCause
400Request body failed validation (missing/invalid projectId, url, pillars, or email).
400"This project is archived and cannot be modified!"
404"Project not found!" - projectId doesn't exist or belongs to another tenant.
429Tenant or trigger-scan-specific limit exceeded.

Errors (intended, not yet guaranteed)

StatusCause
400Target domain isn't DNS-resolvable ("We couldn't reach that site…").
429The scanning backend's own internal rate limit ("Scan limit reached…").

Scan status

GET /v1/scans/:id/status

Poll scan progress. Public - no API key required; rate-limited per IP. Responses are cached a few seconds, so polling every ~5s is fine.

Response - 200 (non-terminal)

200 OK - RUNNING
{
"status": "RUNNING",
"completed": 8,
"total": 20,
"startedAt": "2026-07-27T10:00:05.000Z",
"url": "https://example.com",
"email": "jane@example.com",
"categories": [
{
"pillar": "SECURITY",
"title": "Security",
"score": 62.5,
"max_score": 100,
"band": "ELEVATED_RISK",
"results": [ /* subcategory scores */ ]
}
]
}

Once status reaches a terminal value (COMPLETE, PARTIAL, or FAILED), the response also includes:

FieldDescription
completedAtISO-8601 completion time.
bandPRODUCTION_READY | GROWTH_READY | ELEVATED_RISK | CRITICAL_RISK - see Risk bands.
score_total, score_security, score_performance, score_infrastructure, score_seoNumeric scores.
shareToken / shortCodeSame value, two names. Treat as a bearer credential - anyone holding it can view the public report.
slugPresent if a human-readable slug was generated for the share URL.

Errors

StatusCause
404"Scan not found!" - id doesn't correspond to any scan.