# UKG Jobs API: Get UKG Pro Recruiting Job Postings as JSON

> UKG Pro Recruiting job boards answer a keyless POST with job JSON. Company code, board ID, Top/Skip paging, the embedded detail object and pay fields.

Source: https://cleanjobdata.com/articles/how-to-scrape-ukg-job-listings
Published: 2026-11-01

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.

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](https://cleanjobdata.com/docs/api/jobs).

## 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 on `recruiting.ultipro.ca` and `recruiting2.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

```bash
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:

```json
{
  "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:

```bash
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:

```python
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 obj
```

Fields on the detail object:

- **`Description`.** The full description, as HTML.
- **`Locations[]`.** Each entry has `Address.City`, `Address.State.Name`, `Address.Country.Code` and `Address.Country.Name`, plus display strings in `LocalizedName` or `LocalizedDescription` and a `Coordinates` object.
- **`PostedDate`** and **`UpdatedDate`.** 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)

```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 jobs
```

## Gotchas

**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](https://cleanjobdata.com/guides/job-location-normalization).

**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](https://cleanjobdata.com/articles/how-to-scrape-workday-job-listings) and [iCIMS](https://cleanjobdata.com/articles/how-to-scrape-icims-job-listings). See [which ATS have a public job postings API](https://cleanjobdata.com/articles/ats-public-job-postings-api-list) 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](https://cleanjobdata.com/playground) or see the [API Documentation](https://cleanjobdata.com/docs).

## 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`.

```bash
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](https://cleanjobdata.com/docs/api/jobs) for every filter and response field
- [Playground](https://cleanjobdata.com/playground) to run queries without writing code
- [Pricing](https://cleanjobdata.com/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`.
