# TestWatch REST API: start runs and read verdicts

Source: https://testwatch.dev/docs/api
Updated: 2026-10-17

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

| 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](https://testwatch.dev/docs/preview-urls-and-protection)). |
| `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](https://testwatch.dev/docs/fixtures#test-data), 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/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.
