REST API
V12 REST endpoints, curl examples, and the finding-detail response shape.
Base URL
https://v12.sh/api/v1Every request takes a Bearer token. See Authentication for token setup, and Vocabulary for severity, validity, and run-state values. Full schema (request bodies, response shapes, status codes) lives at /api/v1/openapi.json.
Every token is bound to one organization context at creation (PAT) or consent (OAuth). Runs, findings, and billing state are tenant-scoped to that organization. Repository listing is user-scoped — you see all repos the authenticated user has access to across installations, regardless of the bound org. The bound org is used for billing and run ownership. A user who belongs to multiple organizations needs separate tokens per org. Org-bound tokens do not expand cross-org permissions; if a user loses membership in the token's bound organization, the token is rejected with 401.
Endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /me | user:read | Authenticated user profile and credit balance. |
GET | /repos | repos:read | Connected GitHub repositories. |
GET | /runs | runs:read | Audit runs you have access to. |
POST | /runs | runs:write | Create and queue a new run. |
POST | /runs/estimate | runs:read | Estimate the cost of a run without creating it. |
GET | /runs/{runUid} | runs:read | Single run by UID. |
POST | /runs/{runUid}/cancel | runs:manage | Cancel a non-terminal run. |
GET | /runs/{runUid}/findings | runs:read | Findings for a run. |
GET | /runs/{runUid}/findings/{findingUid} | runs:read | Single finding detail. |
PATCH | /runs/{runUid}/findings/{findingUid} | findings:write | Update finding severity or validity. |
GET | /runs/{runUid}/findings/{findingUid}/comments | runs:read | Active comments for a finding. |
POST | /runs/{runUid}/findings/{findingUid}/comments | findings:write | Add a comment to a finding. |
GET | /runs/{runUid}/findings/{findingUid}/poc | runs:read | Proof-of-concept blob. |
GET | /runs/{runUid}/findings/{findingUid}/fix | runs:read | Compact fix blob. |
GET | /runs/{runUid}/report | runs:read | Audit report (JSON or Accept: text/markdown). |
POST | /zips | runs:write | Create a zip upload slot (returns a presigned PUT URL). |
PoC and fix endpoints return 404 when no artifact has been recorded — that is normal, not every finding has them. The comments endpoint currently returns at most one comment per finding; a second POST returns 409 Conflict.
Creating a run
curl -X POST https://v12.sh/api/v1/runs \
-H 'Authorization: Bearer v12p_...' \
-H 'Content-Type: application/json' \
-d '{
"source": "github",
"name": "Pre-release audit",
"repoFullName": "org/repo",
"branch": "main",
"sha": "abc123def456",
"paths": ["src/", "contracts/"]
}'GitHub-backed runs require exactly one of repoFullName or repoUid. Everything else is optional:
- Omit
branchandsha→ V12 resolves the repo's default branch and current HEAD. - Pass
branchonly → V12 resolves the named branch's HEAD. - Pass
branchandsha→ exact commit. - Passing
shawithoutbranchreturns400. paths(max 500) narrows the audit to specific subtrees; omit for a full audit.
repoUid (returned by GET /repos) is preferred when you have it — there is no name-resolution ambiguity. POST /runs accepts either the numeric ID returned by GET /repos or the same GitHub repository ID encoded as a decimal string.
To audit a local source archive instead of a GitHub repo, create an upload slot, PUT the raw zip to the returned URL, then create the run with source: "zip":
# 1. Create an upload slot
curl -X POST https://v12.sh/api/v1/zips \
-H 'Authorization: Bearer v12p_...'
# → { "zipUid": 123, "uploadUrl": "https://..." }
# 2. PUT the raw zip bytes to the presigned URL
curl -X PUT --data-binary @project.zip \
-H 'Content-Type: application/zip' \
"<uploadUrl>"
# 3. Create the run from the uploaded zip
curl -X POST https://v12.sh/api/v1/runs \
-H 'Authorization: Bearer v12p_...' \
-H 'Content-Type: application/json' \
-d '{ "source": "zip", "name": "Local audit", "zipUid": 123 }'The zip should contain only auditable source files — exclude node_modules, .git, build artifacts, and binaries. The presigned URL is short-lived, so PUT promptly.
Estimating cost
POST /runs/estimate takes the same target selector as run creation (minus name and budgetLimitUsd) and returns the cost estimate without creating anything:
curl -X POST https://v12.sh/api/v1/runs/estimate \
-H 'Authorization: Bearer v12p_...' \
-H 'Content-Type: application/json' \
-d '{ "source": "github", "repoFullName": "org/repo" }'
# → { "estimate": { "billableFileCount": 42, "billableLoc": 6800,
# "meanCostCents": 5200, "upperCostCents": 9900, ... },
# "scope": [ { "path": "contracts/Vault.sol", "loc": 812 }, ... ],
# "resolved": { "repoFullName": "org/repo", "branch": "main", "sha": "..." } }Costs are in USD cents: meanCostCents is the expected cost, upperCostCents the upper band. scope lists the exact files the quote covers — a run created for the same target audits these files. The same estimate object is returned alongside run in the 201 response when a run is created. Estimates are rate-limited separately from run creation.
Diff reviews
Both /runs and /runs/estimate accept a diffReviewConfig to review a change instead of auditing the full target. Provide exactly one diff source — fromRef + toRef to compare two refs (GitHub targets only), patchContent with an inline unified diff, or patchUid referencing a previously uploaded patch. branch/sha cannot be combined with diffReviewConfig.
curl -X POST https://v12.sh/api/v1/runs/estimate \
-H 'Authorization: Bearer v12p_...' \
-H 'Content-Type: application/json' \
-d '{ "source": "github", "repoFullName": "org/repo",
"diffReviewConfig": { "fromRef": "main", "toRef": "feature" } }'When you quote with inline patchContent, the response's resolved.patchUid identifies the stored patch — pass it as diffReviewConfig.patchUid when creating the run to review exactly the quoted patch. Inline patches are limited by the 1 MiB request-body cap.
Reading findings
# All findings, paginated
curl -H 'Authorization: Bearer v12p_...' \
https://v12.sh/api/v1/runs/42/findings
# Critical + high only, first page of 10
curl -H 'Authorization: Bearer v12p_...' \
'https://v12.sh/api/v1/runs/42/findings?severity=critical&severity=high&limit=10'
# Valid findings only, page 2
curl -H 'Authorization: Bearer v12p_...' \
'https://v12.sh/api/v1/runs/42/findings?validity=valid&limit=25&offset=25'severity and validity are repeatable. The response includes totalMatching and hasMore for pagination. See Vocabulary for legal values.
Other examples
# Get a single finding (full detail with sourceLocations + sourceUrls)
curl -H 'Authorization: Bearer v12p_...' \
https://v12.sh/api/v1/runs/42/findings/7
# Mark a finding as a false positive
curl -X PATCH https://v12.sh/api/v1/runs/42/findings/7 \
-H 'Authorization: Bearer v12p_...' \
-H 'Content-Type: application/json' \
-d '{"validity": "invalid"}'
# Download the report as Markdown
curl -H 'Authorization: Bearer v12p_...' \
-H 'Accept: text/markdown' \
https://v12.sh/api/v1/runs/42/report
# Cancel a run
curl -X POST -H 'Authorization: Bearer v12p_...' \
https://v12.sh/api/v1/runs/42/cancelFinding detail response
GET /runs/{runUid}/findings/{findingUid} returns:
{
"uid": 101,
"runUid": 42,
"title": "Unchecked external call return value",
"severity": "high",
"validity": "unreviewed",
"description": "The contract ignores the callee's return value and continues execution.",
"commentCount": 0,
"createdAt": "2026-04-26T18:14:03.000Z",
"impact": "Funds can become stuck if the downstream call fails.",
"rootCause": "The code assumes every low-level call succeeds.",
"sourceLocations": [
{
"file": "src/Vault.sol",
"startLine": 118,
"endLine": 121,
"note": "Unchecked low-level call",
"snippet": "(bool ok,) = target.call(data);"
}
],
"webUrl": "https://v12.sh/runs/42/7",
"sourceUrls": [
"https://github.com/org/repo/blob/<sha>/src/Vault.sol#L118-L121"
]
}sourceLocations is always present; sourceUrls is included only when the run has repo and commit context. Address findings by findingUid, not by the F-001-style numbers shown in the V12 web UI (those are display-only).