# UKG Pro Jobs API

> Query jobs from UKG Pro boards through one API with ?source=ultipro — plus how UltiPro's LoadSearchResults endpoint really behaves.

Source: https://hyperjobs.io/ats/ultipro

UKG Pro is the HR and payroll suite formerly called UltiPro, and like
[Trakstar Hire](/ats/recruiterbox), the rename never reached the plumbing. Boards
still live at `recruiting.ultipro.com/<tenant>/JobBoard/<guid>`, the endpoints
still answer to the old name, and the `source` value here is `ultipro`. There are
47,180 UKG Pro postings in this dataset — a number that moves hourly, so read it
from [`/v1/meta`](/docs/api-reference/meta) rather than trusting this sentence.

<Note>
  The value is `ultipro`, not `ukg` or `ukgpro`. It matches the host the boards
  are served from, and it's the same string everywhere: the `?source=` filter,
  the keys of [`/v1/meta`](/docs/api-reference/meta) → `sources`, and the
  `source` field on every returned posting. If you're looking for `ukg` in the
  [vocabulary](/docs/reference/sources), that's why it isn't there.
</Note>

## Try it

```bash
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "source=ultipro" \
  --data-urlencode "time_frame=7d" \
  --data-urlencode "limit=2"
```

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "ultipro:5b8f0f2e-91c4-4d17-a0e6-7f3b2c9d1a45",
      "title": "Patient Access Representative",
      "company": {
        "slug": "westbrook-health",
        "name": "Westbrook Health System",
        "domain": "westbrookhealth.org",
        "website": "https://westbrookhealth.org",
        "logo": "https://media.licdn.com/dms/image/…",
        "industry": "Hospitals and Health Care",
        "employee_count": 6400
      },
      "location": {
        "remote": false,
        "countries": ["United States"],
        "cities": ["Dayton"],
        "regions": ["Ohio"],
        "raw": ["Dayton, OH, United States"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": null,
      "seniority": null,
      "salary": {
        "min": 17.5,
        "max": 21,
        "currency": "USD",
        "unit": "HOUR",
        "annual_min": 36400,
        "annual_max": 43680,
        "summary": "$17.50 – $21.00 an hour"
      },
      "skills": ["patient registration", "epic"],
      "taxonomies": ["Healthcare"],
      "apply_url": "https://recruiting.ultipro.com/WES1004/JobBoard/8c2e1a70-5f43-4b91-9d0a-3e6c7b28f150/OpportunityDetail?opportunityId=5b8f0f2e-91c4-4d17-a0e6-7f3b2c9d1a45",
      "url": "https://recruiting.ultipro.com/WES1004/JobBoard/8c2e1a70-5f43-4b91-9d0a-3e6c7b28f150/OpportunityDetail?opportunityId=5b8f0f2e-91c4-4d17-a0e6-7f3b2c9d1a45",
      "source": "ultipro",
      "posted_at": "2026-07-11T00:27:00.000Z",
      "expires_at": null,
      "updated_at": "2026-07-14T06:12:41.000Z"
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

`work_arrangement` is null there on purpose, and `salary` is quoted per hour
rather than per year. Both are the platform showing through — see below.

## How UKG Pro boards actually work

Two calls. The list is a POST and returns the whole board:

```bash
# 1. List — one request, every posting, no paging loop
curl -X POST "https://<host>/<tenant>/JobBoard/<guid>/JobBoardView/LoadSearchResults" \
  -H "content-type: application/json" \
  -d '{"opportunitySearch":{"Top":2000,"Skip":0}}'

# 2. Detail — one per posting; this is the only place the body exists
curl "https://<host>/<tenant>/JobBoard/<guid>/OpportunityDetail?opportunityId=<Id>"
```

`Top` is genuinely uncapped, which is rare in this pool. `Top: 2000` returned all
254 postings of one board and all 388 of another in a single request, set-identical
to the paged union, so there is no pagination loop to get wrong. Only `Top` and
`Skip` matter; `QueryString`, `OrderBy`, `Filters` and `matchCriteria` are all
optional.

The detail is not JSON. It's an HTML page with the posting inside a
`new US.Opportunity.CandidateOpportunityDetail({…})` JavaScript literal, so you
brace-match the object (string-aware, or a `}` inside a description ends it early)
and `JSON.parse` what you cut out. The list's `BriefDescription` is a teaser
between roughly 109 and 718 characters and is never the body.

### The board address is three things, not one

| From `https://recruiting2.ultipro.com/WES1004/JobBoard/8c2e1a70-…` | |
| --- | --- |
| **host** | `recruiting2.ultipro.com` |
| **tenant** | `WES1004` |
| **board guid** | `8c2e1a70-5f43-4b91-9d0a-3e6c7b28f150` |

`recruiting` and `recruiting2` are **not** interchangeable, and there's no rule
that gets you from a company to its host — the wrong one 404s. Miss any of the
three and you have nothing.

### The salary fields are not the salary

This is the one to know about UKG Pro, and it is the reason a naive scrape of this
platform ships numbers that are worse than no numbers.

`CompensationAnnualMinimum` / `CompensationAnnualMaximum` and
`CompensationHourlyMinimum` / `CompensationHourlyMaximum` look exactly like the
pay band. They are the platform's **validation bounds** — the range the tenant's
admin configured as acceptable input. One real posting carries an annual minimum
of `20.80` and an annual maximum of `2,077,920.00`, with an hourly minimum of
`0.01`, sitting directly beside its actual advertised rate of $17.50/hr. Read them
and every posting on the board reports a $20.80–$2m salary, at HTTP 200, with a
plausible-looking object.

The real shape is elsewhere:

| Field | What it holds |
| --- | --- |
| `PayRange.PayRangeMinimum` / `PayRangeMaximum` | The advertised band, when the employer published one |
| `PayRangeCurrencyCode` | Its currency |
| `CompensationAmount` `{Code, Value}` | A single posted rate, when there's no band |
| `Salaried` | A **boolean** — the only period signal on the platform |

There is no period string anywhere. `Salaried: true` means the amounts are annual,
`false` means hourly, and when it's absent you cannot tell $60/hr from a $60
salary, so the honest move is to emit nothing. Moving from the bounds to these
fields took salary on the verified boards from 0% to **92%**.

## What bites you

<Warning title="A minimal request body returns an empty board, not an error">
  `POST {}` to `LoadSearchResults` answers **HTTP 200** with
  `{"opportunities":[],"totalCount":0}`. That is byte-for-byte what a real board
  with no open roles looks like.

  So a renamed field, a typo in `opportunitySearch`, or a body that drifts when
  the platform tightens something reads as "this company isn't hiring" — forever,
  silently, with a green status code. There is no error to catch and nothing to
  alert on.
</Warning>

**`FullTime` is a boolean.** There is no employment-type string anywhere on the
platform. `true` and `false` are all you get, which is why
[`employment_type`](/docs/objects/job#classification) on this source is only ever
full-time, part-time, or nothing.

**`LocalizedName` is a site label, and often an empty one.** It reads like a
location — `WCH Service Ctr.` — and on most jobs of some tenants it's the empty
string, so a naive join yields `""`. The structured `Address` (city, state,
country) is the ground truth.

**The list and the detail disagree about `PostedDate`** for the same job — one
board reported `2026-07-15T00:27Z` on the list and `2026-07-14T12:58Z` on the
detail. Neither is wrong exactly; you just have to pick one deliberately and know
you did. We take the list's, because it's the board's own view of itself.

**`JobLocationType` does not decode.** It's an unlabeled integer enum, unset on
about 74% of postings, and it doesn't survive contact with reality: on one tenant
`JobLocationType=1` covered **0 of 64** jobs literally named "Remote", while `2`
covered remote and on-site roles alike. We don't map it, which is why
`work_arrangement` is null on this source far more often than on
[Lever](/ats/lever), where it's a real four-value enum. Null there means "the
platform didn't say", not "we didn't look".

**`totalCount` returns 0 on an over-shot page.** If you do page, never read the
total past the end of the list — it reports what came back, not the board size.

## Or use ours

The fetch is one POST. Everything after it is the work.

You get the description already cut out of its JavaScript literal, the pay read
from `PayRange`/`CompensationAmount` with `Salaried` as the period rather than
from the validation bounds that would put a $2m ceiling on a $17.50/hr job, the
location built from the structured address instead of an empty site label, and
the [company join](/docs/objects/company) — a UKG Pro board tells you `WES1004`
and nothing else about who that is.

Refresh cadence isn't uniform across [sources](/docs/reference/sources), and the
per-posting detail call makes this one more expensive than a board that ships its
bodies inline. See [coverage](/docs/reference/coverage); don't assume every source
moves at the same speed.

Query it with `?source=ultipro`.
[Quickstart](/docs/quickstart) · [Pricing](/pricing)
