- 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.
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.
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
- 01Enterprise Job Aggregation
- 02Healthcare & Manufacturing Sourcing
- 03Multi-Company Job Discovery
- 04Compensation Benchmarking
Trusted by
- United Natural Foods (UNFI)
- Macmillan Publishers
- TechnoServe
- American Chemical Society
- Regal Theatres
- CNL Financial Group
- Hope Network
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.
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}") # UUIDimport 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")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")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")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 '')}")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']}")- 1Load the job board page first and prefer the internal board ID from its loadUrl before calling LoadSearchResults
- 2Paginate with Skip/Top and stop once the jobs collected reach totalCount
- 3Fetch OpportunityDetail pages only when you need the full description or compensation data
- 4Build every URL from the host in the original board link to stay on the right subdomain
- 5Treat 403/429 as throttling: back off, retry, and keep ~200ms between requests
- 6Handle boards that return totalCount 0 without erroring
One endpoint. All UltiPro (UKG Pro) jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=ultipro (ukg pro)" \
-H "X-Api-Key: YOUR_KEY"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.
Access UltiPro (UKG Pro)
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.