Healthcare Talent Source Jobs API.

Healthcare Talent Source runs recruitment portals for hospitals and health systems on hctsportals.com. Each tenant serves a paginated search page whose rows link to fully rendered job records.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on Healthcare Talent Source.

Data fields

  • Full Job Descriptions
  • Numeric Job IDs
  • Facility Locations
  • Paginated Search
  • Direct Apply URLs
  • First-Party Page Proof

Use cases

  1. 01Hospital Job Aggregation
  2. 02Clinical Recruitment Feeds
  3. 03Health System Hiring Trackers
  4. 04Careers Page Monitoring

Trusted by

  • Waterbury
  • CKHS
  • National Jewish
DIY GUIDE

How to scrape Healthcare Talent Source.

Step-by-step guide to extracting jobs from Healthcare Talent Source-powered career pages—endpoints, authentication, and working code.

API type
HTML
Difficulty
intermediate
Rate limit
No published limit; ~100ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Resolve the tenant portal

Each health system gets a subdomain of hctsportals.com and the board is always /jobs/search. Job records live at /jobs/{numericId}-{slug}, where the identifier is the leading number — the slug is decoration and changes when a title is edited.

Step 1: Resolve the tenant portal
import re
from urllib.parse import urlparse, unquote

SUFFIX = ".hctsportals.com"
JOB_PATH = re.compile("^([0-9]+)(?:-|$)")
TENANT = re.compile("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$", re.IGNORECASE)

def parse_hcts(url: str) -> dict | None:
    parsed = urlparse(url)
    host = parsed.netloc.lower().rstrip(".")
    if not host.endswith(SUFFIX):
        return None

    tenant = host[: -len(SUFFIX)]
    if tenant in ("www", "") or "." in tenant or not TENANT.match(tenant):
        return None

    segments = [s for s in parsed.path.split("/") if s]
    job_id = None
    if segments:
        if segments[0] != "jobs" or len(segments) > 2:
            return None
        if len(segments) == 2 and segments[1] != "search":
            match = JOB_PATH.match(unquote(segments[1]))
            if not match:
                return None
            job_id = match.group(1)

    return {"tenant": tenant, "job_id": job_id,
            "board_url": f"https://{tenant}.hctsportals.com/jobs/search"}

print(parse_hcts("https://careers-waterbury.hctsportals.com/jobs/2191162"))

Page the search results

Results are paginated with a rel=next link rather than a page parameter you can guess. Follow that link only while it stays on the tenant's own /jobs/search path, and require the platform fingerprint on every page so a redirect to a marketing site cannot pass as an empty board.

Step 2: Page the search results
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
import time

FINGERPRINT = "connect.healthcaretalentsource.com"

def is_same_search(candidate: str, tenant: str) -> bool:
    identity = parse_hcts(candidate)
    parsed = urlparse(candidate)
    return bool(identity and identity["tenant"] == tenant
                and parsed.path.lower() == "/jobs/search")

def scrape_board(session, tenant: str, max_pages: int = 25) -> list[dict]:
    url = f"https://{tenant}.hctsportals.com/jobs/search"
    jobs, seen = [], set()

    for _ in range(max_pages):
        resp = session.get(url, headers={"Accept": "text/html"}, timeout=30)
        resp.raise_for_status()
        if FINGERPRINT not in resp.text:
            raise RuntimeError("page omitted the Healthcare Talent Source fingerprint")

        soup = BeautifulSoup(resp.text, "html.parser")
        for row in parse_rows(soup, url, tenant):
            if row["id"] not in seen:
                seen.add(row["id"])
                jobs.append(row)

        nxt = soup.select_one("a.next_page[rel='next']")
        if not nxt or not nxt.get("href"):
            break
        candidate = urljoin(url, nxt["href"])
        if not is_same_search(candidate, tenant):
            break
        url = candidate
        time.sleep(0.1)
    return jobs

Parse the result rows

Every result is a .jobs-section__item containing one anchor into /jobs/. Resolve it against the board, confirm it belongs to the tenant you are scraping, and strip any query string so the stored URL stays stable between runs.

Step 3: Parse the result rows
from urllib.parse import urlsplit, urlunsplit

def canonical(url: str) -> str:
    parts = urlsplit(url)
    return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))

def parse_rows(soup: BeautifulSoup, page_url: str, tenant: str) -> list[dict]:
    rows = []
    for item in soup.select(".jobs-section__item"):
        anchor = item.select_one("a[href*='/jobs/']")
        if not anchor or not anchor.get("href"):
            continue

        detail_url = canonical(urljoin(page_url, anchor["href"]))
        identity = parse_hcts(detail_url)
        title = " ".join(anchor.get_text().split())
        if not identity or not identity["job_id"] or not title:
            continue
        if identity["tenant"] != tenant:
            continue

        rows.append({
            "id": identity["job_id"],
            "title": title,
            "url": detail_url,
            "apply_url": detail_url,
        })
    return rows

session = requests.Session()
listings = scrape_board(session, "careers-waterbury")
print(f"{len(listings)} open jobs")

Read the job record

The job page carries the title in the #job heading and the body in the job__details-copy block. Before parsing, confirm the page's own routing variable names the job you asked for — that proves the record belongs to this tenant rather than to a redirect.

Step 4: Read the job record
def fetch_detail(session, listing: dict) -> dict | None:
    resp = session.get(listing["url"], headers={"Accept": "text/html"}, timeout=30)
    if resp.status_code in (404, 410):
        return None  # job removed
    resp.raise_for_status()

    body_html = resp.text
    if FINGERPRINT not in body_html:
        return None
    # The page publishes its own route; require it to match the job we requested.
    marker = 'tm_vars.search_url = "/jobs/' + listing["id"] + '"'
    if marker not in body_html:
        return None

    soup = BeautifulSoup(body_html, "html.parser")
    heading = soup.select_one("#job h1")
    body = soup.select_one("#job .job__details-copy")
    if not heading or not body:
        return None

    location = soup.select_one("#job .job__details-list li span:not(.text-muted)")
    return {
        "id": listing["id"],
        "title": " ".join(heading.get_text().split()),
        "description_html": body.decode_contents().strip(),
        "location": (" ".join(location.get_text().split())
                     if location else None),
        "url": listing["url"],
        "apply_url": listing["url"],
    }

for listing in listings[:3]:
    print(fetch_detail(session, listing))
    time.sleep(0.1)
Common issues
highJob IDs change whenever a title is edited
The path is /jobs/{numericId}-{slug} and the slug tracks the title. Keying on the whole path segment creates a duplicate record every time a recruiter rewords a heading. Take only the leading numeric run as the identifier and discard the slug.
highPagination walks off the board
The rel=next control sometimes resolves to a filtered or unrelated route rather than the tenant's /jobs/search path. Validate every candidate URL against the tenant and the search path before following it, and stop the crawl instead of drifting onto another portal.
mediumA redirect renders as an empty board
Retired portals redirect to a marketing page that parses cleanly and yields zero rows, which reads downstream as every job closing at once. Require the connect.healthcaretalentsource.com fingerprint on each page and fail the run when it is missing.
lowThe location is missing on some records
The location sits in a single detail-list span that recruiters sometimes leave unset, and the muted sibling spans hold labels rather than values. Exclude the muted class when reading it, and fall back to the listing row instead of writing an empty string.
Best practices
  1. 1Take the leading numeric run of the path segment as the job identifier
  2. 2Require the platform fingerprint on every listing and detail page
  3. 3Follow rel=next only while it stays on the tenant's /jobs/search path
  4. 4Strip query strings from detail URLs so stored links stay stable
  5. 5Verify the page's own route variable names the job you requested
  6. 6Throttle to ~100ms between requests with at most three concurrent detail fetches
Or skip the complexity

One endpoint. All Healthcare Talent Source jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=healthcare talent source" \
  -H "X-Api-Key: YOUR_KEY"
Developer tools

Try the API.

Test Jobs and Feed endpoints against https://connect.jobo.world with live request/response examples, then copy ready-to-use curl commands.

Ready to integrate

Access Healthcare Talent Source
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the $5 free starting balance and scale as you grow.

99.9%API uptime
<200msAvg response
50M+Jobs processed