HealthcareSource Performance Manager Jobs API.

HealthcareSource Performance Manager hosts hospital career sites at pm.healthcaresource.com/CS/{tenant}. Its search API is an Elasticsearch-shaped POST that returns every requisition with schema.org-style job fields.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on HealthcareSource Performance Manager.

Data fields

  • Full Job Descriptions
  • Requisition Numbers
  • Shift Information
  • Structured Addresses
  • Hiring Organization Names
  • Offset Pagination

Use cases

  1. 01Hospital Job Aggregation
  2. 02Nursing Recruitment Feeds
  3. 03Health System Hiring Trackers
  4. 04ATS Data Pipelines

Trusted by

  • Access Health Louisiana
  • AnMed
  • Archbold
DIY GUIDE

How to scrape HealthcareSource Performance Manager.

Step-by-step guide to extracting jobs from HealthcareSource Performance Manager-powered career pages—endpoints, authentication, and working code.

API type
REST
Difficulty
intermediate
Rate limit
No published limit; ~75ms between requests, up to 4 concurrent detail fetches
Authentication
No auth

Resolve the tenant from the path

Every board is pm.healthcaresource.com/CS/{tenant}/ and the tenant is the second path segment. Job links live in the URL fragment as #/job/{numericId}, which the server never sees — treat the fragment as client-side routing and build API URLs yourself.

Step 1: Resolve the tenant from the path
import re
from urllib.parse import urlparse

HOST = "pm.healthcaresource.com"
TENANT = re.compile("^[a-z0-9][a-z0-9_-]{0,80}$", re.IGNORECASE)

def parse_pm(url: str) -> dict | None:
    parsed = urlparse(url)
    if parsed.netloc.lower() != HOST:
        return None

    segments = [s for s in parsed.path.split("/") if s]
    if len(segments) != 2 or segments[0].lower() != "cs":
        return None
    if not TENANT.match(segments[1]):
        return None
    tenant = segments[1].lower()

    job_id = None
    fragment = (parsed.fragment or "").strip("#/")
    if fragment:
        parts = [p for p in fragment.split("/") if p]
        if len(parts) != 2 or parts[0].lower() != "job" or not parts[1].isdigit():
            return None
        job_id = parts[1]

    return {"tenant": tenant, "job_id": job_id,
            "board_url": f"https://{HOST}/CS/{tenant}/#/"}

print(parse_pm("https://pm.healthcaresource.com/CS/accesshealthla/#/job/1548"))

POST to the jobseeker search API

The search endpoint is scoped by tenant and takes an Elasticsearch-shaped body. A match_all filter with an explicit from and size returns the whole requisition set; the endpoint only answers to POST, and it expects the board as the Referer.

Step 2: POST to the jobseeker search API
import requests

PAGE_SIZE = 100

def search(session, tenant: str, offset: int = 0) -> dict:
    url = (f"https://{HOST}/JobseekerSearchAPI/{tenant}"
           f"/api/Search?size={PAGE_SIZE}")
    body = {
        "from": offset,
        "size": PAGE_SIZE,
        "query": {"bool": {"filter": {"match_all": {}}}},
    }
    resp = session.post(
        url, json=body,
        headers={"Accept": "application/json",
                 "Content-Type": "application/json; charset=utf-8",
                 "Referer": f"https://{HOST}/CS/{tenant}/#/"},
        timeout=30)
    resp.raise_for_status()
    return resp.json()

session = requests.Session()
first = search(session, "accesshealthla")

Read the hits and their total

Results arrive as hits.hits with the record under _source. The total sits at hits.total and can be either a bare number or an object with a value key depending on the search backend version, so handle both before computing the next offset.

Step 3: Read the hits and their total
def read_total(hits: dict) -> int:
    total = hits.get("total")
    if isinstance(total, int):
        return total
    if isinstance(total, dict) and isinstance(total.get("value"), int):
        return total["value"]
    return len(hits.get("hits") or [])

def join_address(source: dict) -> str | None:
    address = ((source.get("jobLocation") or {}).get("address")) or {}
    parts = [address.get(key) for key in
             ("streetAddress", "addressLocality", "addressRegion", "postalCode")]
    text = ", ".join(p.strip() for p in parts if isinstance(p, str) and p.strip())
    return text or None

def map_hits(payload: dict, tenant: str) -> list[dict]:
    hits = (payload.get("hits") or {}).get("hits") or []
    rows = []
    for hit in hits:
        source = hit.get("_source") or {}
        user_area = source.get("userArea") or {}
        job_id = str(user_area.get("jobPostingID") or "")
        title = source.get("title") or source.get("name")
        if not job_id.isdigit() or not title:
            continue

        rows.append({
            "id": job_id,
            "title": title,
            "summary": source.get("description"),
            "company": (source.get("hiringOrganization") or {}).get("name"),
            "location": join_address(source),
            "requisition_number": user_area.get("requisitionNumber"),
            "shift": user_area.get("shift"),
            "posted_at": source.get("datePosted"),
            "url": f"https://{HOST}/CS/{tenant}/#/job/{job_id}",
        })
    return rows

Walk the offset pagination

Pagination is a from offset rather than a page number. Advance by the number of hits you actually received, not by the page size you asked for, and stop once the running offset reaches the reported total.

Step 4: Walk the offset pagination
import time

def fetch_all(session, tenant: str) -> list[dict]:
    offset, jobs, seen = 0, [], set()
    while True:
        payload = search(session, tenant, offset)
        hits = (payload.get("hits") or {})
        received = len(hits.get("hits") or [])
        total = read_total(hits)

        for row in map_hits(payload, tenant):
            if row["id"] not in seen:
                seen.add(row["id"])
                jobs.append(row)

        offset += received
        if received == 0 or offset >= total:
            return jobs
        time.sleep(0.075)

listings = fetch_all(session, "accesshealthla")
print(f"{len(listings)} open requisitions")

Fetch the full requisition

The detail API is a separate, tenant-scoped v2 endpoint keyed by the numeric job ID. Confirm the returned userArea.jobPostingID echoes the one you asked for, then read the rendered summary — jobSummaryDisplay is the formatted body, with jobSummary as the fallback.

Step 5: Fetch the full requisition
def fetch_detail(session, tenant: str, job_id: str) -> dict | None:
    url = (f"https://{HOST}/JobseekerAPI/Site/{tenant}"
           f"/api/v2/JobPostingV2?id={job_id}")
    resp = session.get(
        url,
        headers={"Accept": "application/json",
                 "Referer": f"https://{HOST}/CS/{tenant}/#/"},
        timeout=30)
    if resp.status_code in (404, 410):
        return None  # requisition removed
    resp.raise_for_status()

    payload = resp.json() or {}
    user_area = payload.get("userArea") or {}
    if str(user_area.get("jobPostingID") or "") != str(job_id):
        raise RuntimeError("Performance Manager returned a different requisition")

    return {
        "id": job_id,
        "title": payload.get("title") or payload.get("name"),
        "description_html": (user_area.get("jobSummaryDisplay")
                             or user_area.get("jobSummary")),
        "company": (payload.get("hiringOrganization") or {}).get("name"),
        "location": join_address(payload),
        "employment_type": payload.get("employmentType"),
        "requisition_number": user_area.get("requisitionNumber"),
        "shift": user_area.get("shift"),
        "posted_at": payload.get("datePosted"),
        "url": f"https://{HOST}/CS/{tenant}/#/job/{job_id}",
    }

for row in listings[:3]:
    print(fetch_detail(session, "accesshealthla", row["id"]))
    time.sleep(0.075)
Common issues
criticalThe job ID never reaches the server
Career-site URLs put the job in the fragment, #/job/1548, which browsers never transmit. Fetching that URL returns the same shell for every requisition. Parse the fragment client-side and call the tenant-scoped API endpoints directly instead of following the link.
criticalThe search endpoint returns 404 or 405
JobseekerSearchAPI answers to POST with a JSON body, not to GET with query parameters. Send the Elasticsearch-shaped body with from, size and a match_all filter, set the JSON content type, and include the board as the Referer.
highPagination stops early or repeats rows
hits.total is sometimes a plain integer and sometimes an object carrying a value key, so a single-shape reader either stops on page one or loops. Normalise both forms, and advance the offset by the hits you actually received rather than by the requested size.
mediumDescriptions come back empty
The narrative lives under userArea, not at the top level: jobSummaryDisplay holds the formatted body and jobSummary the plain version. Reading description from the search hit alone gives a short teaser, so fetch the v2 detail endpoint for the full text.
Best practices
  1. 1Parse the URL fragment yourself — the server never receives #/job/{id}
  2. 2POST an Elasticsearch-shaped body with from, size and a match_all filter
  3. 3Send the board URL as the Referer on both search and detail calls
  4. 4Normalise hits.total across its integer and object forms
  5. 5Advance the offset by hits received, not by the requested page size
  6. 6Read jobSummaryDisplay from userArea, falling back to jobSummary
Or skip the complexity

One endpoint. All HealthcareSource Performance Manager jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=healthcaresource performance manager" \
  -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 HealthcareSource Performance Manager
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