All platforms

HrSmart Jobs API.

Pull structured postings from Deltek's HrSmart career sites, where every detail field carries a stable HTML id — so requisition codes, locations, and descriptions extract cleanly without brittle label matching.

Get API access
HrSmart
Live
<3haverage discovery time
1hrefresh interval
Companies using HrSmart
SanminaUniversity of VictoriaPhotoboxHoare LeaCabell Huntington Hospital
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.

What's in every response.

Data fields, real-world applications, and the companies already running on HrSmart.

Data fields
  • Full HTML Job Descriptions
  • Requisition & Reference Codes
  • Multi-Site Locations
  • Department & Job Category
  • Education & Experience Levels
  • Employment Type & Open Date
Use cases
  1. 01Higher-ed & healthcare job tracking
  2. 02Enterprise talent-market monitoring
  3. 03Careers-page job aggregation
  4. 04Requisition-level data enrichment
Trusted by
SanminaUniversity of VictoriaPhotoboxHoare LeaCabell Huntington Hospital
DIY GUIDE

How to scrape HrSmart.

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

HTMLintermediateNo documented limit; self-throttle (~500ms between requests, max 3 concurrent detail fetches)No auth

Resolve the tenant and build the listings URL

Every HrSmart board lives at {tenant}.mua.hrdepartment.com, where the tenant is the leading subdomain. The board self-pages via URL segments, so build the paginated viewAll path yourself.

Step 1: Resolve the tenant and build the listings URL
import requests
from bs4 import BeautifulSoup

tenant = "photobox"  # leading subdomain of {tenant}.mua.hrdepartment.com
base_url = f"https://{tenant}.mua.hrdepartment.com"

def listing_url(page: int, page_size: int = 100) -> str:
    return (
        f"{base_url}/hr/ats/JobSearch/viewAll"
        f"/jobSearchPaginationExternal_pageSize:{page_size}"
        f"/jobSearchPaginationExternal_page:{page}"
    )

Parse the listings grid

Rows live in the #jobSearchResultsGrid_table body. Each posting is a /hr/ats/Posting/view/{id} anchor; the numeric id in that path is the stable external id. When a row has five or more cells, columns 0/2/3/4 carry the reference code, employment type, location, and posted date.

Step 2: Parse the listings grid
import re

JOB_ID = re.compile(r"/hr/ats/Posting/view/(\d+)")

def parse_rows(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "html.parser")
    jobs = []
    for row in soup.select("#jobSearchResultsGrid_table tbody tr"):
        anchor = row.select_one("a[href*='/hr/ats/Posting/view/']")
        if not anchor:
            continue
        m = JOB_ID.search(anchor.get("href", ""))
        if not m:
            continue
        job_id = m.group(1)
        cells = row.find_all("td")
        wide = len(cells) >= 5
        jobs.append({
            "external_id": job_id,
            "title": anchor.get_text(strip=True),
            # Canonicalize — HrSmart sometimes appends /0/13 for share links.
            "url": f"{base_url}/hr/ats/Posting/view/{job_id}",
            "reference_code": cells[0].get_text(strip=True) if wide else None,
            "employment_type": cells[2].get_text(strip=True) if wide else None,
            "location": cells[3].get_text(strip=True) if wide else None,
            "posted": cells[4].get_text(strip=True) if wide else None,
        })
    return jobs

Page through the self-paging board defensively

The first page reports a total in the .pagination_displaying banner ('Displaying 1 - 22 of 22'); the untranslated ' of N' suffix is the count. Stop when a page adds no new posting ids or the total is reached, and cap the loop (50 pages) so a markup change can never spin forever.

Step 3: Page through the self-paging board defensively
COUNT_RE = re.compile(r"\bof\s+(\d+)\b", re.I)

def total_count(html: str) -> int | None:
    soup = BeautifulSoup(html, "html.parser")
    el = soup.select_one(".pagination_displaying")
    if not el:
        return None
    m = COUNT_RE.search(el.get_text(" ", strip=True))
    return int(m.group(1)) if m else None

def scrape_all(max_pages: int = 50) -> list[dict]:
    seen, out, total = set(), [], None
    for page in range(1, max_pages + 1):
        html = requests.get(listing_url(page), timeout=15).text
        if not html:
            break
        new = [j for j in parse_rows(html) if j["external_id"] not in seen]
        if page == 1:
            total = total_count(html)
        if not new:                       # no new records -> stop
            break
        for j in new:
            seen.add(j["external_id"])
            out.append(j)
        if total is not None and len(out) >= total:
            break
    return out

Read posting fields by their stable ids

Detail pages are a stack of form-group rows whose value cell has an id="job_details_*" attribute. Read fields by id rather than localized label text. The description is required content under #job_details_ats_requisition_description; multi-site locations are separated by <br> tags with a '(Principal)' suffix on the primary site.

Step 4: Read posting fields by their stable ids
def field(soup, el_id):
    el = soup.find(id=el_id)
    return el.get_text(" ", strip=True) if el else None

def parse_detail(html: str) -> dict:
    soup = BeautifulSoup(html, "html.parser")
    desc = soup.find(id="job_details_ats_requisition_description")
    location = field(soup, "job_details_hua_location_id")
    sites = [s.split("(")[0].strip() for s in (location or "").split("\n") if s.strip()]
    return {
        "title": field(soup, "job_details_ats_requisition_title"),
        "description_html": desc.decode_contents() if desc else None,
        "locations": sites or ([location] if location else []),
        "employment_type": field(soup, "job_details_hua_job_type_id"),
        "department": field(soup, "job_details_hua_org_level_id"),
        "category": field(soup, "job_details_ats_requisition_category_id"),
        "education_level": field(soup, "job_details_ats_education_level_id"),
        "experience_level": field(soup, "job_details_ats_requisition_level_id"),
        "open_date": field(soup, "job_details_f_open_date_0"),
        "reference_code": field(soup, "job_details_ats_requisition_code"),
    }

Throttle and handle HTTP errors

Treat 404/410 on a posting page as a removed job rather than a crash, and back off on 403/429. Keep to at most 3 concurrent detail fetches with ~500ms between requests to stay polite.

Step 5: Throttle and handle HTTP errors
import time

def fetch_detail(url: str) -> dict | None:
    resp = requests.get(url, timeout=15)
    if resp.status_code in (404, 410):
        return None                       # posting removed
    if resp.status_code in (403, 429):
        raise RuntimeError("Blocked / rate limited — back off and retry")
    resp.raise_for_status()
    time.sleep(0.5)                       # ~500ms between requests; <=3 concurrent
    return parse_detail(resp.text)
Common issues
criticalURL is not a valid HrSmart tenant

Only *.mua.hrdepartment.com subdomains are HrSmart boards — the bare www.hrdepartment.com root and other ATS hosts (Lever, Greenhouse) are not. Derive the tenant from the leading subdomain and confirm the host matches {tenant}.mua.hrdepartment.com before scraping.

highListings grid parses zero postings

The #jobSearchResultsGrid_table tbody tr selector returns nothing when the tenant has no open jobs or the markup shifts. Treat an empty grid as 'no jobs' rather than an error, and stop paging as soon as a page adds no new posting ids.

mediumPagination never terminates

The board self-pages through URL segments and will keep returning a page forever. Read the total from the '... of N' text in .pagination_displaying, break when a page yields no new ids, and cap the loop at ~50 pages.

mediumLocale-specific labels and date formats

Field labels and date order vary by tenant locale. Read detail fields by their stable id="job_details_*" attributes (never by label text), and parse dates as dd/MM/yyyy with d/M/yyyy and MM/dd/yyyy fallbacks.

lowMulti-site locations with a '(Principal)' suffix

#job_details_hua_location_id can list several sites separated by <br> tags, with a '(Principal)' or '(Primary)' parenthetical on the main one. Split on the line breaks and strip the parenthetical before geocoding each site.

Best practices
  1. 1Derive the tenant slug from the leading subdomain of {tenant}.mua.hrdepartment.com
  2. 2Read detail fields by their stable id="job_details_*" attributes, never by localized label text
  3. 3Stop paging when a page adds no new posting ids, and cross-check against the '... of N' total
  4. 4Keep to at most 3 concurrent detail fetches with ~500ms between requests
  5. 5Split multi-site locations on <br> and strip '(Principal)'/'(Primary)' suffixes
  6. 6Treat a 404/410 on a posting page as a removed job, not a hard failure
Or skip the complexity

One endpoint. All HrSmart jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=hrsmart" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access HrSmart
job data today.

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

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