REST API
V12 REST endpoints, curl examples, and the finding-detail response shape.
Base URL
The REST API lives at:
https://v12.sh/api/v1Every request takes a Bearer token. See Authentication for token setup and Vocabulary for severity, validity, and run-state values. The full schema—request bodies, response shapes, status codes—lives at /api/v1/openapi.json.
Token scoping
Every token is bound to one organization context at creation (PAT) or consent (OAuth):
- Reads: run metadata, reports, findings, PoC, fix, and comments include runs in the bound organization plus runs explicitly shared with you as an
ownerorcontributor. - Writes and management: creating, cancelling, sharing, finding updates, and comments stay tenant-scoped to the bound organization. Billing state is also tenant-scoped.
- Repository listing is user-scoped—you see all repos you have access to across installations, regardless of the bound org.
A user who belongs to multiple organizations needs separate tokens per org for writes in each. Org-bound tokens don’t expand cross-org write permissions; if you lose 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 |
PUT |
/runs/{runUid}/share |
runs:manage |
Add run contributors with { "emails": [...] } (1–25 emails per call) |
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) |
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’s 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.
repoFullName accepts any public GitHub repository in owner/repo form—the V12 GitHub app doesn’t need to be installed on it, so you can audit public repos you neither own nor have connected. Private repos still require an installation (or a token) that can see them.
Auditing an archive
To audit a local source archive instead, create an upload slot, PUT the raw zip to the returned URL, then create the run with source: "zip":
# 1. Create an upload slotcurl -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 URLcurl -X PUT --data-binary @project.zip \ -H 'Content-Type: application/zip' \ "<uploadUrl>"
# 3. Create the run from the uploaded zipcurl -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 fields as run creation, without the create-only name and contextDocumentUids fields, and returns the estimate without creating a run:
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,# "priceCents": 9900 },# "scope": [ { "path": "contracts/Vault.sol", "loc": 812, "bytes": 24918 }, ... ],# "resolved": { "repoFullName": "org/repo", "sha": "..." } }priceCents is the fixed price in USD cents and is charged in full when the run starts. scope lists the exact files the quote covers. 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.
Every run object carries a cost field: the total billable cost in USD actually charged for that run. It’s 0 until the run incurs cost and is final once the run reaches completed, failed, or cancelled. Use estimate for the pre-run quote and cost for the realized total.
Diff reviews
Both /runs and /runs/estimate accept a diffReviewConfig to review a change instead of auditing the full target. For a GitHub target, fromRef is required and must be paired with exactly one of toRef, patchContent, or patchUid. A ZIP target instead accepts exactly one of patchContent or patchUid. branch and sha can’t 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 a GitHub target with inline patchContent, the response’s resolved.patchUid identifies the stored patch. Reuse it with the same fromRef as diffReviewConfig.patchUid when creating the run. Inline patches are limited by the 1 MiB request-body cap.
Reading findings
# All findings, paginatedcurl -H 'Authorization: Bearer v12p_...' \ https://v12.sh/api/v1/runs/42/findings
# Critical + high only, first page of 10curl -H 'Authorization: Bearer v12p_...' \ 'https://v12.sh/api/v1/runs/42/findings?severity=critical&severity=high&limit=10'
# Valid findings only, page 2curl -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.
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 positivecurl -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 Markdowncurl -H 'Authorization: Bearer v12p_...' \ -H 'Accept: text/markdown' \ https://v12.sh/api/v1/runs/42/report
# Cancel a runcurl -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.