The API
The versioned /api/v1 surface for scripts and CI: authentication, running tests, JUnit output, test impact, importing, and the error conventions.
Vera has two HTTP surfaces, and which one you want depends on who is calling.
| Surface | Auth | For |
|---|---|---|
/api/v1 | Authorization: Bearer vera_… token | Scripts, CI, anything outside Vera. Stable and versioned. |
/api/… | Session cookie | The dashboard itself. Internal; changes without notice. |
If you are automating Vera, use /api/v1. The unversioned routes exist because the
dashboard needs them, and documenting them as an integration contract would be
promising stability that is not offered.
There is also /api/mcp for AI coding agents — see MCP — and
/api/agent/v1, which the local agent uses to lease runs.
Authentication
Mint a token in the dashboard, then send it as a bearer token:
curl -H "Authorization: Bearer vera_xxx" \
https://your-vera-host/api/v1/projectsTokens are shown once. /api/v1 requires the Pro api-access feature; on a
plan without it every /api/v1 call returns 401, which is the usual explanation for
a token that "does not work". /api/mcp is available on the free tier.
The endpoints
Projects
GET /api/v1/projects List projects
GET /api/v1/projects/:id Get one projectThe projection is deliberately minimal — it does not include a project's stored credentials or auth configuration. A token that could read those would turn a CI secret into an app-credential leak.
Running tests
POST /api/v1/projects/:id/run Start a run (a suite, a tag, or the project)
GET /api/v1/runs/:id Run status and results
GET /api/v1/runs/:id/wait Long-poll until the run finishes
POST /api/v1/runs/:id/cancel Ask a run to stop/wait is the one to build a pipeline on: start a run, then wait on it, rather than
polling /runs/:id on a timer.
Once a run is terminal both reads answer with the same body, and it carries an
environment object naming the engine that actually executed the run:
{ "id": "run_…", "status": "passed", "durationMs": 8412, "error": null,
"passedOnRetry": false, "healed": false,
"environment": { "engine": "playwright", "engineBrowser": "webkit",
"engineOverriddenFrom": "chromium",
"environmentName": "staging" } }engineBrowser is what the driver reported launching, and it is not always what the
test was saved with: a device descriptor such as iPhone 14 serves a Safari user
agent and therefore launches WebKit, and Real Browser mode launches Chrome. When a
saved engine was discarded that way, engineOverriddenFrom names it — so a run whose
result changed between two builds can be checked against the browser binary that
produced it. engineBrowser and engineOverriddenFrom are null when there is
nothing to report; engineBrowser is null rather than guessed for an executor with
no browser engine to name (a native-mobile Appium session) or a run recorded before
Vera stamped one — null means not recorded, never chromium.
healed is true when the run went green only after self-heal rewrote a selector. It
sits beside status and is never folded into it. A run started here is always
healed: false: it executes cloud-side (server or worker, whatever the project's
runTarget), and neither attaches a selector healer. Reading /runs/:id for a run
started elsewhere can report true — a vera-agent run when the org has turned on
settings.agent.selfHeal, a repair run, or an AI-generation run. See
CI integration.
The start response also reports source — who wrote the test that ran: manual
(a person, in the builder or through an importer), ai (Vera's generators), mcp
(created through the MCP tools), or absent for a test created before Vera recorded
provenance. It sits beside the verdict on the single-test reply and on every row of the
multi-test results[], and is never folded into status or blocking — the same
rule healed follows. Absent means not stated; do not read it as manual, and do not
read any value as a review signal (a test can be ai and approved, or manual and
unreviewed). recorded is defined but nothing writes it today, so a test captured in
the recorder reports manual.
Cancelling is cooperative, not a kill — which is why the status code matters:
| Status | Meaning |
|---|---|
200 | Stopped. The run never started, or was already cancelled; nothing executed. |
202 | Asked. The run is executing and ends at its next step boundary — one step's remaining timeout, plus up to one 20s heartbeat if a local agent holds it (agentRequested: true). Poll /runs/:id for the ending. |
409 | Refused (not_cancellable). The run already passed or failed; a verdict is evidence and is never rewritten. |
404 | No such run in your organisation. |
Calling it twice is safe: a second stop on an already-cancelled run is a 200.
Pass {"scope": "group"} to stop every row of a dataset run group the named run
belongs to, rather than that one row. The response is group-shaped —
total, cancelled, requested, alreadyFinished, alreadyCancelled, plus a
members array — because some rows will already have finished by the time you press
stop, and a bare success would hide the ones that kept running. It is refused with
not_grouped for a run that is not part of a dataset group, rather than quietly
stopping just that run.
Reporting
GET /api/v1/runs/:id/junit JUnit XML for one run
GET /api/v1/suite-runs/:id/junit JUnit XML for a suite runJUnit XML is what makes results render natively in a CI provider's test UI, with per-test names and failure messages, instead of being buried in log output. See CI integration.
Issues
POST /api/v1/projects/:id/issues File an issue against a project
GET /api/v1/projects/:id/issues List that project's issues
GET /api/v1/issues/:id Get one issueA pipeline that finds a defect can file it where the team triages defects, instead
of leaving it in a build log. POST takes a title (required, ≤200 chars) plus any
of description, severity (critical | major | minor, default major),
labels, and the testId / runId / stepIndex / chainRunId pointers you have
to hand. It answers 201 with the issue and a url you can print:
curl -X POST -H "Authorization: Bearer vera_xxx" -H 'content-type: application/json' \
-d '{"title":"Checkout 500s on the staging deploy","severity":"critical","labels":["ci"]}' \
https://your-vera-host/api/v1/projects/prj_123/issuessource is set by the server, not by you. An issue filed with a token is
source: "api"; a body that names its own source is ignored. The field is what
the board's provenance badge and the one-open-AI-issue-per-test bound read, so it
would be worth nothing if a caller could choose it.
The project in the path is resolved inside your organisation: an id belonging
to another workspace answers 404 exactly as an unknown one does, and nothing is
filed. An empty path segment (/api/v1/projects//issues, the shape a script
produces from an unset variable) answers the same 404 on both filing and
listing, rather than filing an issue that belongs to no project. Every id you
send — projectId, testId, runId, chainRunId — must be non-blank; an empty
string is a 400 naming the field, never a stored pointer to nothing.
A testId or runId you send must name a test or run in your organisation at
the moment you file — another workspace's id and an unknown one both answer
404 (resource: "test" or "run") and nothing is filed. It is checked once:
the issue outlives the run afterwards, so retention ageing a run out never
invalidates an issue already on the board.
Listing takes status, severity, source, limit (≤100, default 50) and
offset, and returns { issues, total, limit, offset } where total counts the
filter rather than the page. An unknown (but non-blank) project lists as
{ "total": 0, "issues": [] } — you can read a project you own and have not used,
and the API does not distinguish it from one that is not yours.
Filing is not triage. There is deliberately no token route that closes, reopens or edits an issue — see Issues & triage for where that decision is made, and why it is kept with a person or a confirming agent.
Both reads need issues:read and filing needs issues:manage; every CI token
carries all three.
Test impact
GET /api/v1/projects/:id/impact Which tests a change set could affectThe API behind vera check — given changed files, the tests that cover them. See
vera check.
Importing
POST /api/v1/projects/:id/import/:format:format is one of the eight supported formats. See
Importing & exporting.
Conventions
Errors carry a machine-readable code alongside the message, so a caller can branch without string-matching:
{ "error": "This feature is available on the Team plan.",
"code": "plan_limit", "feature": "chains", "requiredPlan": "team" }plan_limit and plan_required mean the org's plan does not permit the call —
retrying will not help. A 4xx in general is not worth retrying; a 5xx may be.
Long-running operations stream. A run started with
Accept: text/event-stream streams step-by-step progress as Server-Sent Events;
without that header the same endpoint runs to completion and returns the final
result as JSON. Pick per caller: SSE for a live view, plain JSON for a script that
only wants the verdict.
Everything is org-scoped. A token resolves to exactly one organisation and cannot read another's projects, tests or runs. There is no cross-tenant read.
Rate limiting is keyed per organisation in cloud mode, so one noisy integration cannot starve another tenant.
Choosing an integration path
Before writing HTTP calls, check whether something already does the job:
- CI — the
vera-runaction, or the ready-made GitLab/Bitbucket configs. See CI integration. - A local or self-hosted runner —
vera-agent. See The local agent. - An AI coding agent — MCP, which gives it typed tools instead of a REST client. See MCP.
The raw API is the right answer when none of those fit — an unusual CI system, a dashboard of your own, a bulk migration.
See also
- CI integration — §6 covers driving the API from any CI
- MCP — the agent-facing surface
- The local agent — the lease API and the CLI over it
vera check— the impact endpoint in practice- Issues & triage — what happens to an issue after CI files it