REST API

The plain-REST ingest and read API, authed by an API key. What the reporter, CLI, and MCP server call.

kinora’s public API is plain REST, so any CI provider, curl, or language can push results and read them back. The reporter and CLI call the ingest endpoints for you; the MCP server calls the read endpoints. Use this reference when you want a custom integration.

Base URL

  • Cloud: https://api.kinora.dev
  • Self-host: your server’s origin.

Authentication

Every request needs an API token in the Authorization header:

Authorization: Bearer <token>

Create a token in the dashboard under Settings → Workspace. The token is scoped to the workspace that owns it; all requests read and write only that workspace’s data.

Requests are rate-limited per client IP (INGEST_RATE_LIMIT, default 600/min).

Ingest

POST /api/v1/runs

Upload one normalized run. The project is created automatically the first time its slug is seen. The body is the normalized run JSON that @kinora/core produces (the reporter and CLI build it); its top-level shape:

{
  "project": { "slug": "web-app", "name": "Web App" },
  "run": {
    "startedAt": "2026-07-18T10:00:00.000Z",
    "duration": 42000,
    "counts": { "total": 126, "expected": 120, "unexpected": 2, "flaky": 1, "skipped": 3 },
    "playwrightVersion": "1.50.0",
    "git": { "sha": "…", "branch": "main", "baseBranch": "main", "repoUrl": "…" },
    "ci": { "provider": "github", "runUrl": "…", "runNumber": "42" },
    "shards": 4
  },
  "tests": [{ "testKey": "…", "title": "…", "status": "expected", "…": "…" }]
}

Query parameters:

Param Effect
backfill=1 Historical import: suppresses alerts and stays unmetered (billing follows run.startedAt).
regression=1 Compute regression vs the base branch and include it in the response.

Response 201:

{ "projectId": "…", "runId": "…", "tests": 126, "runUrl": "https://app.kinora.dev/projects/web-app/runs/…", "regression": { "…": "…" } }

runUrl is always returned; regression only when regression=1 was passed.

Errors: 400 invalid body, including more than 50,000 tests in one run · 401 invalid API key · 402 plan limit reached (cloud free tier) · 413 body over 25 MB.

Sharded runs

To upload each shard of a CI run on its own, add three optional fields to run. Uploads sharing a groupKey land in one run, and shard.current is the slot the upload fills:

{
  "run": {
    "groupKey": "gh-1234567890",
    "attempt": 1,
    "shard": { "current": 2, "total": 4 },
    "…": "…"
  }
}
Field Meaning
groupKey Identifies the CI run, never one attempt of it (the GitHub run id, the GitLab pipeline id). Requires shard. Up to 200 characters.
attempt The CI attempt that produced the upload, 1 by default. A higher attempt for a slot already filled replaces that slot’s tests, and a test that failed on the earlier attempt and passes now is stored as flaky. The same attempt again (a retried request) replaces them as is, and a lower one is ignored.
shard { current, total }, 1-based, from Playwright’s --shard. The first upload sets total.

The response then also carries the run as it stands:

{ "…": "…", "status": "open", "shardProgress": { "landed": 2, "total": 4 }, "counts": { "…": "…" }, "flaky": [{ "testKey": "…", "title": "…", "file": "…" }] }

status is open until every shard landed, then complete. A run still open after KINORA_SHARD_TIMEOUT_MINUTES (default 120, self-host) is closed as incomplete, and a late shard still completes it. counts and flaky cover every shard landed so far; flaky also carries rerunAttempt for a test that only passed on a re-run. Alerts fire once, on completion or when the run is closed incomplete. A re-run of a slot that already landed adds no results to your usage and is never turned away by the free plan’s result limit. regression is only computed once the run is complete. Without groupKey, an upload is a run of its own, as before.

The read routes below return status and shardProgress for a run assembled this way: on the run summaries, and under meta in a full report.

POST /api/v1/runs/:runId/artifacts

Upload a trace.zip (or other binary) for a run, as multipart/form-data. Send the file part plus a testKey field to link it to a test, and an optional name field.

Response 201:

{ "url": "https://…/trace.zip" }

Errors: 404 run not found · 400 no file part · 413 file over 100 MB.

Read

All read routes are GET, Bearer-authed, and scoped to your workspace.

GET /api/v1/projects

Every project with its latest run summary.

{ "projects": [{ "id": "web-app", "name": "Web App", "description": "Customer-facing web app", "latestRun": { "…": "…" } }] }

id is the project slug.

GET /api/v1/projects/:slug/runs

Run summaries, newest first. Query limit (default 50, max 200).

{ "runs": [{ "…": "…" }] }

GET /api/v1/projects/:slug/runs/:runId

The full report for one run. Use latest as :runId for the most recent run. Returns counts, git/CI metadata, and every test with its status and failures.

GET /api/v1/projects/:slug/failures

The failing and flaky tests of a run (defaults to the latest). Query runId to target a specific run.

{ "runId": "…", "startedAt": "…", "counts": { "…": "…" }, "failures": [{ "status": "unexpected", "…": "…" }] }

GET /api/v1/projects/:slug/history

Per-test history over the project’s latest 500 runs: counts, rates, and one point per run.

  • With testKey: that one test, with every point.
  • Without it: every test. Counts and rates still cover all 500 runs, but each test only carries the points its health reads: its last 20 runs and, for a test failing right now, its whole failing streak. Query testKey for a test’s full timeline.
{ "histories": [{ "testKey": "…", "…": "…" }] }

Examples

Upload a run with curl:

curl -X POST https://api.kinora.dev/api/v1/runs \
  -H "Authorization: Bearer $KINORA_TOKEN" \
  -H "Content-Type: application/json" \
  --data @run.json

Read the latest run’s failures:

curl https://api.kinora.dev/api/v1/projects/web-app/failures \
  -H "Authorization: Bearer $KINORA_TOKEN"