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.
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: Bearer YOUR_API_KEY
Missing or invalid keys get:
{ "statusCode": 401, "message": "Unauthorized" }
Rate limits
| Scope | Limit | Applies to |
|---|---|---|
| Per-tenant, general | 60 req / min | Every authenticated endpoint, combined |
| Per-tenant, trigger scan | 5 req / min | POST /v1/scans/trigger only (on top of the general limit) |
| Per-IP, scan status | 2000 req / min | GET /v1/scans/:id/status (unauthenticated, limited by IP) |
Exceeding a limit returns 429 with a Retry-After header (seconds until the
window resets):
{ "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.
{ "statusCode": 400, "message": "<human-readable message>" }
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).
{ "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
| Param | Type | Default | Description |
|---|---|---|---|
includeArchived | boolean (string "true") | false | Include archived projects in the results. |
Response - 200
[
{ "projectId": "PRJ-3TV1T", "name": "Marketing Site", "status": "ACTIVE" }
]
| Field | Type | Description |
|---|---|---|
projectId | string | Short, stable identifier - pass to trigger_scan. Not a DB ID. |
name | string | Project display name. |
status | string | ACTIVE or ARCHIVED. |
Example
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
| Field | Type | Required | Notes |
|---|---|---|---|
projectId | string | Yes | From GET /v1/projects. null is rejected the same as omitting it. |
url | string | Yes | Must be http:// or https:// and include the protocol. null is rejected the same as omitting it. |
pillars | array of SECURITY | PERFORMANCE | INFRASTRUCTURE | SEO | No | Omitted, null, or [] = full scan across all four pillars. |
email | string (valid email) | No | Sends 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
{
"projectId": "PRJ-3TV1T",
"url": "https://example.com",
"pillars": ["SECURITY"],
"email": "jane@example.com"
}
Skipping the email - these three are equivalent:
{ "projectId": "PRJ-3TV1T", "url": "https://example.com", "email": null }
{ "projectId": "PRJ-3TV1T", "url": "https://example.com", "email": "" }
{ "projectId": "PRJ-3TV1T", "url": "https://example.com" }
Example
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
{ "scanJobId": "b3f1...", "status": "QUEUED", "queuedAt": "2026-07-27T10:00:00.000Z" }
Errors (confirmed)
| Status | Cause |
|---|---|
400 | Request 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. |
429 | Tenant or trigger-scan-specific limit exceeded. |
Errors (intended, not yet guaranteed)
| Status | Cause |
|---|---|
400 | Target domain isn't DNS-resolvable ("We couldn't reach that site…"). |
429 | The 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)
{
"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:
| Field | Description |
|---|---|
completedAt | ISO-8601 completion time. |
band | PRODUCTION_READY | GROWTH_READY | ELEVATED_RISK | CRITICAL_RISK - see Risk bands. |
score_total, score_security, score_performance, score_infrastructure, score_seo | Numeric scores. |
shareToken / shortCode | Same value, two names. Treat as a bearer credential - anyone holding it can view the public report. |
slug | Present if a human-readable slug was generated for the share URL. |
Errors
| Status | Cause |
|---|---|
404 | "Scan not found!" - id doesn't correspond to any scan. |