Every interventional study on ClinicalTrials.gov lists its sites — facility, city, recruitment status, and, very often, the named principal investigator at each one. Which means the world's most complete map of who runs clinical trials, where, on what is a free public API. If you work in or sell to clinical research, this is your market map:
- CRO / sponsor site selection — which facilities in Florida are actively enrolling MASH patients right now? Those sites have the patients, the equipment, and a competing protocol.
- Investigator / KOL mapping — the named PIs on recruiting oncology trials are the exact people a sponsor, CRO, or medical-affairs team needs on a list.
- Competitive enrollment intel — before you commit a site, count the trials already recruiting the same population in the same metro. Enrollment risk is quantifiable.
- BD prospecting — labs, imaging vendors, ePRO/eCOA platforms, patient-recruitment firms: active sites are your buyers, sorted by indication.
Here's the DIY route against the real API, the three places it gets annoying, and the shortcut.
The DIY: API v2 is genuinely good
No key, no account. Recruiting MASH studies with at least one Florida site:
curl "https://clinicaltrials.gov/api/v2/studies?\
query.cond=NASH&query.locn=Florida&filter.overallStatus=RECRUITING&\
pageSize=100&fields=protocolSection.identificationModule,\
protocolSection.contactsLocationsModule,protocolSection.designModule"
Works today, returns JSON, pages cleanly with pageToken. The contactsLocationsModule is where the value lives — per-site facility, city, state, status, and a contacts array where entries with "role": "PRINCIPAL_INVESTIGATOR" are your named investigators.
The three annoyances
Location filters return studies, not sites.
query.locn=Floridagives you every study that has a Florida site — with the full worldwidelocationsarray attached (large multi-center trials list hundreds of sites). Your "Florida site list" requires walking every study's location array and keeping only the Florida rows yourself. This is the big one: the question is site-shaped, the API is study-shaped.The JSON is deeply nested and inconsistently populated. Everything hides under
protocolSection.<something>Module, and a "simple" flat row means stitching identification, status, design, sponsor, and contacts modules together. PI naming also varies by sponsor type: academic centers usually name investigators (Mayo Clinic trials list PIs with credentials), while some industry sponsors anonymize sites to "89bio Clinical Study Site" with a call-center contact — your pipeline needs to handle both without pretending one is the other.Field selection is its own language. The
fieldsparameter wants exact dotted module paths; get one wrong and it errors (better than silence, but still a docs-diving session). Filters similarly split acrossquery.*(search-y) andfilter.*(exact) families with different semantics —query.cond,query.spons,filter.overallStatus, aggregate filters, and so on.
A workable Python flattener for site-level rows:
import requests
def florida_sites(condition, state="Florida"):
params = {
"query.cond": condition, "query.locn": state,
"filter.overallStatus": "RECRUITING", "pageSize": 100,
}
url = "https://clinicaltrials.gov/api/v2/studies"
while True:
data = requests.get(url, params=params).json()
for study in data.get("studies", []):
ps = study["protocolSection"]
nct = ps["identificationModule"]["nctId"]
title = ps["identificationModule"].get("briefTitle")
for loc in ps.get("contactsLocationsModule", {}).get("locations", []):
if loc.get("state") != state:
continue # the study-shaped-vs-site-shaped problem
pis = [c["name"] for c in loc.get("contacts", [])
if c.get("role") == "PRINCIPAL_INVESTIGATOR"]
yield {"nctId": nct, "title": title,
"facility": loc.get("facility"), "city": loc.get("city"),
"siteStatus": loc.get("status"), "investigators": pis}
if not (token := data.get("nextPageToken")):
break
params["pageToken"] = token
for row in florida_sites("NASH"):
print(row)
Fine for one query. The maintenance shows up when you want this across indications, sponsor classes, and phases, refreshed weekly, in a spreadsheet your feasibility team can actually open.
The shortcut: one input, flat study records with sites and PIs attached
I maintain an Apify actor that wraps the v2 API — module-stitching, paging, and contact extraction included:
{
"condition": "NASH",
"statuses": ["RECRUITING"],
"phases": ["PHASE2", "PHASE3"],
"sponsorClasses": ["INDUSTRY"],
"state": "Florida",
"maxResults": 500
}
Each record is one study, flattened: identification, phase, enrollment, lead sponsor, centralContacts, overallOfficials, and a clean locations array where every site carries its status and a ready-made principalInvestigators list. Export JSON/CSV/Excel, schedule it weekly on Apify Schedules for a standing feasibility feed, and pricing is per study returned — a 500-study indication landscape costs about a dollar and a half.
Filter by sponsor (e.g. Pfizer) to map a competitor's entire active footprint, or by intervention (e.g. semaglutide) to watch a mechanism.
Recap
- ClinicalTrials.gov's v2 API is free, keyless, and contains the industry's site + investigator map.
- The DIY tax: study-shaped responses for site-shaped questions, deep module-stitching, and per-sponsor contact inconsistency.
- Thirty lines of Python covers one query; the actor covers the recurring, multi-indication version with named PIs already extracted.
Working a specific feasibility question — an indication, a geography, a competitor? Drop a comment and I'll sketch the exact input.
Top comments (0)