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.flawpilot.com/v1/scans/trigger.
https://api.flawpilot.comAuthentication
Every endpoint except the two health checks 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_KEYMissing 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-tenant, trigger code scan | 10 req / min | POST /v1/code-scans/trigger only (on top of the general limit) |
| Per-tenant, trigger Site Health | 8 req / HOUR | POST /v1/site-health/trigger only. The one limit on this surface measured per hour rather than per minute - a full-site crawl is far heavier than a single scan. |
| Per-tenant, Site Health reads | 30 req / min | GET /v1/site-health/:id and GET /v1/projects/:projectId/site-health |
| Per-IP, scan status | 2000 req / min | GET /v1/scans/:id/status - limited by IP, not the general tenant limit, since the website’s own progress UI also polls the same underlying service. |
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. The original scan endpoints carry no separate machine-readable code - match on statusCode plus, if needed, the exact message text documented per endpoint. The Code Scan and Site Health endpoints do return a named code, listed below.
{ "statusCode": 400, "message": "<human-readable message>" }500. Endpoint docs below list intended-but-not-yet-guaranteed cases separately from confirmed ones.Named codes - Code Scan and Site Health
| Code | Status | Meaning |
|---|---|---|
| PROJECT_NOT_FOUND | 404 | The shortId does not resolve to a project. |
| PROJECT_ARCHIVED | 400 | The project exists but is archived. |
| INTEGRATION_REPO_NOT_FOUND | 404 | Unknown or foreign integrationRepoId. |
| INTEGRATION_REPO_NOT_ASSIGNED | 400 | The repo is not assigned to a project yet, so there is nothing to scan it against. |
| INTEGRATION_REPO_DISABLED | 400 | The repo is connected but excluded from scanning. |
| INTEGRATION_REPO_NO_BRANCH | 400 | No branch is selected or resolvable. |
| INTEGRATION_REPO_PROJECT_MISMATCH | 400 | The repo moved project between resolution and the trigger. |
| INTEGRATION_REPO_ACCESS_DENIED | 403 | The provider returned 403 - the token is valid but the repo is outside its access list. |
| INTEGRATION_REPO_BRANCH_NOT_FOUND | 404 | The provider has no such branch. |
| TENANT_INTEGRATION_NOT_FOUND | 404 | The connection row itself is missing. |
| TENANT_INTEGRATION_NOT_ACTIVE | 400 | Connection status is not ACTIVE. Checked before any provider call. |
| TENANT_INTEGRATION_TOKEN_EXPIRED | 400 | The stored token is known to be expired. |
| TENANT_INTEGRATION_INVALID | 401 | The provider returned 401 - the credentials are dead and the connection needs reconnecting. |
| UNSUPPORTED_INTEGRATION_PROVIDER | 400 | No adapter exists for this provider. |
| CODE_SCAN_JOB_NOT_FOUND | 404 | Unknown or foreign job id. |
| CODE_SCAN_RUN_NOT_FOUND | 404 | Unknown or foreign scanRunId. |
| SITE_HEALTH_NOT_FOUND | 404 | Unknown or foreign Site Health id. |
| VALIDATION_ERROR | 400 | A request field failed validation. |
Health checks
/healthLiveness probe. No auth, not rate-limited beyond infrastructure defaults.
{ "status": "ok", "uptime": 1234.5 }/readyReadiness probe - checks the database and Redis are reachable (1s timeout each).
{ "status": "ready" }{ "status": "not_ready", "checks": { "db": false, "redis": true } }List projects
/v1/projectsLists 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.flawpilot.com/v1/projects \
-H "Authorization: Bearer YOUR_API_KEY"Trigger a scan
/v1/scans/triggerQueues 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. |
| 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": "[email protected]"
}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.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": "[email protected]"
}'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
/v1/scans/:id/statusPoll scan progress. Auth required - send your Bearer API key, same as every other endpoint here. Rate-limited per IP, not the general tenant limit. Responses are cached a few seconds, so polling every ~5s is fine.
This public route is always authenticated. The website’s own progress UI polls a separate, unauthenticated version of the same underlying service for logged-in browser sessions - but this API route never accepts unauthenticated calls.
Response - 200 (non-terminal)
{
"status": "RUNNING",
"completed": 8,
"total": 20,
"startedAt": "2026-07-27T10:00:05.000Z",
"url": "https://example.com",
"email": "[email protected]",
"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 |
| score_security, score_performance, score_infrastructure, score_seo | Per-pillar scores. score_total is NOT included on this API surface - it is present on the website’s own status polling, but withheld from public API and MCP responses. |
| 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. |
| reportLink | Full URL to the public report page, e.g. https://app.flawpilot.com/report/<shortCode>. |
Errors
| Status | Cause |
|---|---|
| 401 | "Unauthorized" - missing or invalid API key. |
| 404 | "Scan not found!" - id doesn’t correspond to any scan. |
List connected repos
/v1/integration-reposEvery repository connected to the tenant, flattened across all Git connections. Auth required; general 60/min limit.
Optional ?projectId= filters to one project. Use integrationRepoId from this response to trigger a code scan.
Response - 200
[
{
"integrationRepoId": "a1b2c3d4-...",
"name": "checkout-service",
"fullName": "acme/checkout-service",
"projectId": "PRJ-3TV1T",
"enabled": true,
"defaultBranch": "main",
"selectedBranch": "main",
"connectionName": "acme GitHub",
"connectionStatus": "ACTIVE"
}
]Fields
| Field | Notes |
|---|---|
| projectId | The project’s shortId, or null when the repo is not assigned to a project yet. An unassigned repo cannot be scanned. |
| enabled | false means the repo is connected but excluded from scanning. |
| connectionStatus | ACTIVE means the connection is healthy. Anything else (DISCONNECTED, EXPIRED, INVALID, REVOKED) means a trigger will fail until the connection is restored. Proactive visibility, not a guarantee - status can change between listing and triggering. |
curl https://api.flawpilot.com/v1/integration-repos \
-H "Authorization: Bearer YOUR_API_KEY"Trigger a code scan
/v1/code-scans/triggerQueues one scan job per requested dimension. Auth required; general limit AND a stricter 10/min trigger limit.
There is no projectId field. The project is resolved server-side from the repo you name, so the two can never disagree.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| integrationRepoId | string | Yes | From GET /v1/integration-repos. The repo must be assigned to a project, enabled, and have a branch. |
| dimensions | array of SAST | SCA | SECRETS | CODE_QUALITY | No | Omitted or [] = all four. One job is created per dimension, sharing one scanRunId. |
Response - 201
[
{
"id": "job-uuid",
"dimension": "SAST",
"status": "QUEUED",
"branch": "main",
"commitSha": "9f2c1ab...",
"scanRunId": "run-uuid",
"repoName": "checkout-service",
"repoFullName": "acme/checkout-service",
"createdAt": "2026-09-10T10:00:00.000Z"
}
]Keep scanRunId - it is shared by every dimension in this trigger and is what you poll next.
curl -X POST https://api.flawpilot.com/v1/code-scans/trigger \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"integrationRepoId": "a1b2c3d4-...",
"dimensions": ["SAST", "SECRETS"]
}'Code scan status
/v1/code-scans/runs/:scanRunIdProgress and scores for every dimension in one run. Auth required; 60/min.
Response - 200
{
"scanRunId": "run-uuid",
"branch": "main",
"commitSha": "9f2c1ab...",
"repoName": "checkout-service",
"repoFullName": "acme/checkout-service",
"dimensions": [
{ "dimension": "SAST", "status": "COMPLETED", "score": 82.5, "findingsCount": 14 },
{ "dimension": "SECRETS", "status": "RUNNING", "score": null, "findingsCount": 0 }
]
}A run is done when every entry in dimensions has reached a terminal status. score is null until that dimension finishes.
Code scan findings
/v1/code-scans/jobs/:id/findingsFinding-level detail for one job. Auth required; 60/min. Note this takes a job id, not a scanRunId.
Query parameters
| Parameter | Notes |
|---|---|
| severity | CRITICAL | HIGH | MEDIUM | LOW. Omit for all severities. |
| reviewStatus | Filter by triage state. |
| page | Defaults to 1. |
| limit | Defaults to 10 on this surface. |
Results are already sorted by severity, critical first, so the first page is the part worth acting on.
curl "https://api.flawpilot.com/v1/code-scans/jobs/JOB_ID/findings?severity=CRITICAL&limit=25" \
-H "Authorization: Bearer YOUR_API_KEY"Trigger a Site Health check
/v1/site-health/triggerCrawls the whole site and scores every reachable page with Lighthouse. Auth required; general limit AND a stricter 8-per-HOUR trigger limit.
This is the one endpoint whose action limit is measured per hour rather than per minute - a full-site crawl is far heavier than a single-URL scan.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| projectId | string | Yes | From GET /v1/projects. |
| url | string | Yes | The entry point to crawl from. Include the protocol. |
| mode | DESKTOP | MOBILE | No | Defaults to DESKTOP. The MCP tool asks for this explicitly; the API keeps the default. |
| string (valid email) | No | Sends the report here on completion. |
Response - 201
{
"id": "sh-uuid",
"status": "QUEUED",
"url": "https://example.com",
"mode": "DESKTOP",
"createdAt": "2026-09-10T10:00:00.000Z"
}Site Health status
/v1/site-health/:idPoll one check. Auth required; 30/min.
Response - 200
{
"id": "sh-uuid",
"status": "COMPLETED",
"url": "https://example.com",
"mode": "DESKTOP",
"pageCount": 42,
"errorMessage": null,
"scorePerformance": 71.2,
"scoreAccessibility": 88.0,
"scoreBestPractices": 92.0,
"scoreSeo": 95.5,
"scoreOverall": 86.7,
"expiresAt": "2026-10-10T10:00:00.000Z",
"createdAt": "2026-09-10T10:00:00.000Z",
"updatedAt": "2026-09-10T10:06:31.000Z"
}Scores are null until the crawl finishes. Per-page results, findings and screenshots are dashboard-only - this endpoint returns the top-line scores.
List Site Health checks
/v1/projects/:projectId/site-healthCheck history for one project, newest first. Auth required; 30/min.
Query parameters
| Parameter | Notes |
|---|---|
| status | Filter by run status. |
| mode | DESKTOP | MOBILE. |
| url | Filter by the crawled URL. |
| dateFrom / dateTo | ISO dates bounding createdAt. |
| page / limit | Defaults to page 1, limit 10. |
Response - 200
{
"items": [ /* same shape as GET /v1/site-health/:id */ ],
"total": 37,
"page": 1,
"limit": 10
}