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). Read-only run resources (run metadata, reports, findings, PoC, fix, and comments) include runs in that organization plus runs explicitly shared with the authenticated user as an owner or contributor. Writes/manage — creating, cancelling, sharing, finding updates/triage, and comment create/delete — stay tenant-scoped to the bound organization. Billing state is also tenant-scoped. 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 for writes/manage in each org. Org-bound tokens do not expand cross-org write 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.

repoFullName accepts any public GitHub repository in owner/repo form — the V12 GitHub App does not 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.

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.

Every run object returned by the API carries a cost field: the total billable cost in USD actually charged for that run. It is 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 after a run ends.

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