TestWatchStart free
Docs menu

TestWatch REST API: start runs and read verdicts

Six endpoints under https://api.testwatch.dev/v1, called with a workspace API key. The CLI and the GitHub Action use the same API. Available on Starter and up.

by Vasyl Harastei

API keys

  • Owners and admins create keys in Settings → API keys. A key starts with tw_ and is shown once. TestWatch stores only a hash of it.
  • Scopes: runs:write starts and cancels runs; runs:read reads runs, JUnit and test data, and calls whoami. A key can have both.
  • A key can be pinned to one app. It then gets 404 for every other app’s tests and runs, and appId becomes optional.
  • Revoking a key takes effect on the next request.
  • Keys work only on the /v1 endpoints below. Any other endpoint answers 403 api_key_not_allowed.

Send the key as a bearer token:

export TESTWATCH_API_KEY=tw_...
curl https://api.testwatch.dev/v1/whoami \
  -H "Authorization: Bearer $TESTWATCH_API_KEY"

GET /v1/whoami

Scope runs:read. Returns the workspace and the key, so a CI job can check its key before it starts anything.

{
  "workspace": { "id": "...", "name": "Acme" },
  "key": { "id": "...", "name": "CI", "scopes": ["runs:write", "runs:read"], "appId": null }
}

POST /v1/runs

Scope runs:write. Starts a run. Every field is optional except appId, which a pinned key may leave out.

FieldMeaning
appIdThe app whose tests run.
environmentEnvironment id or name. Default: the app’s default environment.
environmentUrlA deployed URL. Matched to an environment by its origin first, then by preview patterns.
baseUrlA preview URL to run against instead of the environment’s own. It must match a preview pattern (see Preview URLs).
testsTest ids, all from one app (up to 1,000). Default: every runnable test.
tags{ "tags": [...], "mode": "or" | "and" }. mode defaults to or.
accountTest account id to sign in as. Default: the environment’s.
commitSha, branch, pullRequest, repo, ciUrlCI details shown on the run. repo is owner/name; ciUrl must be http(s).
idempotencyKeySame as the Idempotency-Key header (up to 200 characters). The header wins when both are sent.
curl -X POST https://api.testwatch.dev/v1/runs \
  -H "Authorization: Bearer $TESTWATCH_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: deploy-4f2a9c1" \
  -d '{
    "appId": "<your app id>",
    "environment": "staging",
    "tags": { "tags": ["smoke"], "mode": "or" },
    "commitSha": "4f2a9c1",
    "branch": "main"
  }'

The answer is 201 for a new run and 200 when the idempotency key matched a run that was already started, with replayed: true. Send a key with every request, so a retried request returns the same run instead of starting a second one. A key already used by a run you can’t see answers 409.

{ "runId": "...", "url": "https://app.testwatch.dev/apps/.../runs/...", "replayed": false, "unreachable": false }

unreachable: true means the app didn’t answer. The run is recorded anyway and its verdict is infra.

GET /v1/runs/:id

Scope runs:read. The run with its verdict, final, finalAt, counts per verdict, the environment, targetUrl and preview, the CI details, and every result with its title, device, status, verdict, duration, a flaky flag, a cleaned error message (no stack trace, up to 500 characters) and a dashboard link.

curl https://api.testwatch.dev/v1/runs/<run id> \
  -H "Authorization: Bearer $TESTWATCH_API_KEY"

The verdict is one of pass, real_bug, needs_review or infra; the worst one across the results wins. It is provisional until final is true. A run finishes before TestWatch has checked its failures, so poll until final is true, not until the run has finished. The CLI polls every 5 seconds. If a check never comes back, the run becomes final 20 minutes after it finished, and anything still unchecked counts as needs_review.

GET /v1/runs/:id/junit

Scope runs:read. A JUnit XML report with one test suite per device. real_bug and needs_review become failures, infra becomes an error, and quarantined or cancelled tests are skipped. Signed-in users can download the same file from the run page on every plan.

curl -o testwatch.xml https://api.testwatch.dev/v1/runs/<run id>/junit \
  -H "Authorization: Bearer $TESTWATCH_API_KEY"

POST /v1/runs/:id/cancel

Scope runs:write. Removes the tests that haven’t started; tests already running finish with their real result. Returns { "cancelled": n, "running": n }. Calling it again before the run closes does no harm; a run that already finished answers 409.

curl -X POST https://api.testwatch.dev/v1/runs/<run id>/cancel \
  -H "Authorization: Bearer $TESTWATCH_API_KEY"

GET /v1/runs/:id/test-data

Scope runs:read. Every value the run’s tests created with the test-data helpers, with the run’s prefix. Use it when a cleanup webhook arrives with truncated: true.

curl https://api.testwatch.dev/v1/runs/<run id>/test-data \
  -H "Authorization: Bearer $TESTWATCH_API_KEY"

Errors

Error bodies are JSON with a message, and a code where one applies.

StatusCodeWhen
400environment_url_unknown, preview_url_not_allowed, preview_url_ambiguous and othersThe body is invalid, or the URL or environment doesn’t match the app.
401api_key_invalidThe key is wrong or revoked.
402plan_limitThe plan doesn’t include API access. The body also has limit, used, max, plan and upgradeTo.
403api_key_not_allowedThe key lacks the scope, or the endpoint isn’t open to keys.
403tenant_suspended, tenant_deletingThe workspace is suspended or scheduled for deletion.
404No such app, test or run for this key.
409The idempotency key was used by a run you can’t see, or you cancelled a finished run.
429Too many requests. Wait and retry.

Rate limits

Limits are per key, per minute:

  • POST /v1/runs and POST /v1/runs/:id/cancel: 30.
  • GET /v1/whoami and GET /v1/runs/:id: 300.
  • GET /v1/runs/:id/junit and GET /v1/runs/:id/test-data: 60.

Runs started through the API count toward your plan’s included runs, like any other run.