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
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 carryschema_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.
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:
{
"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.
| Field | Type | Default | Meaning |
|---|---|---|---|
query | string (required) | none | The question in plain English, 1 to 1,000 characters after trimming. |
drug_name | string 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. |
condition | string or string[] | [] | Conditions or diseases. |
sponsor | string or string[] | [] | Sponsor organisations (see sponsor_role). |
country | string or string[] | [] | Countries with at least one trial site. |
trial_phase | string[] | [] | Phases: EARLY_PHASE1, PHASE1 to PHASE4, NA. Labels such as "Phase 3" are accepted too (case and punctuation are ignored). |
status | string[] | [] | Overall status codes such as RECRUITING or COMPLETED, or their labels ("Recruiting"). The full list is in /v1/options. |
start_year | integer or null | null | Earliest start year, 1900 to 10 years from now. |
end_year | integer or null | null | Latest start year. Must not be before start_year. |
compare | object[] | [] | 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_type | string or null | null | Force 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_n | integer or null | null | Keep 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_datum | integer | 5 | How 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_unknown | boolean | true | Show 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_entity | boolean | false | Networks 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:
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:
| Key | Meaning |
|---|---|
status | ok, or one of the non-chart statuses. |
visualization | The chart spec, or null when status isn't ok. |
meta | How the answer was produced: filters, assumptions, counting rule, the API queries run. |
result_id | Identifies 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:
{ "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.
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
| type | Channels | How to draw it |
|---|---|---|
bar_chart | x (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_chart | x, 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_series | x (temporal, time_unit: "year"), y, optional color | One 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. |
histogram | x (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_plot | x, y (quantitative), id (nct_id) | One point per trial that reports both values. estimated: true marks a point built from an estimated value. |
network_graph | node_id, node_label, node_size, node_color, edge_source, edge_target, edge_width | data 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. |
metric | value (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:
"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 firstcitations_per_datumof them, in a fixed order (most recent start date first, then NCT ID), or all of them withcitations: "full".citations_truncated:truewhen 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:
// 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
| Field | Meaning |
|---|---|
interpretation | How 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). |
filters | The filters actually applied, after merging your fields with the question, under normalised names (drugs, conditions, statuses, start_year_min, groups...). |
assumptions | Readings the AI had to choose, in plain sentences. Show them to users. |
warnings | Things to know, such as a field overriding the question, or a chart type that didn't fit. |
counting_rule | One sentence on what each value counts, e.g. "Each trial is counted once per country where it has at least one site." |
unique_trialsadditive | unique_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. |
excluded | Trials left out and why, e.g. { "reason": "start after 2026", "count": 7 }. |
notes | Context for the reader, e.g. "Showing the top 10 of 69 countries." |
api_queries | Every ClinicalTrials.gov API v2 query that was run, with its total_count (and group for comparisons). Open the URL to check the numbers yourself. |
sortcitations | How 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_overlapnetwork | Comparisons: trials shared by each pair of groups. Networks: node and edge counts before and after pruning, and the community algorithm. |
data_timestampretrieved_atcache | The ClinicalTrials.gov data version, when it was read, and whether the plan and result came from cache (hit, miss, revalidated). |
details | Status-specific details for non-chart answers (see statuses). |
trace_idguardplannernarrationusagetimings_ms | trace_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.
| status | When |
|---|---|
ok | A chart: visualization is set. |
no_matching_trials | The filters are valid but match no trials. meta.details.search_counts lists each search with its count. |
out_of_scope | The 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_interpret | No 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": {
"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"
}
}| HTTP | code | When |
|---|---|---|
| 422 | invalid_request | The body failed validation: wrong type, out of range, or an unknown field. details lists each problem as { loc, msg }. |
| 400 | rejected_by_policy | The input safety check rejected the request. |
| 401 | unauthorized | GET /v1/usage without the admin token. |
| 404 | not_found | Unknown job_id, result_id or data point key, or a path that doesn't exist. |
| 405 | method_not_allowed | The path exists but not with this method, e.g. GET /v1/query. The Allow header lists the right one. |
| 410 | expired | The result, or the finished job, is past its retention (24 hours). Run the query again. |
| 429 | too_many_requests | Too many requests from your IP (with a Retry-After header in seconds), or too many background jobs at once. |
| 503 | upstream_unavailable | ClinicalTrials.gov kept failing after retries, or its data changed mid-read. Retry later. |
| 503 | llm_unavailable | The AI model kept failing, fallback model included. |
| 503 | safety_check_unavailable | The input safety check failed; requests are refused rather than let through. |
| 503 | budget_exceeded | Today's AI budget is spent and the question needs an AI call. Repeats of answered questions still work (see limits). |
| 500 | internal_error | A 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:
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}
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
}statusgoespending→running→succeededorfailed, orcancelledif you cancel it (below).progressis{}until retrieval starts, then counts pages:pages_doneofpages_total.- On
succeeded,resultis the normal response body, exactly as a 200 would have returned it. - On
failed,erroris{ 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}
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
pendingorrunningjob becomescancelled, witherror.codecancelled. Its worker stops within about a second, before the next ClinicalTrials.gov page;progressshows where it stopped. - A job that has already finished (
succeeded,failedorcancelled) comes back unchanged, so cancelling twice is safe. An unknownjob_idis404 not_found. - Anyone with the
job_idcan 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
| Parameter | Meaning |
|---|---|
datum | Required. The data point's key, e.g. PHASE1, pembrolizumab|PHASE2 or drug:ipilimumab|drug:nivolumab. |
page_size | 1 to 100, default 50. |
cursor | Leave it out for the first page; then pass the previous page's next_cursor. |
curl -s 'https://api.cheiron.engineeradriel.com/v1/results/d70db059-8f32-49a2-9fdc-231f5e3fd056/citations?datum=PHASE1&page_size=50'{
"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.
curl -s https://api.cheiron.engineeradriel.com/v1/options{
"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
| Limit | Value | When it's reached |
|---|---|---|
| Requests per client IP | 300 per minute, on every /v1 path | 429 too_many_requests with Retry-After. |
| Background jobs per client IP | 10 pending or running | 429 too_many_requests on the next broad question. |
| Daily AI budget | 100 USD of model spend per UTC day, for everyone | Questions 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.gov | 40 requests per minute, 2 at a time, shared by all users | Requests wait their turn; broad questions become background jobs. |
| Answer in the request | 60 seconds (estimated) | Longer questions become background jobs. |
| Result retention | 24 hours | 410 expired from citations paging and job polling. |
| Full citations | 2,000 per response | Over 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.