TestVault← Back to site

TestVault API

v1.0.0 · OpenAPI document

Start a run of a named plan from your pipeline, poll it, and fail the build when a test fails. A token acts for a workspace, not for the person who created it, so it keeps working after they leave the team.

Run your suite from CI

  1. Mint a token in Settings → Workspace → API tokens. It is shown once — TestVault stores only a hash and cannot show it again.
  2. Add it to your repository as a secret named TESTVAULT_TOKEN (Settings → Secrets and variables → Actions).
  3. Commit the workflow below, naming one of your own plans instead of the example.
.github/workflows/testvault.yml
name: TestVault

on: [push]

jobs:
  qa:
    runs-on: ubuntu-latest
    steps:
      - name: Start the suite
        id: start
        run: |
          BODY=$(curl -sS -X POST "https://testvault.app/api/public/v1/runs" \
            -H "Authorization: Bearer ${{ secrets.TESTVAULT_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{"plan":"Smoke"}')

          RUN_ID=$(echo "$BODY" | jq -r '.runId // empty')
          if [ -z "$RUN_ID" ]; then
            # A refusal has no runId. Print what TestVault said instead of polling nothing.
            echo "$BODY" | jq -r '"TestVault refused: \(.error // "unknown") (\(.code // "-"))"'
            exit 1
          fi
          echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"

      - name: Wait for the result
        run: |
          RUN_ID="${{ steps.start.outputs.run_id }}"
          for _ in $(seq 1 120); do
            BODY=$(curl -sS "https://testvault.app/api/public/v1/runs/$RUN_ID" \
              -H "Authorization: Bearer ${{ secrets.TESTVAULT_TOKEN }}")
            STATUS=$(echo "$BODY" | jq -r '.status // "unknown"')
            [ "$STATUS" = "running" ] || break
            sleep 10
          done

          echo "$BODY" | jq -r '(.tests // [])[] | select(.status == "failed") | "FAILED  \(.name)"'
          echo "$BODY" | jq -r '"\(.totals.passed // 0) passed, \(.totals.failed // 0) failed, \(.totals.skipped // 0) skipped"'

          # Fail on failures, not on the status: "running" is not a failure.
          if [ "$STATUS" != "completed" ]; then
            # Covers both "still running after the timeout" and a mid-poll refusal, which has no
            # status at all — "did not finish in time" alone would send you looking for the wrong
            # thing when the real answer is that somebody revoked the token.
            echo "Run did not complete (status: $STATUS) — $(echo "$BODY" | jq -r '.error // "timed out"')"
            exit 1
          fi
          if [ "$(echo "$BODY" | jq -r '.totals.failed // 0')" -gt 0 ]; then
            exit 1
          fi

It polls until the run finishes, prints each failure, and exits non-zero only when a test actually failed — running is not a failure, and a job that treats any non-completed status as red goes red on its first poll. It gives up after 20 minutes rather than polling forever.

Or just call it

shell
curl -X POST "https://testvault.app/api/public/v1/runs" \
  -H "Authorization: Bearer tv_live_…" \
  -H "Content-Type: application/json" \
  -d '{"plan":"Smoke"}'

Authentication

Every request carries Authorization: Bearer tv_live_…. A token is created with a fixed set of scopes and they cannot be changed afterwards — mint a new token instead. Revoking one takes effect on the next request.

ScopeGrants
readRead runs, tests and bugs
runs:writeStart test runs
bugs:writeFile bugs
repo:linkLink a run to a commit

Endpoints

POST/api/public/v1/runsruns:write

Start a run of a named plan

Dispatches the plan's automated tests and returns immediately with a runId to poll. Manual cases in the plan are not included — a person walks those. Quarantined tests are excluded.

Requires the runs:write scope. This endpoint spends tokens, at the same price as the same run started in the app; if the workspace balance will not cover it the run is refused with 402 and the body says what it would have cost.

Request body
FieldTypeNotes
planrequiredstringThe plan NAME, as shown in the app and returned by GET /plans — not an id, so a pipeline does not hard-code one. Matched exactly, within your workspace.
browserstringDefaults to chromium-desktop.
202 — Accepted and dispatched. Poll GET /runs/{runId} for the result.
FieldTypeNotes
runIdrequiredstring
statusrequired"running" | "completed" | "failed" | "cancelled"
planobject | nullThe named plan this run executed, when one was named. Null for a run of every active test, or when the plan has since been deleted.
idstring
namestring
startedAtstring (date-time)
finishedAtstring (date-time) | null
totalsrequiredobject
totalrequiredinteger
passedrequiredinteger
failedrequiredinteger
skippedrequiredinteger
testsobject[]
namerequiredstringThe test case title.
specFilestringThe spec the case belongs to.
statusrequired"passed" | "failed" | "skipped"
durationMsintegerHow long the case took, in milliseconds.
errorstringPresent only on a failure.
202400401402403409
GET/api/public/v1/runs/{runId}read

Read a run

What CI polls. While status is running the totals are what has been recorded so far. running is not a failure — fail your build on failed > 0 once status is completed, never on the status alone.

Path parameters
FieldTypeNotes
runIdrequiredstringFrom the response to POST /runs.
200 — The run.
FieldTypeNotes
runIdrequiredstring
statusrequired"running" | "completed" | "failed" | "cancelled"
planobject | nullThe named plan this run executed, when one was named. Null for a run of every active test, or when the plan has since been deleted.
idstring
namestring
startedAtstring (date-time)
finishedAtstring (date-time) | null
totalsrequiredobject
totalrequiredinteger
passedrequiredinteger
failedrequiredinteger
skippedrequiredinteger
testsobject[]
namerequiredstringThe test case title.
specFilestringThe spec the case belongs to.
statusrequired"passed" | "failed" | "skipped"
durationMsintegerHow long the case took, in milliseconds.
errorstringPresent only on a failure.
200401403404
GET/api/public/v1/runs/{runId}/reportread

Read the signed test report for a run

The STR, for a build artifact. Returns the latest revision. 404 when no report has been created for this run — a report is a document somebody produces, and this endpoint will not fabricate one on a GET.

Path parameters
FieldTypeNotes
runIdrequiredstringFrom the response to POST /runs.
200 — The report.
FieldTypeNotes
runIdrequiredstring
titlerequiredstring
revisionrequiredinteger
createdAtstring (date-time)
signedAtstring (date-time) | null
signedNamestring | null
snapshotrequiredobjectThe report body as stored.
200401403404
GET/api/public/v1/plansread

List the plans a run can be started from

So a pipeline can name a suite without hard-coding an id. automatedTests is how many tests a run of this plan would actually dispatch — a plan whose items are all manual reports 0, and starting it would be refused with 409.

200 — The workspace's plans.
FieldTypeNotes
plansrequiredobject[]
idrequiredstring
namerequiredstring
statusrequired"draft" | "active" | "archived"
automatedTestsrequiredinteger
200401403

When it refuses

Every refusal answers with { success: false, error, code }. The error is a sentence for a human and may be reworded; the code is stable, so branch on that.

StatusCodeWhen
401missing_tokenNo Authorization header, so there was nothing to authenticate.
401malformedNot an API token (expected an Authorization: Bearer tv_live_… header).
401unknownUnknown API token.
401revokedThis API token has been revoked.
401expiredThis API token has expired.
401public_api_disabledThe API is switched off for this deployment. You will not see this on testvault.app.
403upgrade_requiredThe token is live, but the workspace's plan does not include the API. Re-checked on every request, so a downgrade takes effect immediately.
403insufficient_scopeThe token is live but was not created with the scope this endpoint needs. The body names which scope. Scopes cannot be added to an existing token — mint a new one.
400plan_requiredNo plan in the body. GET /plans lists the names.
400plan_not_foundNo plan in your workspace has that name. Matched exactly.
409no_runnable_testsThe plan dispatches nothing — it holds only manual cases, or only quarantined tests. A green run of zero tests would be the worst available answer, so this is a refusal.
402insufficient_balanceThe workspace balance will not cover this run. The body carries needs and has.
404run_not_foundNo such run in your workspace. A run belonging to another workspace answers identically.
404report_not_createdThe run exists but nobody has produced a report for it. A GET will not fabricate one.
500dispatch_failedThe run could not be started, and the reason was not a refusal. Safe to retry.

Rate limits. There is no rate limit and no 429. The bound on this API is your workspace balance, checked before a run is dispatched — a run costs exactly what the same run costs in the app, and reads are free. If the balance will not cover a run you get the 402 below, which names what it needed. Nothing here will ask you to back off and retry.

Two things it does not do

  • No webhooks. CI already polls, and a webhook is a delivery guarantee, a retry policy and a signature scheme — a release of its own rather than a field on this one.
  • No access to the rest of the product. These are the only endpoints a token can reach. Everything else is the app’s own surface, carries no version, and would be frozen as a public contract the moment it were published.