# 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: ```json { "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 ```json { "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 `: 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: ```bash pnpm --filter @cancerindex/api create-key -- --label "Lab X" --email lab@example.org --tier research --rpm 600 ``` Only 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|all`, `type`, `malignant`, `hematologic`, `pediatric`, `rare`, `sort=name|active_trials|publications_5y`, `limit`, `offset` | | `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|all`, `sort`, `order`, `minActive`): counts, growth YoY, enrollment, sponsor/country HHI, termination share, trials per 1,000 deaths — formula `ci-trial-intel-v1` | | `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|city`) | | `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 ```bash 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/resolve ``` Resolving 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 ```bash 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 ```