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.
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:writestarts and cancels runs;runs:readreads runs, JUnit and test data, and callswhoami. A key can have both. - A key can be pinned to one app. It then gets
404for every other app’s tests and runs, andappIdbecomes optional. - Revoking a key takes effect on the next request.
- Keys work only on the
/v1endpoints below. Any other endpoint answers403 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.
| Field | Meaning |
|---|---|
appId | The app whose tests run. |
environment | Environment id or name. Default: the app’s default environment. |
environmentUrl | A deployed URL. Matched to an environment by its origin first, then by preview patterns. |
baseUrl | A preview URL to run against instead of the environment’s own. It must match a preview pattern (see Preview URLs). |
tests | Test ids, all from one app (up to 1,000). Default: every runnable test. |
tags | { "tags": [...], "mode": "or" | "and" }. mode defaults to or. |
account | Test account id to sign in as. Default: the environment’s. |
commitSha, branch, pullRequest, repo, ciUrl | CI details shown on the run. repo is owner/name; ciUrl must be http(s). |
idempotencyKey | Same 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.
| Status | Code | When |
|---|---|---|
| 400 | environment_url_unknown, preview_url_not_allowed, preview_url_ambiguous and others | The body is invalid, or the URL or environment doesn’t match the app. |
| 401 | api_key_invalid | The key is wrong or revoked. |
| 402 | plan_limit | The plan doesn’t include API access. The body also has limit, used, max, plan and upgradeTo. |
| 403 | api_key_not_allowed | The key lacks the scope, or the endpoint isn’t open to keys. |
| 403 | tenant_suspended, tenant_deleting | The workspace is suspended or scheduled for deletion. |
| 404 | No such app, test or run for this key. | |
| 409 | The idempotency key was used by a run you can’t see, or you cancelled a finished run. | |
| 429 | Too many requests. Wait and retry. |
Rate limits
Limits are per key, per minute:
POST /v1/runsandPOST /v1/runs/:id/cancel: 30.GET /v1/whoamiandGET /v1/runs/:id: 300.GET /v1/runs/:id/junitandGET /v1/runs/:id/test-data: 60.
Runs started through the API count toward your plan’s included runs, like any other run.