DEV Community

CBLU2005
CBLU2005

Posted on

Trial site selection & investigator mapping with ClinicalTrials.gov's free API (CRO edition)

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"
Enter fullscreen mode Exit fullscreen mode

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

  1. Location filters return studies, not sites. query.locn=Florida gives you every study that has a Florida site — with the full worldwide locations array 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.

  2. 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.

  3. Field selection is its own language. The fields parameter wants exact dotted module paths; get one wrong and it errors (better than silence, but still a docs-diving session). Filters similarly split across query.* (search-y) and filter.* (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)
Enter fullscreen mode Exit fullscreen mode

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:

ClinicalTrials.gov Scraper

{
    "condition": "NASH",
    "statuses": ["RECRUITING"],
    "phases": ["PHASE2", "PHASE3"],
    "sponsorClasses": ["INDUSTRY"],
    "state": "Florida",
    "maxResults": 500
}
Enter fullscreen mode Exit fullscreen mode

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

  1. ClinicalTrials.gov's v2 API is free, keyless, and contains the industry's site + investigator map.
  2. The DIY tax: study-shaped responses for site-shaped questions, deep module-stitching, and per-sponsor contact inconsistency.
  3. 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)