API — browse guides

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.

SurfaceAuthFor
/api/v1Authorization: Bearer vera_… tokenScripts, CI, anything outside Vera. Stable and versioned.
/api/…Session cookieThe 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/projects

Tokens 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 project

The 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:

StatusMeaning
200Stopped. The run never started, or was already cancelled; nothing executed.
202Asked. 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.
409Refused (not_cancellable). The run already passed or failed; a verdict is evidence and is never rewritten.
404No 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 run

JUnit 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 issue

A 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/issues

source 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 affect

The 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-run action, 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