Clinical Trials Explorer

Developer guide

API documentation

Send a question about ClinicalTrials.gov, get back a chart spec your code can draw, where every number links to the trials behind it.

On this page
  1. Overview
  2. Quick start
  3. The request
  4. The response
  5. Statuses and errors
  6. Background jobs
  7. Citations paging
  8. Options
  9. Limits
  10. Interactive reference

Overview

The API reads a plain-English question, such as "Which countries have the most recruiting trials for lung cancer?", plans the matching ClinicalTrials.gov searches, runs them live, and counts the results. It returns a visualization spec: the chart type, a title, which data field goes on which axis, and the data itself.

Every data point carries citations: the trials it counts, with the exact record field and value that put each trial there. Nothing in the chart is a number you have to take on trust.

Base URL
https://api.cheiron.engineeradriel.com
Format
JSON in, JSON out. No authentication.
Version
Paths start with /v1; responses carry schema_version (now "2.0").

The production API accepts browser calls only from this web app (CORS). Call it from a server, a script or a notebook.

Quick start

Ask a question. Most answers take 5 to 40 seconds.

Request
curl -s https://api.cheiron.engineeradriel.com/v1/query \
  -H 'Content-Type: application/json' \
  -d '{
  "query": "How are breast cancer trials distributed across phases?",
  "citations_per_datum": 3
}'

A real response, trimmed to one of its nine bars, one citation, and part of meta:

Response 200
{
  "schema_version": "2.0",
  "result_id": "d70db059-8f32-49a2-9fdc-231f5e3fd056",
  "status": "ok",
  "visualization": {
    "type": "bar_chart",
    "title": "ClinicalTrials.gov breast cancer trials by phase",
    "encoding": {
      "x": { "field": "phase", "type": "ordinal", "label": "Phase",
             "unit": null, "sort": "natural", "time_unit": null },
      "y": { "field": "trial_count", "type": "quantitative", "label": "Number of trials",
             "unit": "trials", "sort": null, "time_unit": null }
    },
    "data": [
      {
        "key": "PHASE1",
        "phase": "Phase 1",
        "trial_count": 1619,
        "citation_count": 1619,
        "citations": [
          {
            "nct_id": "NCT07454668",
            "url": "https://clinicaltrials.gov/study/NCT07454668",
            "title": "Trastuzumab Deruxtecan + Stereotactic Radiosurgery (SRS) in HER2+ Breast Cancer Brain Metastases",
            "evidence": [{ "field": "protocolSection.designModule.phases[0]", "excerpt": "PHASE1" }]
          }
        ],
        "citations_truncated": true
      }
    ]
  },
  "meta": {
    "interpretation": { "intent": "distribution", "chart_type": "bar_chart", "dimension": "phase",
                        "dimension_2": null, "measure": "trial_count", "chart_type_source": "ai" },
    "filters": { "conditions": ["breast cancer"] },
    "assumptions": ["Interpreting 'breast cancer trials' as trials with condition 'breast cancer'."],
    "warnings": [],
    "notes": [],
    "counting_rule": "Each trial is counted once; trials with two phases get a combined bucket (e.g. Phase 2/Phase 3).",
    "additive": true,
    "unique_trials": 16765,
    "excluded": [],
    "api_queries": [
      { "group": null, "url": "https://clinicaltrials.gov/api/v2/studies?query.cond=%22breast+cancer%22", "total_count": 16765 }
    ],
    "data_timestamp": "2026-10-23T09:00:04",
    "trace_id": "f99e189b-ed92-498b-909d-51252c63d1cd"
  }
}

To draw it: put data[i].phase (from encoding.x.field) on the x axis and data[i].trial_count (from encoding.y.field) on the y axis. To list every trial in the Phase 1 bar, page through its citations with result_id and the bar's key.

The request

POST/v1/query

Only query is required. The other fields pin down what the question leaves open. Structured fields override the AI's reading of the question: the planner treats them as fixed facts, and if the question disagrees (it says lung cancer, condition says breast cancer) the field wins and meta.warnings records the conflict. Unknown fields are rejected with a 422.

FieldTypeDefaultMeaning
querystring (required)noneThe question in plain English, 1 to 1,000 characters after trimming.
drug_namestring or string[][]Drugs to filter on. Several values mean any of them (OR). Up to 10 terms of up to 200 characters; the same applies to the next three fields.
conditionstring or string[][]Conditions or diseases.
sponsorstring or string[][]Sponsor organisations (see sponsor_role).
countrystring or string[][]Countries with at least one trial site.
trial_phasestring[][]Phases: EARLY_PHASE1, PHASE1 to PHASE4, NA. Labels such as "Phase 3" are accepted too (case and punctuation are ignored).
statusstring[][]Overall status codes such as RECRUITING or COMPLETED, or their labels ("Recruiting"). The full list is in /v1/options.
start_yearinteger or nullnullEarliest start year, 1900 to 10 years from now.
end_yearinteger or nullnullLatest start year. Must not be before start_year.
compareobject[][]2 to 4 groups to compare, each { label, drug_name?, condition?, sponsor?, country? }. Labels (1 to 100 characters) must be unique, and each group needs at least one entity.
chart_typestring or nullnullForce a chart: bar_chart, grouped_bar_chart, time_series, histogram, scatter_plot, network_graph or metric. If it can't show the question, the answer is could_not_interpret.
top_ninteger or nullnullKeep only the top N categories (1 to 50).
match_mode"strict" | "broad""strict"How drugs and conditions match. strict uses the intervention and condition fields (with synonyms); broad also matches titles, keywords and descriptions.
sponsor_role"lead" | "any""lead"lead: the lead sponsor only. any: lead sponsor or collaborator.
citations"sample" | "full""sample"sample: up to citations_per_datum citations inline per data point. full: every citation inline, as long as the whole response has at most 2,000; above that it falls back to a sample, and meta.citations says so (details).
citations_per_datuminteger5How many citations each data point carries inline (1 to 50).
phase_mode"combined" | "split""combined"combined: a Phase 2/3 trial counts once, in a "Phase 2/Phase 3" bucket. split: it counts in both phases.
condition_source"mesh" | "raw""mesh"Group conditions by their MeSH terms, or by the text the sponsor registered.
include_unknownbooleantrueShow a "Not reported" bucket for trials without a value.
edge_metric"count" | "jaccard""count"Networks only: edge width is shared trials, or the Jaccard similarity.
hide_query_entitybooleanfalseNetworks only: drop the node for the entity you asked about (it links to everything).

For example, a comparison of two drugs, limited to recruiting trials that started in 2018 or later:

Request with structured fields
curl -s https://api.cheiron.engineeradriel.com/v1/query \
  -H 'Content-Type: application/json' \
  -d '{
  "query": "Compare phases for these drugs",
  "compare": [
    {
      "label": "Pembrolizumab",
      "drug_name": "pembrolizumab"
    },
    {
      "label": "Nivolumab",
      "drug_name": "nivolumab"
    }
  ],
  "status": [
    "RECRUITING"
  ],
  "start_year": 2018
}'

The response

The envelope

Every 200 response has the same five keys, whatever the chart:

KeyMeaning
statusok, or one of the non-chart statuses.
visualizationThe chart spec, or null when status isn't ok.
metaHow the answer was produced: filters, assumptions, counting rule, the API queries run.
result_idIdentifies the stored result, for citations paging. null when nothing was stored.
schema_version"2.0". The major version changes when a field is removed or changes meaning.

The visualization spec

visualization has type, title, encoding and data. encoding maps each visual channel to a data field, in the style of Vega-Lite:

One encoding channel
{ "field": "trial_count", "type": "quantitative", "label": "Number of trials", "unit": "trials", "sort": null, "time_unit": null }

type is quantitative, nominal, ordinal or temporal; sort is ascending, descending, natural or null; time_unit is "year" on temporal axes.

Render without guessing: read every data key from encoding[channel].field, and every axis title from label and unit. Field names change with the question (phase, country, start_year, total_enrollment...), the channel names don't.

Drawing a bar chart (TypeScript)
const { type, encoding, data } = response.visualization;
const x = encoding.x.field;   // "phase": never hard-code "phase" or "trial_count"
const y = encoding.y.field;   // "trial_count"
const bars = data.map((d) => ({ label: d[x], value: d[y], key: d.key }));
// Axis titles: encoding.x.label, encoding.y.label (+ encoding.y.unit).
// Order: keep the order of data (it is already sorted as meta.sort says).

Chart types

typeChannelsHow to draw it
bar_chartx (nominal or ordinal category), y (quantitative)One bar per row, in the order of data. x.sort is natural (phases, statuses: keep the order) or descending (ranked by y). A "Not reported" bucket, when present, comes last.
grouped_bar_chartx, y, color (the group or series)Long format: one row per category and group. Pivot on color.field (group for compare groups). Keys look like pembrolizumab|PHASE2.
time_seriesx (temporal, time_unit: "year"), y, optional colorOne line per color value (or a single line). Years are strings ("2015") and zero-filled. estimated_count per row is how many of its trials have an anticipated, not actual, date.
histogramx (bin_start), y (count)Each row is a bin from bin_start to bin_end. The last bin may have is_overflow: true and bin_end: null: everything above the 99th percentile. meta.notes states the bin width.
scatter_plotx, y (quantitative), id (nct_id)One point per trial that reports both values. estimated: true marks a point built from an estimated value.
network_graphnode_id, node_label, node_size, node_color, edge_source, edge_target, edge_widthdata is { nodes, edges } instead of a list. Node colour is community (one entity type) or type (two). Nodes also carry degree and is_query_entity; edges carry both weight and jaccard.
metricvalue (quantitative, with unit)A single number: data has one row, { key: "all", value, label }.

Networks use flat channel names and a { nodes, edges } object. From a real melanoma drug network:

network_graph (trimmed)
"encoding": {
  "node_id":     { "field": "id", "type": "nominal", "label": "Entity" },
  "node_label":  { "field": "label", "type": "nominal", "label": "Name" },
  "node_size":   { "field": "weight", "type": "quantitative", "label": "Trials" },
  "node_color":  { "field": "community", "type": "nominal", "label": "Community" },
  "edge_source": { "field": "source", "type": "nominal", "label": "From" },
  "edge_target": { "field": "target", "type": "nominal", "label": "To" },
  "edge_width":  { "field": "weight", "type": "quantitative", "label": "Shared trials" }
},
"data": {
  "nodes": [
    {
      "key": "drug:pembrolizumab", "id": "drug:pembrolizumab", "label": "pembrolizumab",
      "type": "drug", "weight": 250, "degree": 19, "community": 2, "is_query_entity": false,
      "citation_count": 250, "citations": [...], "citations_truncated": true
    }
  ],
  "edges": [
    {
      "key": "drug:ipilimumab|drug:nivolumab", "source": "drug:ipilimumab", "target": "drug:nivolumab",
      "weight": 122, "jaccard": 0.3873,
      "citation_count": 122, "citations": [...], "citations_truncated": true
    }
  ]
}

Per-datum citations

Every data point (every bar, year, bin, point, node and edge) carries:

  • key: a stable identifier, used to page through its citations.
  • citation_count: how many trials are behind it.
  • citations: the first citations_per_datum of them, in a fixed order (most recent start date first, then NCT ID), or all of them with citations: "full".
  • citations_truncated: true when there are more than shown.

Full citations. With "citations": "full", every data point carries all its citations, as long as the response holds at most 2,000 in total. Above that you get the usual sample instead, and meta.citations says why. Either way, meta.citations reports what you asked for and what you got:

meta.citations
// asked for full, got full
{ "requested": "full", "applied": "full", "per_datum": 5, "reason": null }

// asked for full on a broad question: too many to inline
{ "requested": "full", "applied": "sample", "per_datum": 5,
  "reason": "2,000 citation limit exceeded (17,450 contributing trial references); page through them with the citations endpoint" }

Trials-per-year charts counted year by year only read a sample of each year, so they always answer with a sample (reason starts with counted per year). For any data point, citations paging lists every trial.

Each citation has the trial's nct_id, url and title, and evidence: the record fields that put the trial in this bucket, as { field, excerpt }. field is a path in the ClinicalTrials.gov study record (e.g. protocolSection.designModule.phases[0]) and excerpt is its exact value. The server checks every excerpt against the record it read before answering.

Key meta fields

FieldMeaning
interpretationHow the question was read: intent, chart_type, dimension, dimension_2, measure, and chart_type_source (ai, user when you set chart_type, or fallback when the chart type was swapped for one that fits).
filtersThe filters actually applied, after merging your fields with the question, under normalised names (drugs, conditions, statuses, start_year_min, groups...).
assumptionsReadings the AI had to choose, in plain sentences. Show them to users.
warningsThings to know, such as a field overriding the question, or a chart type that didn't fit.
counting_ruleOne sentence on what each value counts, e.g. "Each trial is counted once per country where it has at least one site."
unique_trialsadditiveunique_trials is the number of distinct trials behind the chart. additive: false means one trial can sit in several buckets, so the values don't add up to it.
excludedTrials left out and why, e.g. { "reason": "start after 2026", "count": 7 }.
notesContext for the reader, e.g. "Showing the top 10 of 69 countries."
api_queriesEvery ClinicalTrials.gov API v2 query that was run, with its total_count (and group for comparisons). Open the URL to check the numbers yourself.
sortcitationsHow data is ordered (by x or y, order), and which citation mode was requested and applied, with the reason when they differ (full citations).
group_overlapnetworkComparisons: trials shared by each pair of groups. Networks: node and edge counts before and after pruning, and the community algorithm.
data_timestampretrieved_atcacheThe ClinicalTrials.gov data version, when it was read, and whether the plan and result came from cache (hit, miss, revalidated).
detailsStatus-specific details for non-chart answers (see statuses).
trace_idguardplannernarrationusagetimings_mstrace_id identifies the request in our logs. guard, planner, narration, usage and timings_ms describe the AI stages, cost and timings. guard.cached: true means a repeat was answered from cache while the daily AI budget was spent, without calling the guard again (see limits).

Statuses and errors

Answers that aren't charts (200)

These are valid answers, not errors: HTTP 200 with visualization: null, and meta explaining why.

statusWhen
okA chart: visualization is set.
no_matching_trialsThe filters are valid but match no trials. meta.details.search_counts lists each search with its count.
out_of_scopeThe question isn't about trial registrations (e.g. "does this drug work?"). meta.details.reason says why, and meta.details.suggestion may offer a question that works.
could_not_interpretNo valid plan could be made, including when a forced chart_type can't show the question. meta.details.errors and meta.details.best_attempt explain.

Errors

Real errors share one body. It never contains internal details; trace_id links it to our logs.

Error 422
{
  "error": {
    "code": "invalid_request",
    "message": "The request is not valid.",
    "details": [{ "loc": ["body", "foo"], "msg": "Extra inputs are not permitted" }],
    "trace_id": "58df5557-da79-4916-a112-8de8098a859d"
  }
}
HTTPcodeWhen
422invalid_requestThe body failed validation: wrong type, out of range, or an unknown field. details lists each problem as { loc, msg }.
400rejected_by_policyThe input safety check rejected the request.
401unauthorizedGET /v1/usage without the admin token.
404not_foundUnknown job_id, result_id or data point key, or a path that doesn't exist.
405method_not_allowedThe path exists but not with this method, e.g. GET /v1/query. The Allow header lists the right one.
410expiredThe result, or the finished job, is past its retention (24 hours). Run the query again.
429too_many_requestsToo many requests from your IP (with a Retry-After header in seconds), or too many background jobs at once.
503upstream_unavailableClinicalTrials.gov kept failing after retries, or its data changed mid-read. Retry later.
503llm_unavailableThe AI model kept failing, fallback model included.
503safety_check_unavailableThe input safety check failed; requests are refused rather than let through.
503budget_exceededToday's AI budget is spent and the question needs an AI call. Repeats of answered questions still work (see limits).
500internal_errorA bug on our side. Quote the trace_id when you report it.

Retry 429 after Retry-After seconds and 503 after a short wait; the others won't succeed unchanged. Every error uses this body, including a path that doesn't exist (404) and a wrong method (405).

Background jobs

Broad questions can need hundreds of pages from ClinicalTrials.gov. When the estimate is over 60 seconds, POST /v1/query answers 202 Accepted instead of making you wait:

Response 202
HTTP/1.1 202 Accepted

{
  "job_id": "5b0d7c3e-…",
  "status": "pending",
  "poll_url": "/v1/jobs/5b0d7c3e-…",
  "estimated_seconds": 240
}

Poll poll_url every second or two (below, $JOB_ID is the job_id from the 202):

GET/v1/jobs/{job_id}

Polling
curl -s https://api.cheiron.engineeradriel.com/v1/jobs/$JOB_ID

{
  "job_id": "5b0d7c3e-…",
  "status": "running",
  "progress": { "stage": "retrieve", "pages_done": 12, "pages_total": 40 },
  "estimated_seconds": 240,
  "result": null,
  "error": null
}
  • status goes pending → running → succeeded or failed, or cancelled if you cancel it (below).
  • progress is {} until retrieval starts, then counts pages: pages_done of pages_total.
  • On succeeded, result is the normal response body, exactly as a 200 would have returned it.
  • On failed, error is { code, message, details, trace_id }, the same fields as an error body. A job that hits a temporary ClinicalTrials.gov or AI failure is retried, up to 3 attempts, before it fails.

Asking the same question again while its job is still running returns the same job. A finished job is kept for 24 hours, like its result; after that, polling returns 410 expired.

No longer need the answer? Cancel the job, so it stops using ClinicalTrials.gov's shared rate limit:

DELETE/v1/jobs/{job_id}

Cancelling
curl -s -X DELETE https://api.cheiron.engineeradriel.com/v1/jobs/$JOB_ID

{
  "job_id": "5b0d7c3e-…",
  "status": "cancelled",
  "progress": { "stage": "retrieve", "pages_done": 12, "pages_total": 40 },
  "estimated_seconds": 240,
  "result": null,
  "error": {
    "code": "cancelled",
    "message": "This job was cancelled.",
    "details": null,
    "trace_id": "…"
  }
}
  • A pending or running job becomes cancelled, with error.code cancelled. Its worker stops within about a second, before the next ClinicalTrials.gov page; progress shows where it stopped.
  • A job that has already finished (succeeded, failed or cancelled) comes back unchanged, so cancelling twice is safe. An unknown job_id is 404 not_found.
  • Anyone with the job_id can cancel the job, just as anyone with it can poll it. It's a random UUID that only the asker receives, so don't share it.
  • Asking the same question after a cancel starts a new job.

A question answered in the request (200) can't be cancelled. If you close the connection early, the server still finishes and stores the answer, so asking the same question again is quick (meta.cache.result is hit). If the question turns out to be broad, its job starts anyway; wait for the 202 if you may want to cancel it.

Citations paging

The response usually carries a sample of each data point's trials. This endpoint lists all of them, in the same order, whatever the citation mode:

GET/v1/results/{result_id}/citations

ParameterMeaning
datumRequired. The data point's key, e.g. PHASE1, pembrolizumab|PHASE2 or drug:ipilimumab|drug:nivolumab.
page_size1 to 100, default 50.
cursorLeave it out for the first page; then pass the previous page's next_cursor.
First page
curl -s 'https://api.cheiron.engineeradriel.com/v1/results/d70db059-8f32-49a2-9fdc-231f5e3fd056/citations?datum=PHASE1&page_size=50'
Response 200 (one citation shown)
{
  "result_id": "d70db059-8f32-49a2-9fdc-231f5e3fd056",
  "datum": "PHASE1",
  "label": "Phase 1",
  "citation_count": 1619,
  "citations": [
    {
      "rank": 0,
      "nct_id": "NCT07454668",
      "url": "https://clinicaltrials.gov/study/NCT07454668",
      "title": "Trastuzumab Deruxtecan + Stereotactic Radiosurgery (SRS) in HER2+ Breast Cancer Brain Metastases",
      "evidence": [{ "field": "protocolSection.designModule.phases[0]", "excerpt": "PHASE1" }]
    }
  ],
  "next_cursor": 50
}

next_cursor is null on the last page. Results are kept for 24 hours; after that this endpoint returns 410 expired.

Options

GET/v1/options

The vocabulary the API understands, for building pickers and checking values: dimensions (what a chart can be broken down by), measures (what it counts), chart types, and the allowed values and labels of trial_phase, status and country.

Request
curl -s https://api.cheiron.engineeradriel.com/v1/options
Response 200 (shape)
{
  "dimensions": [
    { "key": "phase", "label": "Phase", "kind": "…", "multi_valued": false, "counting_rule": "…",
      "values": [{ "value": "PHASE1", "label": "Phase 1" }, …] },
    …
  ],
  "measures": [{ "key": "trial_count", "label": "…", "unit": "…" }, …],
  "chart_types": [{ "key": "bar_chart" }, …],
  "filters": {
    "trial_phase": [{ "value": "PHASE1", "label": "Phase 1" }, …],
    "status": [{ "value": "RECRUITING", "label": "Recruiting" }, …],
    "country": ["Afghanistan", …]
  }
}

Limits

LimitValueWhen it's reached
Requests per client IP300 per minute, on every /v1 path429 too_many_requests with Retry-After.
Background jobs per client IP10 pending or running429 too_many_requests on the next broad question.
Daily AI budget100 USD of model spend per UTC day, for everyoneQuestions that need an AI call get 503 budget_exceeded until midnight UTC. Asking exactly the same question as before still works if its stored answer is valid (meta.cache shows hit or revalidated, with no AI call). Jobs, citations and options keep working.
ClinicalTrials.gov40 requests per minute, 2 at a time, shared by all usersRequests wait their turn; broad questions become background jobs.
Answer in the request60 seconds (estimated)Longer questions become background jobs.
Result retention24 hours410 expired from citations paging and job polling.
Full citations2,000 per responseOver it, citations: "full" falls back to a sample; meta.citations.reason says so.

Interactive reference

The API publishes its OpenAPI schema, generated from the code, so every field and type is listed there too.