CancerIndex — Public API
Base URL: https://www.cancerindex.io/api/v1 (proxied by the web app to the Fastify service on
http://127.0.0.1:8251/v1). Read-only. JSON only.
The reference documentation is generated from the route schemas and is always current:
- Swagger UI:
/v1/docs - OpenAPI 3.1:
/v1/openapi.json
This page only explains the conventions and gives examples. Do not edit endpoint descriptions here;
change the zod schemas in apps/api/src/routes/*.ts (CLAUDE.md §357).
Conventions
Envelope
Every /v1 response is wrapped:
{
"data": …,
"sources": [{ "id": "CI-SOURCE-00000001", "slug": "oncotree", "name": "OncoTree", "license": "CC BY 4.0", "attribution": "OncoTree (MSKCC), CC BY 4.0, https://oncotree.mskcc.org", "url": "https://oncotree.mskcc.org" }],
"dataRelease": "CancerIndex 2026-09",
"generatedAt": "2026-09-08T09:33:19.433Z",
"total": 865, "limit": 50, "offset": 0, "hasMore": true
}sources are the distinct upstream sources behind data (license and attribution included so you
can comply with redistribution terms). Pagination fields appear on list endpoints; limit ≤ 200.
Identifiers
Entities are addressed by their public id (CI-CAN-00000364) or a human reference: cancer slug
(lung-adenocarcinoma), HGNC symbol (BRAF, aliases accepted), variant slug, drug slug, NCT id,
PMID. Merged cancers redirect to the surviving entity.
Errors
{ "error": { "code": "not_found", "message": "cancer \"foo\" not found" }, "requestId": "…" }Codes: bad_request (400, with zod details), unauthorized (401), not_found (404),
rate_limited (429), service_unavailable (503), internal (500). Every response carries
x-request-id; quote it when reporting a problem.
Rate limits and API keys
Anonymous: 60 requests/minute per IP. With Authorization: Bearer <key>: the key's own limit
(api_keys.rate_limit_per_minute). Headers: x-ratelimit-limit, x-ratelimit-remaining,
x-ratelimit-reset, retry-after on 429. Mint a key:
pnpm --filter @cancerindex/api create-key -- --label "Lab X" --email lab@example.org --tier research --rpm 600Only the sha256 hash and a prefix are stored; the key is printed once.
Scientific labels
Payload objects that carry a number also carry the category (observed_data, curated_evidence,
regulatory_status, computed_metric) and a provenance object (source, dataset, version,
retrievedAt, URL). Missing data is null or an empty list — never 0 substituted for "unknown".
counters: null means counters have not been computed yet.
Endpoints (summary)
| Method + path | Purpose |
|---|---|
GET /healthz |
{ ok, dataRelease, db } |
GET /v1/cancers |
list; q, `level=top |
GET /v1/cancers/:id |
entity + aliases + codes + hierarchy (parents/children per hierarchy type, breadcrumbs) + anatomy + counters + completeness + current rankings + last 20 changes |
GET /v1/cancers/:id/statistics |
epidemiology observations with per-row provenance + charting series; filters metric, geography, sex |
GET /v1/cancers/:id/survival |
survival observations with provenance |
GET /v1/cancers/:id/genes |
cohort frequencies (denominators) + curated evidence per gene, cancer + descendants |
GET /v1/cancers/:id/variants |
variants with evidence counts by level/direction/type |
GET /v1/cancers/:id/drugs |
evidence by drug (level/direction/significance) + jurisdiction-aware approvals |
GET /v1/cancers/:id/trials |
trials for the cancer and descendants; status, phase, interventionalOnly |
GET /v1/cancers/:id/publications |
publication edges + literature count windows (query stored) |
GET /v1/genes · GET /v1/genes/:symbol |
genes; detail with cancers (curated + cohort), variants, drugs, literature |
GET /v1/variants/:id |
variant, ClinVar interpretations, CIViC evidence grouped by cancer, edges |
GET /v1/drugs · GET /v1/drugs/:id |
drugs; detail with evidence by cancer, approvals, trials |
GET /v1/trials · GET /v1/trials/:nct |
trial search (q, status, phase, cancer, country); detail with condition/intervention mappings, locations (200), publications |
GET /v1/publications/:pmid |
publication + entity edges (abstract truncated, links) |
GET /v1/rankings/metrics |
metric catalog with available scopes |
GET /v1/rankings |
metric, geography, sex, age, year, level; snapshot metadata + metric definition + rows |
GET /v1/rankings/:metric/:cancerId/explain |
"Why this rank?": inputs, previous rank, neighbours, lineage trace |
GET /v1/search?q= |
cross-entity search, ≤ 20 typed results, exact > alias > prefix > fuzzy |
GET /v1/sources · GET /v1/sources/:slug |
registry, license status, connector health, runs, counts |
GET /v1/stats |
live counts (60 s cache) |
GET /v1/changes |
change events; entityType, entityId, kind, since |
GET /v1/epidemiology · /epidemiology/coverage · /epidemiology/metrics |
time-aware observations with provenance (metric, cancer ≤ 8, geography, sex, age, from, to, source, estimateType); coverage matrix; metrics present |
GET /v1/trials/intelligence · /trials/intelligence/:cancer |
derived per-cancer trial metrics (`level=top |
GET /v1/trials/terminated |
terminated / withdrawn / suspended studies with registrant-stated reason and keyword category (cancer, reason, status, since) |
GET /v1/trials/sites |
country (or city) aggregates of trial sites (cancer top-level, phase, recruiting, `level=country |
GET /v1/research-gap · /research-gap/scopes |
Research Gap components per burden scope (geography, year, sex, source): deaths, trials, publications, shares, log₂ ratios, per-1,000-deaths intensities |
GET /v1/approvals · /approvals/recent |
jurisdiction-aware approval records (authority, jurisdiction, cancer, drug, status, from, to, q); recent feed grouped by month |
GET /v1/pipeline · /pipeline/summary |
drug development stage per drug / per drug × top-level cancer (cancer, stage, drug) — formula ci-drug-pipeline-v1 |
GET /v1/graph/:type/:id · /graph/cancer/:id/paths |
knowledge-graph neighbourhood (source-native edges + derived registry links; limit, rel, context, includeDerived) and cancer → gene → variant → drug → approval → trial chains |
GET/POST /v1/admin/* |
operators only (x-admin-token): connectors, run/pause/resume, runs/:runId, unresolved + resolve, trace, jobs/counters, jobs/rank, audit |
Examples
API=http://127.0.0.1:8251
# health
curl -s $API/healthz
# homepage ticker
curl -s $API/v1/stats | jq .data
# cancers whose name/alias starts with "glio", sorted by active trials
curl -s "$API/v1/cancers?q=glio&sort=active_trials&limit=5" | jq '.data[] | {id, slug, name, counters}'
# one cancer with hierarchy and current rankings
curl -s $API/v1/cancers/lung-adenocarcinoma | jq '.data | {name, primaryNcitCode, hierarchy: .hierarchy.parents, rankings}'
# epidemiology observations with provenance for a top-level cancer, US only
curl -s "$API/v1/cancers/lung-cancer/statistics?geography=USA&sex=all" | jq '.data.series[0]'
# trials recruiting for a cancer and its subtypes
curl -s "$API/v1/cancers/lung-adenocarcinoma/trials?status=RECRUITING&limit=10" | jq '.data[] | {nctId, briefTitle, phases}'
# a gene and its cancers
curl -s $API/v1/genes/BRAF | jq '.data | {symbol, counters, cancers: .cancers.curatedEvidence[0:3]}'
# a variant: evidence separated by cancer
curl -s $API/v1/variants/braf-v600e | jq '.data.evidenceByCancer[] | {cancer: .cancer.name, acceptedItems}'
# ranking: active trials, top-level set
curl -s "$API/v1/rankings?metric=active_trials&level=top&limit=10" | jq '{snapshot: .data.snapshot, top: [.data.rows[] | {rank, name: .cancer.name, value}]}'
# why this rank? (lineage down to raw records)
curl -s "$API/v1/rankings/active_trials/lung-cancer/explain?level=top" | jq '.data | {rank, previousRank, inputs, trace}'
# search
curl -s "$API/v1/search?q=glio" | jq '.data[] | {type, name, match}'
# sources and license status
curl -s $API/v1/sources | jq '.data[] | {slug, licenseStatus, status, health: .connector.health, records: .counts.sourceRecords}'
# authenticated request (higher limit)
curl -s -H "Authorization: Bearer cix_…" $API/v1/stats -D - -o /dev/null | grep -i x-ratelimit
# admin: enqueue a connector run, then watch it
curl -s -X POST -H "x-admin-token: $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"mode":"incremental","maxMinutes":30,"reason":"manual refresh"}' $API/v1/admin/connectors/clinicaltrials/run
curl -s -H "x-admin-token: $ADMIN_TOKEN" $API/v1/admin/connectors | jq '.data[] | {connectorId, health, lastSuccessAt, recentRuns: .recentRuns[0].status}'
# admin: curation queue and resolution
curl -s -H "x-admin-token: $ADMIN_TOKEN" "$API/v1/admin/unresolved?entityKind=cancer&limit=20"
curl -s -X POST -H "x-admin-token: $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"cancerId":"CI-CAN-00000364","reason":"synonym per NCIt"}' $API/v1/admin/unresolved/123/resolveResolving an unresolved label adds a curated cancer_aliases row (alias_type = synonym,
source_terminology = curation, source_id = the label's source), re-maps trial_conditions rows
that carry the same normalised text, writes audit_log and a change_events row; every other
connector picks the alias up on its next run through CancerResolver.
Local development
pnpm dev:api # tsx watch, http://127.0.0.1:8251
pnpm --filter @cancerindex/api test # unit tests + read-only smoke tests (skipped if DB unreachable)
pnpm --filter @cancerindex/api typecheck