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.
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).
- Structured Job Locations
- Requisition Numbers
- Department & Job Category
- Pay Ranges & Compensation
- Full HTML Job Descriptions
- Posted & Updated Dates
- 01Enterprise Job Aggregation
- 02Healthcare & Manufacturing Sourcing
- 03Multi-Company Job Discovery
- 04Compensation Benchmarking
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']}")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.
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.
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.
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.
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.
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.
- 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" 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.