Start a run

REST API

V12 REST endpoints, curl examples, and the finding-detail response shape.

Base URL

The REST API lives at:

https://v12.sh/api/v1

Every 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 owner or contributor.
  • 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

Create a GitHub-backed 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 branch and sha → V12 resolves the repo’s default branch and current HEAD.
  • Pass branch only → V12 resolves the named branch’s HEAD.
  • Pass branch and sha → exact commit.
  • Passing sha without branch returns 400.
  • 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":

Upload and audit a 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 fields as run creation, without the create-only name and contextDocumentUids fields, and returns the estimate without creating a run:

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.

Quote a diff review
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

Listing and filtering 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.

Other examples

Triage, reports, cancellation
# 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/cancel

Finding 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.

Type to search.