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/resultschecks the key and its scope and then answers503 INGEST_NOT_AVAILABLEwithRetry-After: 3600. When it opens, an accepted upload is recorded and answered202; 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. 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.
- Open Settings → API keys and choose the Workspace service keys tab.
- Click New service key.
- 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.
- Give it a Label that names the pipeline and the job (required, up to 120 characters), for example GitHub Actions — e2e (main).
- Choose Scopes.
ingest:resultsis always on and cannot be unticked; tickreadif the job also needs to list its projects. - Choose when it Expires: 90 days, 1 year (the default) or Never expires (marked Not recommended).
- 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 (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
projectfield. If you send one anyway, it must be that key’s own project; any other value answers403 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 is400 PROJECT_REQUIRED; a project of another workspace, or one that does not exist, is403 KEY_PROJECT_MISMATCH. The create dialog warns before you choose it: prefer one key per project unless one pipeline reports for several. - A
projectvalue that is not aprj_id — a numeric id, a name — answers400 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_UNAUTHORIZEDon 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.
curl
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:
{ "ingest_id": "ing_7f3k9q2m4x8z1c5v", "status": "accepted" }
GitHub Actions
# .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-Keyheader (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 withIdempotent-Replay: trueand writes nothing twice. The same key with a different upload answers422 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_EXCEEDEDwithRetry-Afterin seconds. - Tenant headers. The key alone decides the workspace. Do not send
X-Tenant-WorkspaceorX-Tenant-Subdomainwith 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 — every route, field and error code, generated from the OpenAPI document.
- MCP Server — personal keys and MCP clients.
- Limits & locks — what every plan includes.