---
title: "API keys"
description: "Personal keys for MCP clients and workspace service keys for CI: scopes, project binding, plan limits, rotation, a CI quick start and every error code."
source: "https://testorchestrator.com/docs/api-keys"
---

# API keys

TestOrchestrator has two kinds of API key. A **personal key** connects an MCP client and acts as you. A **workspace service key** authenticates a CI pipeline on the public API, `https://api.testorchestrator.com/api/v1`, to upload automated test results. This page covers service keys: who creates them, their scopes and project, plan limits, rotation, a CI quick start and every error code.

> **Result upload opens in a following release; keys can be created now.** Workspace service keys, their scopes and project binding are live. Until result upload opens, `POST /api/v1/ingest/results` checks the key and its scope and then answers `503 INGEST_NOT_AVAILABLE` with `Retry-After: 3600`. When it opens, an accepted upload is recorded and answered `202`; turning its results into a test run arrives after that.

## Two kinds of key

Both live on **Settings → API keys** (under **Personal**), on two tabs. They are not interchangeable: each one works on exactly one surface and is refused everywhere else.

| Property | Personal key | Workspace service key |
| --- | --- | --- |
| Tab | Personal keys | Workspace service keys |
| For | An MCP client (Claude, Cursor, Claude Code) acting as you | A CI pipeline uploading automated results |
| Prefix | `to_live_` | `to_svc_` |
| Who creates it | Any member, for themselves | A **Tenant Admin**; every member can see the list |
| Scopes | `mcp` only | `ingest:results`, plus `read` if you tick it |
| Project | Follows your own project access | One project, or explicitly all projects |
| Works on | `https://api.testorchestrator.com/mcp` | `https://api.testorchestrator.com/api/v1` |
| Refused on the other | `403 SCOPE_REQUIRED` on `/api/v1` | `401` on `/mcp`, like an unknown key |

Personal keys are covered in [MCP Server](https://testorchestrator.com/docs/mcp). The rest of this page is about workspace service keys.

## Create a service key

Only a **Tenant Admin** can create or revoke a service key. Everyone else sees the tab read-only, with the note _“Workspace service keys are created and revoked by Tenant Admins.”_, and no create or revoke action.

1. Open **Settings → API keys** and choose the **Workspace service keys** tab.
2. Click **New service key**.
3. **Project** comes first. It defaults to the project you last worked in (or the first open project). Pick another, or pick **All projects** — see [project binding](https://testorchestrator.com/docs/api-keys#project-binding).
4. Give it a **Label** that names the pipeline and the job (required, up to 120 characters), for example _GitHub Actions — e2e (main)_.
5. Choose **Scopes**. `ingest:results` is always on and cannot be unticked; tick `read` if the job also needs to list its projects.
6. Choose when it **Expires**: **90 days**, **1 year** (the default) or **Never expires** (marked _Not recommended_).
7. Click **Create service key**, or press Enter in the label.

The **Service key created** dialog shows the key **once**, with its project, its scopes and how CI should carry it. Like the personal-key reveal, it cannot be dismissed by accident: clicking outside it and Escape do nothing. **Copy and close** copies the key and closes the dialog (if the browser blocks the clipboard, it stays open and says so); **I’ve saved it elsewhere** closes it once you have stored the key yourself. After that only the prefix (`to_svc_…`) is ever shown again.

> Store the key as a CI secret (for example `TO_API_KEY`), never in the repository. Anyone holding it can upload results into the key’s project — or every project, for an all-projects key.

The list shows each key’s label and who created it, its project, prefix, scopes, status (**Active**, **Expired** or **Revoked**), when it last authenticated a request (**Never** until a job uses it) and when it expires — flagged once expiry is within two weeks.

## Scopes

| Scope | Lets the key | Held by |
| --- | --- | --- |
| `ingest:results` | Call `POST /api/v1/ingest/results`. It cannot read anything. | Every service key — a service key without it cannot be created |
| `read` | Call `GET /api/v1/projects`, which lists the projects the key can act on with their `prj_` ids. | Service keys you tick it on |
| `mcp` | Use the MCP server as the key’s owner. | Personal keys only — never a service key |

A key that calls a route without its scope gets `403 SCOPE_REQUIRED`. Each route’s scope is listed in the [API reference](https://testorchestrator.com/docs/api-keys/reference) (`x-required-scope`).

## Project binding

A project is named on the public API by its public id: `prj_` followed by 12 characters, for example `prj_8kd2hq0vxc1a`. It never changes and is safe to keep in CI config. The numeric id in the app URL is never accepted.

- **A key for one project** (the default) uploads into that project. The request needs no `project` field. If you send one anyway, it must be that key’s own project; any other value answers `403 KEY_PROJECT_MISMATCH`.
- **An all-projects key** uploads into any project of the workspace, so every upload must say which: `project=prj_…`. Without it the answer is `400 PROJECT_REQUIRED`; a project of another workspace, or one that does not exist, is `403 KEY_PROJECT_MISMATCH`. The create dialog warns before you choose it: prefer one key per project unless one pipeline reports for several.
- A `project` value that is not a `prj_` id — a numeric id, a name — answers `400 INVALID_PROJECT_ID`.

**Finding a `prj_` id.** Call `GET /api/v1/projects` with a key that has the `read` scope: a project key lists its one project, an all-projects key lists every project in the workspace. MCP clients see the same id as `publicId` in `get_projects`.

## Plan limits

Each plan includes a number of **active** service keys. Revoked and expired keys do not count, and personal keys never count.

| Plan | Active service keys |
| --- | --- |
| Free | 1 active service key |
| Starter | 5 active service keys |
| Pro | 25 active service keys |
| Business | No limit |

At the limit, the tab says so and names the next plan, and **New service key** is disabled until a key is revoked. If another admin used the last slot while your dialog was open, creating answers `403 PLAN_LIMIT_SERVICE_KEYS` with `limit` and `requiredPlan`, and the dialog shows the same notice without losing what you typed.

## Rotation and revocation

There is no in-place rotation and no way to reactivate a key. To rotate, create a new key, replace the CI secret, confirm the job authenticates (its **Last used** updates), then revoke the old key.

- **Revoke** from the key’s row menu: **Revoke key…**. A key that authenticated a request in the last seven days asks you to type `REVOKE`, because a pipeline is using it; a dormant key asks for a plain confirmation. Revocation is immediate.
- A revoked or expired key answers `401 API_KEY_UNAUTHORIZED` on every request. Expired and revoked keys stay on the list, greyed, with the reason on hover; use the **Status** filter to hide them.
- Only a Tenant Admin can revoke a service key.

## CI quick start

Send the results file as `multipart/form-data` to `https://api.testorchestrator.com/api/v1/ingest/results` with the key as a bearer token. `file` and `format` (`junit-xml`, `playwright-json` or `to-results-json`) are required; `project` is only for an all-projects key. Every field, with its limits, is in the [API reference](https://testorchestrator.com/docs/api-keys/reference#post-api-v1-ingest-results).

### curl

```bash
curl -sS -X POST https://api.testorchestrator.com/api/v1/ingest/results \
  -H "Authorization: Bearer $TO_API_KEY" \
  -H "Idempotency-Key: $CI_RUN_ID-$CI_RUN_ATTEMPT" \
  -F file=@results/junit.xml \
  -F format=junit-xml \
  -F run_key=ci-$CI_RUN_ID \
  -F ci_branch=main
```

A successful upload answers `202`:

```json
{ "ingest_id": "ing_7f3k9q2m4x8z1c5v", "status": "accepted" }
```

### GitHub Actions

```yaml
# .github/workflows/e2e.yml — store the key as the repository secret TO_API_KEY
- name: Run Playwright
  run: npx playwright test --reporter=json > results.json
- name: Report to TestOrchestrator
  if: always()
  run: |
    curl -sS --fail-with-body -X POST https://api.testorchestrator.com/api/v1/ingest/results \
      -H "Authorization: Bearer ${{ secrets.TO_API_KEY }}" \
      -H "Idempotency-Key: ${{ github.run_id }}-${{ github.run_attempt }}" \
      -F file=@results.json \
      -F format=playwright-json \
      -F run_key=gha-${{ github.run_id }} \
      -F ci_branch=${{ github.ref_name }} \
      -F ci_commit=${{ github.sha }} \
      -F ci_provider=github
```

With an all-projects key, add `-F project=prj_…` to each upload.

### Retries and rate limits

- **Idempotency.** Send an `Idempotency-Key` header (1–255 visible characters, for example the CI run id plus the attempt). Repeating the same upload with the same key within 24 hours returns the first answer again with `Idempotent-Replay: true` and writes nothing twice. The same key with a different upload answers `422 IDEMPOTENCY_KEY_REUSED`.
- **Rate limit.** Each key has its own token bucket of 60 uploads that refills at one per second: a burst of up to 60, then 60 a minute. Over it, the answer is `429 RATE_LIMIT_EXCEEDED` with `Retry-After` in seconds.
- **Tenant headers.** The key alone decides the workspace. Do not send `X-Tenant-Workspace` or `X-Tenant-Subdomain` with it.

## Troubleshooting

Every refusal has the body `{errorCode, message, status, details, path, timestamp}`.

| Response | Cause | Fix |
| --- | --- | --- |
| `401 API_KEY_UNAUTHORIZED` | No key, an unknown key, or a revoked or expired one. | Check the CI secret, and the key’s status and expiry on the Workspace service keys tab. |
| `403 SCOPE_REQUIRED` | The key lacks the route’s scope: a personal key on `/api/v1`, or a service key without `read` on `GET /api/v1/projects`. | Use a service key; create one with `read` if the job lists projects. |
| `403 KEY_PROJECT_MISMATCH` | The `project` sent is not this key’s project, belongs to another workspace, or does not exist. | Use the key created for that project, or check the `prj_` id with `GET /api/v1/projects`. |
| `400 PROJECT_REQUIRED` | An all-projects key uploaded without `project`. | Add `-F project=prj_…`, or use a project key. |
| `400 INVALID_PROJECT_ID` | `project` is not a `prj_` id — for example a numeric id. | Pass the public id, never the number from the app URL. |
| `400 TENANT_HEADER_NOT_ALLOWED` | A tenant header was sent with a key. | Remove `X-Tenant-Workspace` / `X-Tenant-Subdomain`. |
| `400 INVALID_FORMAT` | `format` is missing or not one of the three names. | Use `junit-xml`, `playwright-json` or `to-results-json`. |
| `400 FILE_REQUIRED` | The request is not multipart, or has no `file` part. | Send `-F file=@…`; an empty file is allowed. |
| `400 INVALID_PARAMETER` | A field is malformed or too long; `details` names it. | Check the field’s limit in the API reference. |
| `400 IDEMPOTENCY_KEY_INVALID` | The `Idempotency-Key` header is empty, too long or has invisible characters. | Use 1–255 visible characters. |
| `422 IDEMPOTENCY_KEY_REUSED` | The same `Idempotency-Key` named a different upload in the last 24 hours. | Make the key unique per upload (run id, attempt, shard). |
| `413 FILE_TOO_LARGE` | The file is over 25 MB. | Split it by shard, or drop stdout from the report. |
| `429 RATE_LIMIT_EXCEEDED` | The key’s bucket is empty (a burst of 60, refilling at one per second). | Wait for `Retry-After`; upload once per CI job rather than per spec file. |
| `503 INGEST_NOT_AVAILABLE` | Result upload is switched off — it opens in a following release. | Keep the key; retry after `Retry-After` once upload opens. |
| `403 SERVICE_KEY_ADMIN_ONLY` | Someone other than a Tenant Admin tried to create a service key. | Ask a Tenant Admin. |
| `403 PLAN_LIMIT_SERVICE_KEYS` | The plan’s active service keys are all in use; the body names `limit` and `requiredPlan`. | Revoke a key you no longer use, or move to the next plan. |

## Related

- [API reference](https://testorchestrator.com/docs/api-keys/reference) — every route, field and error code, generated from the OpenAPI document.
- [MCP Server](https://testorchestrator.com/docs/mcp) — personal keys and MCP clients.
- [Limits & locks](https://testorchestrator.com/docs/plans-and-billing/limits) — what every plan includes.
