All platforms

UltiPro (UKG Pro) Jobs API.

Pull structured listings, pay ranges, and requisition data from enterprise UKG-hosted career boards using a JSON listings API paired with server-rendered detail pages.

Get API access
UltiPro (UKG Pro)
Live
100K+jobs indexed monthly
<3haverage discovery time
1hrefresh interval
Companies using UltiPro (UKG Pro)
United Natural Foods (UNFI)Macmillan PublishersTechnoServeAmerican Chemical SocietyRegal Theatres
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 UltiPro (UKG Pro).

Data fields
  • Structured Job Locations
  • Requisition Numbers
  • Department & Job Category
  • Pay Ranges & Compensation
  • Full HTML Job Descriptions
  • Posted & Updated Dates
Use cases
  1. 01Enterprise Job Aggregation
  2. 02Healthcare & Manufacturing Sourcing
  3. 03Multi-Company Job Discovery
  4. 04Compensation Benchmarking
Trusted by
United Natural Foods (UNFI)Macmillan PublishersTechnoServeAmerican Chemical SocietyRegal TheatresCNL Financial GroupHope Network
DIY GUIDE

How to scrape UltiPro (UKG Pro).

Step-by-step guide to extracting jobs from UltiPro (UKG Pro)-powered career pages—endpoints, authentication, and working code.

HybridintermediateUndocumented; throttle to ~3 concurrent detail fetches, ~200ms apartNo auth

Extract URL components

UltiPro job boards encode a company code and a board ID in the URL. Extract both from your target company's careers page URL before making any request.

Step 1: Extract URL components
import re

# UltiPro URL patterns:
# https://recruiting.ultipro.com/{COMPANY_CODE}/JobBoard/{BOARD_ID}/
# https://recruiting2.ultipro.com/{COMPANY_CODE}/JobBoard/{BOARD_ID}/

company_url = "https://recruiting.ultipro.com/HOP1003HOPN/JobBoard/b3a1c5d7-6c5e-46f6-8478-cd649884f0ef"

# Extract company code
company_code_match = re.search(r'recruiting\d*\.ultipro\.(?:com|ca)/([^/]+)', company_url)
company_code = company_code_match.group(1) if company_code_match else None

# Extract board ID
board_id_match = re.search(r'JobBoard/([^/]+)', company_url)
board_id = board_id_match.group(1) if board_id_match else None

print(f"Company Code: {company_code}")  # HOP1003HOPN
print(f"Board ID: {board_id}")  # UUID

Fetch job listings via API

POST to the LoadSearchResults endpoint to retrieve job metadata. The response returns a truncated BriefDescription per job, not the full posting. Some boards use a different internal board ID for API calls than the one in the URL (see common issues).

Step 2: Fetch job listings via API
import requests

api_url = f"https://recruiting.ultipro.com/{company_code}/JobBoard/{board_id}/JobBoardView/LoadSearchResults"

headers = {
    "Content-Type": "application/json; charset=UTF-8",
    "X-Requested-With": "XMLHttpRequest",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}

payload = {
    "opportunitySearch": {
        "Top": 50,
        "Skip": 0,
        "QueryString": "",
        "OrderBy": [{
            "Value": "postedDateDesc",
            "PropertyName": "PostedDate",
            "Ascending": False
        }],
        "Filters": [
            {"t": "TermsSearchFilterDto", "fieldName": 4, "extra": None, "values": []},
            {"t": "TermsSearchFilterDto", "fieldName": 5, "extra": None, "values": []},
            {"t": "TermsSearchFilterDto", "fieldName": 6, "extra": None, "values": []},
            {"t": "TermsSearchFilterDto", "fieldName": 37, "extra": None, "values": []}
        ]
    },
    "matchCriteria": {
        "PreferredJobs": [],
        "Educations": [],
        "LicenseAndCertifications": [],
        "Skills": [],
        "hasNoLicenses": False,
        "SkippedSkills": []
    }
}

response = requests.post(api_url, json=payload, headers=headers)
data = response.json()
print(f"Found {data.get('totalCount', 0)} total jobs")

Parse the API response

Read job metadata from the opportunities array. Only the brief description is present here; full descriptions come from the detail page in step 5.

Step 3: Parse the API response
jobs = []
for job in data.get("opportunities", []):
    location_info = None
    if job.get("Locations") and len(job["Locations"]) > 0:
        addr = job["Locations"][0].get("Address", {})
        location_info = {
            "city": addr.get("City"),
            "state": addr.get("State", {}).get("Code"),
            "postal_code": addr.get("PostalCode"),
            "country": addr.get("Country", {}).get("Code"),
        }

    # JobLocationType: 1=On-site, 2=Hybrid, 3=Remote
    location_type_map = {1: "On-site", 2: "Hybrid", 3: "Remote"}

    jobs.append({
        "id": job.get("Id"),
        "title": job.get("Title"),
        "requisition_number": job.get("RequisitionNumber"),
        "full_time": job.get("FullTime"),
        "job_category": job.get("JobCategoryName"),
        "location": location_info,
        "posted_date": job.get("PostedDate"),
        "brief_description": job.get("BriefDescription", "")[:200],
        "location_type": location_type_map.get(job.get("JobLocationType"), "Unknown"),
    })

print(f"Parsed {len(jobs)} jobs")

Handle pagination

UltiPro uses Skip/Top offset pagination. Keep fetching pages until the number of jobs collected reaches the totalCount reported by the API.

Step 4: Handle pagination
import time

all_jobs = []
skip = 0
page_size = 50

while True:
    payload["opportunitySearch"]["Skip"] = skip
    payload["opportunitySearch"]["Top"] = page_size

    response = requests.post(api_url, json=payload, headers=headers)
    data = response.json()

    opportunities = data.get("opportunities", [])
    if not opportunities:
        break

    all_jobs.extend(opportunities)
    total_count = data.get("totalCount", 0)

    print(f"Fetched {len(opportunities)} jobs (total: {len(all_jobs)}/{total_count})")

    if len(all_jobs) >= total_count:
        break

    skip += page_size
    time.sleep(1)  # Rate limiting delay

print(f"Retrieved {len(all_jobs)} total jobs")

Fetch full descriptions from the detail page

The OpportunityDetail page embeds the full posting inside a `new US.Opportunity.CandidateOpportunityDetail({...})` JavaScript call. Locate that call, extract the balanced { } object, and JSON-parse it rather than relying on CSS selectors.

Step 5: Fetch full descriptions from the detail page
import json
import re

_CANDIDATE_OPPORTUNITY = re.compile(
    r"new\s+US\.Opportunity\.CandidateOpportunityDetail\s*\(",
    re.IGNORECASE,
)

# A pulled/filled posting still returns HTTP 200 but renders one of these markers.
_UNAVAILABLE_MARKERS = (
    "opportunityunavailablemessage",
    "view other opportunities",
)


def _extract_balanced_object(source: str, start: int):
    """Return the balanced { ... } substring beginning at 'start'."""
    depth, in_string, quote, escaped = 0, False, "", False
    for i in range(start, len(source)):
        c = source[i]
        if in_string:
            if escaped:
                escaped = False
            elif c == "\\":
                escaped = True
            elif c == quote:
                in_string = False
            continue
        if c in ('"', "'"):
            in_string, quote = True, c
        elif c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return source[start:i + 1]
    return None


def fetch_job_details(company_code: str, board_id: str, job_id: str) -> dict:
    detail_url = (
        f"https://recruiting.ultipro.com/{company_code}/JobBoard/{board_id}"
        f"/OpportunityDetail?opportunityId={job_id}"
    )
    html = requests.get(detail_url, headers=headers).text

    if any(marker in html.lower() for marker in _UNAVAILABLE_MARKERS):
        return {"available": False}

    match = _CANDIDATE_OPPORTUNITY.search(html)
    brace_index = html.find("{", match.end()) if match else -1
    payload = _extract_balanced_object(html, brace_index) if brace_index >= 0 else None
    if payload is None:
        return {"available": True, "opportunity": None}

    try:
        opp = json.loads(payload)
    except json.JSONDecodeError:
        # The embedded object is JS, not strict JSON: drop trailing commas
        # and quote bare keys before retrying.
        cleaned = re.sub(r",\s*([}\]])", r"\1", payload)
        cleaned = re.sub(r"(\w+)\s*:", r'"\1":', cleaned)
        opp = json.loads(cleaned)

    pay_range = opp.get("PayRange") or {}
    return {
        "available": True,
        "title": opp.get("Title"),
        "description": opp.get("Description"),
        "requisition_number": opp.get("RequisitionNumber"),
        "posted_date": opp.get("PostedDate"),
        "updated_date": opp.get("UpdatedDate"),
        "salaried": opp.get("Salaried"),
        "hours_per_week": opp.get("HoursPerWeek"),
        "pay_min": pay_range.get("PayRangeMinimum"),
        "pay_max": pay_range.get("PayRangeMaximum"),
    }

# Example usage
if all_jobs:
    details = fetch_job_details(company_code, board_id, all_jobs[0].get("Id"))
    print(f"Description length: {len(details.get('description') or '')}")

Detect the correct subdomain

UltiPro splits boards across recruiting.ultipro.com, recruiting2.ultipro.com, and recruiting.ultipro.ca. Always build API and detail URLs from the host in the original board URL to avoid 404s.

Step 6: Detect the correct subdomain
import re

def parse_ultipro_url(url: str) -> dict:
    """Extract all components from an UltiPro job board URL."""
    patterns = {
        "base_url": r'(https://recruiting\d*\.ultipro\.(?:com|ca))',
        "company_code": r'recruiting\d*\.ultipro\.(?:com|ca)/([^/]+)',
        "board_id": r'JobBoard/([^/]+)',
    }

    result = {"original_url": url}

    # Extract base URL (includes subdomain)
    base_match = re.search(patterns["base_url"], url)
    result["base_url"] = base_match.group(1) if base_match else None

    # Extract company code
    code_match = re.search(patterns["company_code"], url)
    result["company_code"] = code_match.group(1) if code_match else None

    # Extract board ID
    board_match = re.search(patterns["board_id"], url)
    result["board_id"] = board_match.group(1) if board_match else None

    return result

# Test with different subdomains
urls = [
    "https://recruiting.ultipro.com/HOP1003HOPN/JobBoard/b3a1c5d7-6c5e-46f6-8478-cd649884f0ef",
    "https://recruiting2.ultipro.com/HEN1009HPCC/JobBoard/abc123",
    "https://recruiting.ultipro.ca/BFL5000BFLCA/JobBoard/xyz789",
]

for url in urls:
    parsed = parse_ultipro_url(url)
    print(f"Base: {parsed['base_url']}, Code: {parsed['company_code']}")
Common issues
criticalThe URL board ID may differ from the internal board ID the API expects

Each board exposes a UUID in its URL, but UltiPro sometimes calls LoadSearchResults with a different internal board ID. Load the job board page first and read the board segment from the embedded loadUrl / opportunityLinkUrl; fall back to the URL board ID when they match. Board IDs can't be guessed, so store one per company.

highThe listings API returns only a truncated BriefDescription

LoadSearchResults returns a short BriefDescription (roughly 100-200 characters), not the full posting. For complete descriptions you must fetch each OpportunityDetail page, which adds one HTTP request per job.

mediumFull descriptions live in embedded JavaScript, not clean HTML fields

The detail page renders the posting inside a `new US.Opportunity.CandidateOpportunityDetail({...})` call. Locate that call, extract the balanced { } object, and JSON-parse it - stripping trailing commas and quoting bare keys if strict parsing fails - instead of scraping CSS selectors.

mediumMultiple subdomains host different boards

Boards are split across recruiting.ultipro.com, recruiting2.ultipro.com, and recruiting.ultipro.ca. Build every API and detail URL from the host in the original board URL; using the wrong subdomain returns 404s.

mediumRemoved jobs still return HTTP 200

A pulled or filled posting renders an opportunity-unavailable / 'view other opportunities' page rather than a 404. Check for those markers before trusting the detail HTML so you don't record empty jobs.

mediumRequests can be blocked with 403 or need an anti-forgery token

A 403 usually means you're blocked or missing X-RequestVerificationToken. Send X-Requested-With: XMLHttpRequest, back off on 403/429, and if it persists extract the token from the initial page load's cookies or meta tags.

Best practices
  1. 1Load the job board page first and prefer the internal board ID from its loadUrl before calling LoadSearchResults
  2. 2Paginate with Skip/Top and stop once the jobs collected reach totalCount
  3. 3Fetch OpportunityDetail pages only when you need the full description or compensation data
  4. 4Build every URL from the host in the original board link to stay on the right subdomain
  5. 5Treat 403/429 as throttling: back off, retry, and keep ~200ms between requests
  6. 6Handle boards that return totalCount 0 without erroring
Or skip the complexity

One endpoint. All UltiPro (UKG Pro) jobs. No scraping, no sessions, no maintenance.

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

Access UltiPro (UKG Pro)
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