UKG Pro Jobs API
Query jobs from UKG Pro boards through one API with ?source=ultipro — plus how UltiPro's LoadSearchResults endpoint really behaves.
UKG Pro is the HR and payroll suite formerly called UltiPro, and like
Trakstar Hire, 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 rather than trusting this sentence.
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 → sources, and the
source field on every returned posting. If you're looking for ukg in the
vocabulary, that's why it isn't there.
Try it#
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"{
"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:
# 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#
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.
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 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, 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 — a UKG Pro board tells you WES1004
and nothing else about who that is.
Refresh cadence isn't uniform across sources, and the per-posting detail call makes this one more expensive than a board that ships its bodies inline. See coverage; don't assume every source moves at the same speed.
Query it with ?source=ultipro.
Quickstart · Pricing
Every source above normalises onto the same schema, with the hiring company already joined onto each posting.
Get an API key