# Scan API

URL: https://docs.aevral.com/docs/scan-api
Markdown: https://docs.aevral.com/docs/scan-api.md

POST /v1/scans starts a whole-repo scan and GET /v1/scans/:id reads it. Authentication with an aevr_ key from AEVRAL_API_KEY, request fields, responses, the polling contract, idempotency, and the error codes with the action a coding agent should take.

A key starts and reads scans for its own organization only. It cannot authorize an overage charge, buy a plan or raise a spend cap: those stay in the console with an owner or admin. Scans take 10 to 40 minutes, so start one, then poll.

## Authentication

```
Authorization: Bearer aevr_...
```

- Keys start with `aevr_` and are minted in the console **Developer API** page (see [Console and API keys](/docs/console-and-api-keys)).
- Coding agents look for the key in the `AEVRAL_API_KEY` environment variable. Never ask the human to paste a key into chat, and never put a key in a URL. If the variable is not set, tell the human to export it in their shell and stop.
- The organization comes from the key alone. A key never reads or starts scans in another organization.

## Start a scan

```
POST https://aevral-worker-prod.fly.dev/v1/scans
Authorization: Bearer $AEVRAL_API_KEY
Content-Type: application/json

{
  "installation_repo_id": 123456789,
  "idempotency_key": "6f1c2e0a-3b7d-4c55-9a0e-2d4b8f7a1c90"
}
```

```bash
curl -X POST https://aevral-worker-prod.fly.dev/v1/scans \
  -H "Authorization: Bearer $AEVRAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"installation_repo_id": 123456789, "idempotency_key": "'"$(uuidgen)"'"}'
```

### Fields

| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `installation_repo_id` | integer | yes | GitHub's numeric repository id, not the name. Get it with `gh api repos/OWNER/REPO --jq .id`. The repository must be authorized for your organization in the console. |
| `idempotency_key` | string | yes | Any unique string you choose, such as a UUID. Sending the same key again returns the scan it already started instead of a new one. |
| `sha` | string | no | The commit to scan. Omit it (or send fewer than 7 characters) to scan the head of the default branch, resolved by the server. A value of 7 or more characters must be a 7 to 40 character hex commit id, else `400 invalid_sha`. A branch name is not a commit. |
| `max_overage_cents` | integer | no | Console session only. With a key, any value above 0 returns `400 overage_requires_console_session`. |

### Response

`202 Accepted` for a new scan, `200 OK` for a duplicate. In the normal case (`scan_id` is a UUID) both carry the poll URL in the body (`status_url`) and in the `Location` header. If `status_url` is absent, check `scan_id`, do not build a URL yourself, and report the malformed response to the human.

```json
{
  "ok": true,
  "scan_id": "5ca11ab1-0000-4000-8000-000000000001",
  "sha": "0123456789abcdef0123456789abcdef01234567",
  "billing_mode": "included",
  "charge_cents": 0,
  "status_url": "https://aevral-worker-prod.fly.dev/v1/scans/5ca11ab1-0000-4000-8000-000000000001"
}
```

| Field | Meaning |
| --- | --- |
| `scan_id` | The scan's UUID. |
| `sha` | The full commit that will be scanned (resolved when you omitted it). |
| `billing_mode` | How the scan counts: `included`, `overage`, `demo` (a private free-trial scan; unpaid public-repository scans are `included`) or `idempotent` (a duplicate of a scan already run this period). |
| `charge_cents` | What this scan costs beyond the plan, in cents. 0 for an included scan. |
| `remaining_included` | Included scans left this period, when the plan has a counter. |
| `duplicate` | `true` on a 200: the same `idempotency_key`, or the same repository and commit already scanned this period. No second charge. |
| `status_url` | Poll this URL. Present whenever `scan_id` is a UUID. |

## Read a scan

```
GET https://aevral-worker-prod.fly.dev/v1/scans/{scan_id}
Authorization: Bearer $AEVRAL_API_KEY
```

`200 OK` returns:

| Field | Meaning |
| --- | --- |
| `scan_id`, `sha`, `created_at` | Which scan, which commit, when. |
| `status` | `queued`, `running`, or one of the terminal statuses below. |
| `phase`, `progress` | Engine progress while running (phase name, batches and files done). Informational only. |
| `billing_mode`, `charge_cents` | As on admission. |
| `summary`, `coverage`, `report` | The result, filled when the scan is terminal. The same report the console shows. |

## Polling contract

- Poll `status_url` every 30 seconds. A scan takes 10 to 40 minutes. If you stop waiting before a terminal status, give the human the last status you read and the `scan_id`: the scan can be read later.
- Stop when `status` is terminal:

| Terminal `status` | What to tell the human |
| --- | --- |
| `findings` | Findings exist. Read `report` and point to the console report. |
| `no_confirmed_findings` | No confirmed findings over the covered tree. Say what `coverage` covered; it is not a guarantee. |
| `scan_incomplete` | An incomplete result: part of the tree was not covered, or analysis or output failed partway. It may still carry findings. Not a clean bill of health. |
| `unsupported_repo` | The repository could not be scanned. |
| `failed` | The scan failed. The human can start a new one. |

- `queued` and `running` are not terminal: keep polling.
- A `502 scan_lookup_failed` means the status is unknown right now: wait `retry_after` seconds and poll again. Do not report the scan as running, finished or failed from this response.

## Idempotency

`idempotency_key` is required. Generate one key per intended scan and keep it for retries: if a request times out, send the same body again. A duplicate returns `200` with `duplicate: true` and the same `scan_id`, never a second charge. Starting the same repository and commit again in the same billing period also returns the existing scan as a duplicate. A scan already queued or running for that commit returns `409 in_flight` with its `scan_id`.

## Errors

Error bodies are JSON with an `error` field. Some add `message`, `retry_after`, `scan_id`, `charge_cents` or `usage_url`.

### `POST /v1/scans`

| Status | `error` | What the agent should do |
| --- | --- | --- |
| 400 | `invalid json` | Send a JSON object body with `Content-Type: application/json`. |
| 400 | `installation_repo_id and idempotency_key are required` | Add both. `installation_repo_id` is the numeric id from `gh api repos/OWNER/REPO --jq .id`. |
| 400 | `sha is required (server-side resolution unavailable)` | This host cannot resolve the default branch head. Send an explicit 7 to 40 character hex `sha` (for example from `git rev-parse HEAD` on the default branch). |
| 400 | `invalid_sha` | Send a 7 to 40 character hex commit id, or omit `sha` to scan the default branch head. Do not send a branch name. |
| 400 | `overage_requires_console_session` | Remove `max_overage_cents`. An overage scan is confirmed by a human in the console. |
| 401 | `invalid_api_key_format` | The key is quoted, truncated or mistyped. Ask the human to check `AEVRAL_API_KEY` (it starts with `aevr_`). Do not ask for the key itself. |
| 401 | `invalid API key` | The key was not recognized. Ask the human to verify that `AEVRAL_API_KEY` holds the key they meant to use, and to recover the existing key from wherever they stored it. Mint a new one in the console Developer API page only if the key is confirmed revoked or lost. Never ask for the key in chat. |
| 401 | `missing or malformed credentials ...` | Send `Authorization: Bearer $AEVRAL_API_KEY`. |
| 402 | `quota_exhausted` | No scans left this period. Stop. Tell the human. For the plan state, follow `usage_url` when present; otherwise call `GET /v1/usage`. Do not retry. |
| 402 | `overage_confirmation_required` | Included scans are used up; the next one costs `charge_cents`. Stop. Only a human can confirm it, from the console. Never retry with `max_overage_cents`. Follow `usage_url` when present; otherwise call `GET /v1/usage`. |
| 402 | `spend_cap_reached` | The organization's monthly spend cap blocks the charge (`billed_cents`, `cap_cents`). Stop. An owner or admin can raise the cap in the console. Follow `usage_url` when present; otherwise call `GET /v1/usage`. |
| 403 | `scans_disabled` | Scanning is not enabled for this organization. Stop and tell the human. |
| 404 | `unknown_repo` | The repository is not authorized for this organization. Check the numeric id, then ask the human to authorize the repository or press Sync from GitHub in the console. |
| 404 | `unknown_org` | The key's organization no longer exists. Stop. |
| 409 | `in_flight` | A scan for this commit is already running. Poll `/v1/scans/{scan_id}` from the body instead of starting another. |
| 429 | `rate_limited` | Too many scan starts. Wait `retry_after` seconds (also in `Retry-After`), then retry with the same `idempotency_key`. |
| 429 | `daily_scan_limit` | The organization started its 20 whole-repo scans for this UTC day. Wait until `resets_at` (next 00:00 UTC; also in `Retry-After`). Do not retry before. Tell the human; the limit can be raised on request. |
| 503 | `rate_limit_unavailable` | The scan-start limiter could not be checked, so the start was refused. Retry after `retry_after` seconds with the same `idempotency_key`. |
| 502 | `sha_resolution_failed` | The default branch head could not be read from GitHub. Retry later, or send an explicit `sha`. |
| 502 | `rpc_failed` | Admission failed on our side. Retry later with the same `idempotency_key`. |
| 503 | `scan_service_unavailable` | No scan can run right now. Nothing was admitted or charged. Retry later. |

### `GET /v1/scans/{scan_id}`

| Status | `error` | What the agent should do |
| --- | --- | --- |
| 401 | (as above) | Fix the key as above. |
| 404 | `not_found` | No scan with that id in this organization, or the id is not a UUID. Stop polling. Use the `status_url` (or the UUID `scan_id`) from the admission response. |
| 502 | `scan_lookup_failed` | Temporary lookup outage: the status is unknown. Wait `retry_after` seconds (5, also in `Retry-After`) and poll again. Do not say the scan is running, finished or failed. |

## For coding agents

- Read the key from `AEVRAL_API_KEY`. Never ask the human to paste it into chat, never print it, never put it in a URL.
- Get `installation_repo_id` from `gh api repos/OWNER/REPO --jq .id`. Omit `sha` unless the human named a commit.
- Generate one `idempotency_key` per scan and reuse the same key when retrying after a timeout or a 429.
- Poll `status_url` every 30 seconds until a terminal status. Expect 10 to 40 minutes. If `status_url` is missing, check `scan_id`, build no URL yourself, and report the malformed response.
- On any 402, stop and tell the human. Never say you bought, confirmed or upgraded anything.
- Report the terminal status honestly: `scan_incomplete` and `no_confirmed_findings` are not guarantees.
- Plan and usage: [Usage API](/docs/usage-api). Prices: only from `GET https://aevral-worker-prod.fly.dev/v1/billing/plans`, never from memory.

See also [What a scan looks like](/docs/what-a-scan-looks-like), [Console and API keys](/docs/console-and-api-keys) and [For AI agents](/docs/for-ai-agents).
