UKG Jobs API: Get UKG Pro Recruiting Job Postings as JSON
TL;DR
UKG Pro Recruiting (formerly UltiPro) job boards load jobs from a POST to LoadSearchResults, with Top and Skip in the JSON body and no key, cookie or token. The list has titles, dates and requisition numbers, but on some boards its locations are empty. The full description, structured locations and compensation fields come from each job's OpportunityDetail page, where the job object is embedded in an inline script. Pay can be an annual or hourly range or a single CompensationAmount figure.
On this page
To get a company's jobs from UKG Pro Recruiting, send a POST to https://recruiting.ultipro.com/{companyCode}/JobBoard/{boardId}/JobBoardView/LoadSearchResults with Top and Skip in a JSON body. No API key, cookie or token is needed, and the full description, structured locations and pay fields come from each job's OpportunityDetail page.
That's the DIY route, and it works for a handful of companies you already know. If you'd rather not build and maintain a UKG scraper, CleanJobData already ingests ATS platforms and company career sites and serves the jobs normalized through one REST API — see the Jobs API docs.
At a Glance
| Endpoint | POST https://{host}/{companyCode}/JobBoard/{boardId}/JobBoardView/LoadSearchResults |
| Detail page | GET https://{host}/{companyCode}/JobBoard/{boardId}/OpportunityDetail?opportunityId={Id} |
| API key | None |
| Pagination | Skip / Top in the body, 50 per page |
| Full description | Detail page, in an embedded JSON object |
| Salary field | Yes, on the detail: CompensationAnnualMinimum and related fields |
| Remote | No reliable flag; infer from title, location and description |
Finding the Host, Company Code and Board ID
UKG job board URLs look like this:
https://recruiting.ultipro.com/ACM1000ACME/JobBoard/9b2f0c1e-0000-0000-0000-000000000000/
You need three values:
- Host. Usually
recruiting.ultipro.com, but tenants also live onrecruiting.ultipro.caandrecruiting2.ultipro.com. Take it from the URL. - Company code. The first path segment, here
ACM1000ACME. - Board ID. The GUID after
/JobBoard/.
The company code and board ID have no relationship you can derive, so copy both. If the board is embedded on the company's own site, open the Network tab and look for a request to LoadSearchResults.
The List Request
curl -X POST "https://recruiting.ultipro.com/ACM1000ACME/JobBoard/9b2f0c1e-0000-0000-0000-000000000000/JobBoardView/LoadSearchResults" \
-H "content-type: application/json" \
-d '{"opportunitySearch":{"Top":50,"Skip":0,"QueryString":"","OrderBy":[{"Value":"postedDateDesc","PropertyName":"PostedDate","Ascending":false}]}}'The board's own page sends extra empty filter and match fields with this request. They aren't needed: the body above returned full results with no cookie, token or prior page load. A trimmed response:
{
"opportunities": [
{
"Id": "37c081ee-0000-0000-0000-000000000000",
"Title": "Program Coordinator",
"RequisitionNumber": "PROGR005583",
"FullTime": true,
"JobCategoryName": "Programs",
"PostedDate": "2026-10-30T17:50:17.099Z",
"BriefDescription": "The Program Coordinator will work closely with...",
"Locations": [
{
"LocalizedDescription": "Benin/Cotonou",
"Address": {
"City": "Cotonou, Littoral",
"State": null,
"Country": { "Code": "BEN", "Name": "Benin" }
}
}
],
"JobLocationType": null
}
],
"totalCount": 83
}| Field | What it is |
|---|---|
Id | Opportunity GUID, used in the detail URL |
RequisitionNumber | The tenant's readable requisition number |
Title | Job title |
PostedDate | ISO timestamp in UTC |
FullTime | Boolean |
JobCategoryName | Category set by the employer |
BriefDescription | A short summary, not the full description |
Locations | Location objects, but [null] on some boards |
totalCount | Total jobs on the board |
Pagination
Send Skip values of 0, 50, 100 and so on with Top at 50. Stop when a page returns fewer than 50 rows or when you've collected totalCount unique IDs. Paging by Skip was stable in our tests, with totalCount matching the unique ID count exactly.
The Detail Page
The detail is an HTML page, not a JSON endpoint:
curl "https://recruiting.ultipro.com/ACM1000ACME/JobBoard/9b2f0c1e-0000-0000-0000-000000000000/OpportunityDetail?opportunityId=37c081ee-0000-0000-0000-000000000000"The job object is passed inline to new US.Opportunity.CandidateOpportunityDetail({...}) in a script tag. The object contains nested braces, so a regex up to the end of the line won't work. In Python, json.JSONDecoder().raw_decode reads exactly one JSON object from a starting position:
import json
import requests
MARKER = "new US.Opportunity.CandidateOpportunityDetail("
def get_ukg_detail(host: str, company: str, board: str, opportunity_id: str) -> dict | None:
url = f"https://{host}/{company}/JobBoard/{board}/OpportunityDetail"
html = requests.get(url, params={"opportunityId": opportunity_id}, timeout=15).text
start = html.find(MARKER)
if start == -1:
return None # closed jobs return 200 without the embedded object
obj, _ = json.JSONDecoder().raw_decode(html, start + len(MARKER))
return objFields on the detail object:
Description. The full description, as HTML.Locations[]. Each entry hasAddress.City,Address.State.Name,Address.Country.CodeandAddress.Country.Name, plus display strings inLocalizedNameorLocalizedDescriptionand aCoordinatesobject.PostedDateandUpdatedDate. ISO timestamps in UTC.RequisitionNumber,FullTime,Salaried. Identification and employment type.CompensationAnnualMinimum,CompensationAnnualMaximum,CompensationHourlyMinimum,CompensationHourlyMaximum. Pay ranges, when the employer sets them.CompensationAmount. A single{ "Code": "USD", "Value": "90000.00" }figure that some employers use instead of a range.CompensationCurrency,CompensationCurrencyCode. Currency fields.
Fetching Jobs (Python)
def list_ukg_jobs(host: str, company: str, board: str) -> list[dict]:
url = f"https://{host}/{company}/JobBoard/{board}/JobBoardView/LoadSearchResults"
by_id, skip = {}, 0
while True:
body = {"opportunitySearch": {
"Top": 50, "Skip": skip, "QueryString": "",
"OrderBy": [{"Value": "postedDateDesc", "PropertyName": "PostedDate", "Ascending": False}],
}}
data = requests.post(url, json=body, timeout=20).json()
page = [o for o in data.get("opportunities") or [] if o and o.get("Id")]
for o in page:
by_id[o["Id"]] = o
if len(page) < 50 or len(by_id) >= (data.get("totalCount") or 0):
break
skip += 50
return list(by_id.values())
def ukg_salary(d: dict) -> dict:
amount = d.get("CompensationAmount") or {}
single = float(amount["Value"]) if amount.get("Value") is not None else None
annual = d.get("CompensationAnnualMinimum") is not None or d.get("CompensationAnnualMaximum") is not None
return {
"min": d.get("CompensationAnnualMinimum") or d.get("CompensationHourlyMinimum") or single,
"max": d.get("CompensationAnnualMaximum") or d.get("CompensationHourlyMaximum") or single,
"period": "year" if annual else ("hour" if d.get("CompensationHourlyMinimum") is not None else None),
"currency": d.get("CompensationCurrencyCode") or d.get("CompensationCurrency") or amount.get("Code"),
}
def fetch_ukg_jobs(host: str, company: str, board: str) -> list[dict]:
jobs = []
for item in list_ukg_jobs(host, company, board):
d = get_ukg_detail(host, company, board, item["Id"])
if d is None:
continue
locations = [l for l in d.get("Locations") or [] if l]
primary = next((l for l in locations if (l.get("Address") or {}).get("City")), locations[0] if locations else {})
address = primary.get("Address") or {}
jobs.append({
"id": item.get("RequisitionNumber") or item["Id"],
"title": d.get("Title"),
"posted": d.get("PostedDate"),
"description_html": d.get("Description"),
"city": address.get("City"),
"state": (address.get("State") or {}).get("Name"),
"country": (address.get("Country") or {}).get("Name"),
"salary": ukg_salary(d),
"url": f"https://{host}/{company}/JobBoard/{board}/OpportunityDetail?opportunityId={item['Id']}",
})
return jobsGotchas
List locations can be empty. On some boards the list's Locations is [null] for nearly every job, while the detail has the full city, state and country. Use the detail for location.
Pick the location entry that has a city. A multi-location job can have a first entry with City set to null (the city only appears in its display name) and a later entry with a real city. Prefer the first entry with Address.City, and keep the others as extra locations.
Location values need cleanup. City can include a region, as in "Cotonou, Littoral", and Country.Code is an ISO alpha-3 code (BEN), so convert it if you store alpha-2. Treat Coordinates as a hint: one job in Benin carried coordinates in the US Midwest. See the location normalization guide.
Pay comes in two shapes. Some jobs have annual or hourly range fields. Others leave every range field null and put one figure in CompensationAmount, such as "90000.00" USD on one job and "27.21" CAD, an hourly rate, on another. CompensationAmount doesn't say which period it is, so you have to judge from the amount.
The currency code field is usually empty. CompensationCurrencyCode was null on every job we checked, even with pay present. Use CompensationCurrency, then CompensationAmount.Code.
JobLocationType isn't a remote flag. It looks like a work-arrangement code, but values didn't match the descriptions: jobs with different values both described on-site work. Check the title and location for "remote" or "hybrid", and match phrases like "hybrid role" in the description rather than the bare word, which also shows up in phrases like "hybrid solutions". Boards with French or Spanish titles need those languages' terms too.
Closed jobs return 200. A closed opportunity's detail page loads normally but doesn't contain the embedded object.
Use RequisitionNumber as your ID. It's the tenant's readable requisition number. Keep the Id GUID too, because the detail URL needs it.
No employer name. Neither the list nor the detail names the company, so you supply it yourself.
Job Board Endpoint vs UKG's Official API
UKG offers authenticated APIs to its customers. They need credentials from the employer's UKG Pro account and cover HR and recruiting data beyond the public board. The endpoints in this article are the ones the public job board uses, so they need no account and return only published jobs. They're undocumented and can change.
When This Is Enough
For one employer whose board URL you have, the POST list plus a detail page per new job gets you full descriptions, structured locations and pay.
When It Isn't
Across many UKG tenants you deal with three hosts, two pay shapes and location cleanup, plus other platforms like Workday and iCIMS. See which ATS have a public job postings API for how they compare.
CleanJobData reads UKG job boards alongside other platforms and returns one schema, with parsed salary, locations resolved to cities, states and countries, and a remote type. Try it in the Playground or see the API Documentation.
Skip the scraper: get this data from the CleanJobData API
If you'd rather not maintain a UKG collector, you can query normalized jobs from ATS platforms and company career sites with a single request. The response has structured city/state/country locations, parsed salary when the employer publishes one, a remote type, and company metadata, and you page through results with the cursor in pagination.next_page.
curl "https://api.cleanjobdata.com/jobs?title=manager&domain=recruiting.ultipro.com&limit=20" \
-H "Authorization: Bearer $CLEANJOBDATA_API_KEY"The domain filter is an exact match on the host of each job's application_url, so it only returns jobs whose apply link resolves to recruiting.ultipro.com (Canadian and secondary hosts are separate values); use company_website_url or employer_id to scope to one employer instead. Coverage depends on which employers are in the dataset, so check that the companies you care about appear before you rely on it.
- Jobs API reference for every filter and response field
- Playground to run queries without writing code
- Pricing — the Trial is free with no card required
FAQ
Does UKG Pro Recruiting have a public jobs API?
Its job boards load jobs from POST https://{host}/{companyCode}/JobBoard/{boardId}/JobBoardView/LoadSearchResults, which needs no API key, cookie or token. It isn't documented, but it returns the same jobs the public board shows.
How do I get the full job description from UKG?
Fetch OpportunityDetail?opportunityId={Id} for each job. The page embeds the job as a JSON object passed to US.Opportunity.CandidateOpportunityDetail, and its Description field holds the HTML.
Does UKG include salary in its job data?
Often, on the detail object. Look at the annual and hourly minimum and maximum fields first, then CompensationAmount, which holds a single figure, and take the currency from CompensationCurrency.
Where do I find a UKG company code and board ID?
Both are in the job board URL: recruiting.ultipro.com/{companyCode}/JobBoard/{boardId}. Copy the host too, because some tenants use recruiting.ultipro.ca or recruiting2.ultipro.com.