Searching ClinicalTrials.gov (v2 REST API)
Query ClinicalTrials.gov — the U.S. registry of clinical studies — for trials
matching a condition, intervention, and recruitment status. This skill uses the
modern v2 REST API (/api/v2/studies), which returns structured JSON and
paginates with an opaque cursor (pageToken), not page numbers.
The v2 API is fully public: no API key, no registration, no license barrier.
The legacy v1/classic API and the older query_term-style endpoints are
deprecated — do not build on them.
When to use
- OpenMed extracted a diagnosis ("metastatic colorectal cancer") or a drug
("pembrolizumab") and you want open trials for it.
- You are building a patient-to-trial matching feature and need candidate studies
before applying eligibility logic (
parsing-trial-eligibility).
- You need a corpus of trial records (eligibility text, outcomes) to feed back
into
openmed.analyze_text for biomedical NER.
If you already have an NCT number, fetch the single study directly
(/api/v2/studies/NCT01234567) instead of searching.
Quick start (real v2 API call)
Base URL: https://clinicaltrials.gov/api/v2. No auth. JSON by default.
import requests
BASE = "https://clinicaltrials.gov/api/v2"
def search_trials(condition: str, intervention: str | None = None,
status: str = "RECRUITING", page_size: int = 50) -> dict:
"""One page of studies for a condition (+ optional intervention)."""
params = {
"query.cond": condition, # condition / disease search
"filter.overallStatus": status, # comma-separated enum values
"pageSize": min(page_size, 1000), # max 1000; default 10
"countTotal": "true", # include totalCount on first page
"format": "json",
}
if intervention:
params["query.intr"] = intervention # drug / intervention search
r = requests.get(f"{BASE}/studies", params=params, timeout=30)
r.raise_for_status()
return r.json()
data = search_trials("breast cancer", intervention="trastuzumab")
print(data["totalCount"]) # total matches (first page only)
for study in data["studies"]:
ps = study["protocolSection"]
nct = ps["identificationModule"]["nctId"]
title = ps["identificationModule"]["briefTitle"]
print(nct, "-", title)
Equivalent cURL:
curl "https://clinicaltrials.gov/api/v2/studies?query.cond=breast+cancer\
&query.intr=trastuzumab&filter.overallStatus=RECRUITING&pageSize=50&format=json"
Response shape
Top level: studies (array), nextPageToken (present only if more results),
and totalCount (only when countTotal=true, on the first page). Each study is
a protocolSection of typed modules:
| Field path |
Meaning |
identificationModule.nctId |
NCT........ study id |
identificationModule.briefTitle |
short title |
statusModule.overallStatus |
RECRUITING, COMPLETED, … |
conditionsModule.conditions |
list of condition strings |
armsInterventionsModule.interventions |
drugs / procedures |
eligibilityModule.eligibilityCriteria |
free-text inclusion/exclusion |
eligibilityModule.sex / minimumAge / maximumAge |
demographic gates |
contactsLocationsModule.locations |
recruiting sites |
Cursor pagination
There are no page numbers. Loop until nextPageToken is absent. The token is
opaque — pass it back verbatim. Do not re-send countTotal after page 1.
def iter_all(condition: str, status: str = "RECRUITING"):
params = {"query.cond": condition, "filter.overallStatus": status,
"pageSize": 1000, "format": "json"}
while True:
r = requests.get(f"{BASE}/studies", params=params, timeout=30)
r.raise_for_status()
page = r.json()
yield from page.get("studies", [])
token = page.get("nextPageToken")
if not token:
break
params["pageToken"] = token # cursor for the next page
Trimming payloads
Default responses are large. Restrict to the fields you need with fields (dotted
paths or module names) to cut bandwidth:
params["fields"] = ("NCTId,BriefTitle,OverallStatus,"
"Condition,EligibilityCriteria")
Workflow
- Build the query from OpenMed facts. Map extracted Disease spans →
query.cond; Pharmaceutical spans → query.intr. Free-text keywords go in
query.term. Combine status filters as filter.overallStatus=RECRUITING,NOT_YET_RECRUITING.
- Page through with the cursor until
nextPageToken is gone; cap total pulls.
- Persist
nctId, status, conditions, interventions, and the raw eligibility
text. Eligibility goes to parsing-trial-eligibility.
- Optionally re-NER the eligibility / outcomes text with
openmed.analyze_text to structure inclusion criteria.
Hand-off to / from OpenMed
- From OpenMed → trial search.
openmed.analyze_text(note, model_name="disease_detection_superclinical")
yields Disease and Pharmaceutical entities. Use the surface forms (or a grounded
term from coding-icd10 / normalizing-rxnorm) as query.cond / query.intr.
- Trial text → OpenMed. Feed
eligibilityModule.eligibilityCriteria and brief
summaries back through openmed.analyze_text to extract conditions, meds, and
labs mentioned in the criteria. Then hand to parsing-trial-eligibility for
inclusion/exclusion matching against patient facts.
- Keep patient data local. The API call carries only the query terms
(condition/drug names), never the patient note or any PHI.
Edge cases & gotchas
- Synonyms & spelling. The condition matcher is fuzzy but not infinite —
"MI" will not match "myocardial infarction". Normalize OpenMed output first
(ICD-10 / RxNorm) and consider issuing a few synonym variants.
- Status enums are exact. Valid values include
RECRUITING,
NOT_YET_RECRUITING, ENROLLING_BY_INVITATION, ACTIVE_NOT_RECRUITING,
COMPLETED, SUSPENDED, TERMINATED, WITHDRAWN, UNKNOWN. Comma-separate;
do not lowercase.
totalCount is first-page only. Request countTotal=true once; it is not
repeated on subsequent pages.
- Page size cap is 1000. Larger values are silently clamped.
- Rate limits. No key required, but throttle politely (a short sleep between
pages); aggressive scraping can be blocked. For bulk/offline work, consider the
full registry data dump rather than thousands of paged calls.
pageToken expires if the underlying index shifts; restart the query if a
token is rejected.
- Not medical advice. A trial appearing in results does not mean the patient
qualifies — eligibility is decided downstream and reviewed by a clinician.
Standards & references
1---2name: searching-clinicaltrials3description: Searches ClinicalTrials.gov for studies by condition, intervention, and recruitment status using the modern v2 REST API with cursor (pageToken) pagination. Use when the user wants to find trials for a diagnosis or drug, screen patients against open studies, build a trial-matching feature, or pull a trial corpus for analysis. Trigger keywords: clinical trial, ClinicalTrials.gov, NCT number, trial search, recruiting studies, eligibility, query.cond, query.intr, pageToken, v2 API. Pairs adjacent to OpenMed: take Disease/Pharmaceutical entities from openmed.analyze_text and turn them into query.cond / query.intr filters; the returned eligibility text feeds parsing-trial-eligibility. ClinicalTrials.gov API v2 is fully public — no API key, no license.4license: Apache-2.05---67# Searching ClinicalTrials.gov (v2 REST API)89Query **ClinicalTrials.gov** — the U.S. registry of clinical studies — for trials10matching a condition, intervention, and recruitment status. This skill uses the11**modern v2 REST API** (`/api/v2/studies`), which returns structured JSON and12paginates with an opaque cursor (`pageToken`), not page numbers.1314The v2 API is **fully public**: no API key, no registration, no license barrier.15The legacy v1/classic API and the older `query_term`-style endpoints are16deprecated — do not build on them.1718## When to use1920- OpenMed extracted a diagnosis ("metastatic colorectal cancer") or a drug21 ("pembrolizumab") and you want open trials for it.22- You are building a patient-to-trial matching feature and need candidate studies23 before applying eligibility logic (`parsing-trial-eligibility`).24- You need a corpus of trial records (eligibility text, outcomes) to feed back25 into `openmed.analyze_text` for biomedical NER.2627If you already have an NCT number, fetch the single study directly28(`/api/v2/studies/NCT01234567`) instead of searching.2930## Quick start (real v2 API call)3132Base URL: `https://clinicaltrials.gov/api/v2`. No auth. JSON by default.3334```python35import requests3637BASE = "https://clinicaltrials.gov/api/v2"3839def search_trials(condition: str, intervention: str | None = None,40 status: str = "RECRUITING", page_size: int = 50) -> dict:41 """One page of studies for a condition (+ optional intervention)."""42 params = {43 "query.cond": condition, # condition / disease search44 "filter.overallStatus": status, # comma-separated enum values45 "pageSize": min(page_size, 1000), # max 1000; default 1046 "countTotal": "true", # include totalCount on first page47 "format": "json",48 }49 if intervention:50 params["query.intr"] = intervention # drug / intervention search51 r = requests.get(f"{BASE}/studies", params=params, timeout=30)52 r.raise_for_status()53 return r.json()5455data = search_trials("breast cancer", intervention="trastuzumab")56print(data["totalCount"]) # total matches (first page only)57for study in data["studies"]:58 ps = study["protocolSection"]59 nct = ps["identificationModule"]["nctId"]60 title = ps["identificationModule"]["briefTitle"]61 print(nct, "-", title)62```6364Equivalent cURL:6566```bash67curl "https://clinicaltrials.gov/api/v2/studies?query.cond=breast+cancer\68&query.intr=trastuzumab&filter.overallStatus=RECRUITING&pageSize=50&format=json"69```7071## Response shape7273Top level: `studies` (array), `nextPageToken` (present only if more results),74and `totalCount` (only when `countTotal=true`, on the first page). Each study is75a `protocolSection` of typed modules:7677| Field path | Meaning |78| --- | --- |79| `identificationModule.nctId` | `NCT........` study id |80| `identificationModule.briefTitle` | short title |81| `statusModule.overallStatus` | `RECRUITING`, `COMPLETED`, … |82| `conditionsModule.conditions` | list of condition strings |83| `armsInterventionsModule.interventions` | drugs / procedures |84| `eligibilityModule.eligibilityCriteria` | free-text inclusion/exclusion |85| `eligibilityModule.sex` / `minimumAge` / `maximumAge` | demographic gates |86| `contactsLocationsModule.locations` | recruiting sites |8788## Cursor pagination8990There are **no page numbers**. Loop until `nextPageToken` is absent. The token is91opaque — pass it back verbatim. Do not re-send `countTotal` after page 1.9293```python94def iter_all(condition: str, status: str = "RECRUITING"):95 params = {"query.cond": condition, "filter.overallStatus": status,96 "pageSize": 1000, "format": "json"}97 while True:98 r = requests.get(f"{BASE}/studies", params=params, timeout=30)99 r.raise_for_status()100 page = r.json()101 yield from page.get("studies", [])102 token = page.get("nextPageToken")103 if not token:104 break105 params["pageToken"] = token # cursor for the next page106```107108### Trimming payloads109110Default responses are large. Restrict to the fields you need with `fields` (dotted111paths or module names) to cut bandwidth:112113```python114params["fields"] = ("NCTId,BriefTitle,OverallStatus,"115 "Condition,EligibilityCriteria")116```117118## Workflow1191201. **Build the query from OpenMed facts.** Map extracted Disease spans →121 `query.cond`; Pharmaceutical spans → `query.intr`. Free-text keywords go in122 `query.term`. Combine status filters as `filter.overallStatus=RECRUITING,NOT_YET_RECRUITING`.1232. **Page through** with the cursor until `nextPageToken` is gone; cap total pulls.1243. **Persist** `nctId`, status, conditions, interventions, and the raw eligibility125 text. Eligibility goes to `parsing-trial-eligibility`.1264. **Optionally re-NER** the eligibility / outcomes text with127 `openmed.analyze_text` to structure inclusion criteria.128129## Hand-off to / from OpenMed130131- **From OpenMed → trial search.** `openmed.analyze_text(note, model_name="disease_detection_superclinical")`132 yields Disease and Pharmaceutical entities. Use the surface forms (or a grounded133 term from `coding-icd10` / `normalizing-rxnorm`) as `query.cond` / `query.intr`.134- **Trial text → OpenMed.** Feed `eligibilityModule.eligibilityCriteria` and brief135 summaries back through `openmed.analyze_text` to extract conditions, meds, and136 labs mentioned in the criteria. Then hand to `parsing-trial-eligibility` for137 inclusion/exclusion matching against patient facts.138- Keep patient data local. The API call carries only the **query terms**139 (condition/drug names), never the patient note or any PHI.140141## Edge cases & gotchas142143- **Synonyms & spelling.** The condition matcher is fuzzy but not infinite —144 "MI" will not match "myocardial infarction". Normalize OpenMed output first145 (ICD-10 / RxNorm) and consider issuing a few synonym variants.146- **Status enums are exact.** Valid values include `RECRUITING`,147 `NOT_YET_RECRUITING`, `ENROLLING_BY_INVITATION`, `ACTIVE_NOT_RECRUITING`,148 `COMPLETED`, `SUSPENDED`, `TERMINATED`, `WITHDRAWN`, `UNKNOWN`. Comma-separate;149 do not lowercase.150- **`totalCount` is first-page only.** Request `countTotal=true` once; it is not151 repeated on subsequent pages.152- **Page size cap is 1000.** Larger values are silently clamped.153- **Rate limits.** No key required, but throttle politely (a short sleep between154 pages); aggressive scraping can be blocked. For bulk/offline work, consider the155 full registry data dump rather than thousands of paged calls.156- **`pageToken` expires** if the underlying index shifts; restart the query if a157 token is rejected.158- **Not medical advice.** A trial appearing in results does not mean the patient159 qualifies — eligibility is decided downstream and reviewed by a clinician.160161## Standards & references162163- ClinicalTrials.gov API v2 — https://clinicaltrials.gov/data-api/api164- Study data structure (modules / field paths) —165 https://clinicaltrials.gov/data-api/about-api/study-data-structure166- Search areas & query syntax —167 https://clinicaltrials.gov/data-api/about-api/search-areas168- OpenAPI / interactive reference — https://clinicaltrials.gov/api/v2/