V12 Docs
Public API

REST API

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

Base URL

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

MethodPathScopeDescription
GET/meuser:readAuthenticated user profile and credit balance.
GET/reposrepos:readConnected GitHub repositories.
GET/runsruns:readAudit runs you have access to.
POST/runsruns:writeCreate and queue a new run.
POST/runs/estimateruns:readEstimate the cost of a run without creating it.
GET/runs/{runUid}runs:readSingle run by UID.
POST/runs/{runUid}/cancelruns:manageCancel a non-terminal run.
GET/runs/{runUid}/findingsruns:readFindings for a run.
GET/runs/{runUid}/findings/{findingUid}runs:readSingle finding detail.
PATCH/runs/{runUid}/findings/{findingUid}findings:writeUpdate finding severity or validity.
GET/runs/{runUid}/findings/{findingUid}/commentsruns:readActive comments for a finding.
POST/runs/{runUid}/findings/{findingUid}/commentsfindings:writeAdd a comment to a finding.
GET/runs/{runUid}/findings/{findingUid}/pocruns:readProof-of-concept blob.
GET/runs/{runUid}/findings/{findingUid}/fixruns:readCompact fix blob.
GET/runs/{runUid}/reportruns:readAudit report (JSON or Accept: text/markdown).
POST/zipsruns:writeCreate 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 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 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/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).