MHR iTrent Jobs API.
Extract every live vacancy from iTrent-powered career sites — the UK HR and payroll platform behind many councils, universities, and public-sector employers — as structured JSON with salaries, locations, and closing dates.
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 MHR iTrent.
- Full Job Descriptions
- Salary Text & Pay Bands
- Locations & Regions
- Contract Basis & Hours
- Business Reference Codes
- Posted & Closing Dates
- 01UK Public-Sector Job Boards
- 02Council & University Vacancy Feeds
- 03Salary Benchmarking
- 04Recruitment Market Analytics
How to scrape MHR iTrent.
Step-by-step guide to extracting jobs from MHR iTrent-powered career pages—endpoints, authentication, and working code.
import re
import time
import requests
# A live iTrent board: host label and /{instance}_webrecruitment/ must match.
# Use the tenant's exact route (ETREC105GF or ETREC179GF).
BOARD = "https://ce0164li.webitrent.com/ce0164li_webrecruitment/wrd/run/ETREC105GF.open"
WVID, LANG = "8433573cTb", "USA"
http = requests.Session()
http.headers["User-Agent"] = "Mozilla/5.0"
def bootstrap_session(open_url, params):
"""The .open page embeds a fresh USESSION token in its response."""
resp = http.get(open_url, params=params, timeout=30)
resp.raise_for_status()
match = re.search(r"[?&]USESSION=([A-F0-9]{16,})", resp.text, re.IGNORECASE)
if not match:
raise RuntimeError("iTrent bootstrap did not expose a USESSION - retry")
return match.group(1), resp.url
usession, referer = bootstrap_session(BOARD, {"WVID": WVID, "LANG": LANG})def endpoint(board_url, name):
base = board_url.rsplit("/", 1)[0] # .../wrd/run
return f"{base}/{name}"
LISTINGS = endpoint(BOARD, "etrec106gf.json")
def fetch_listings(page=None):
session_qs = f"WVID={WVID}&USESSION={usession}&LANG={LANG}"
mhr_params = session_qs
if page: # (rec_from, rec_to, results_pp, total_rec) — only after page 1
mhr_params += f"&RESULTS_PP={page[2]}&TOTAL_REC={page[3]}&REC_FROM={page[0]}&REC_TO={page[1]}"
resp = http.get(
LISTINGS,
params={"WVID": WVID, "USESSION": usession, "LANG": LANG},
headers={
"Accept": "application/json, text/plain, */*",
"X-Requested-With": "XMLHttpRequest",
"Referer": referer,
"mhrParams": mhr_params, # required XHR header, else you get HTML
},
timeout=30,
)
resp.raise_for_status()
if resp.text.lstrip().startswith("<"):
raise RuntimeError("iTrent served HTML instead of JSON - refresh USESSION")
return resp.json()results, page = [], None
while True:
payload = fetch_listings(page)
search = payload["search"]
results.extend(payload["results"])
if search["rec_to"] >= search["total_rec"]:
break
nxt_from = search["rec_to"] + 1
nxt_to = min(search["total_rec"], search["rec_to"] + search["results_pp"])
page = (nxt_from, nxt_to, search["results_pp"], search["total_rec"])
time.sleep(2) # single in-flight request, ~2s apart
print(f"{len(results)} of {search['total_rec']} vacancies collected")DETAIL = endpoint(BOARD, "etrec107gf.json")
def fetch_detail(vacancy_id):
# USESSION is volatile — establish a new one per detail via the .open route.
sess, ref = bootstrap_session(
endpoint(BOARD, "ETREC107GF.open"),
{"WVID": WVID, "LANG": LANG, "VACANCY_ID": vacancy_id})
resp = http.get(
DETAIL,
params={"WVID": WVID, "USESSION": sess, "LANG": LANG, "VACANCY_ID": vacancy_id},
headers={
"Accept": "application/json, text/plain, */*",
"X-Requested-With": "XMLHttpRequest",
"Referer": ref,
},
timeout=30,
)
resp.raise_for_status()
profile = resp.json() # JSON array with exactly one object
if len(profile) != 1 or profile[0].get("vacancy_id") != vacancy_id:
raise RuntimeError("iTrent detail did not return the requested vacancy")
job = profile[0]
return {
"vacancy_id": job["vacancy_id"], # persistent job key
"business_ref": job.get("vacancy_ref"),
"title": job["job_titles"],
"description": job["job_description"], # HTML; expect 80+ chars of text
"location": job.get("location"),
"salary": job.get("salary"),
"hours": job.get("con_hrs"),
"posted": job.get("vacancy_d"), # yyyyMMdd
"closes": job.get("app_close_d"), # yyyyMMdd
"apply_url": job.get("apply_url"),
}
for job in results:
detail = fetch_detail(job["vacancy_id"])
time.sleep(2)Detect a body that begins with '<' and treat it as a failure, not an empty board. Re-fetch the .open page for a fresh USESSION and retry the JSON call.
Send the WVID/USESSION/LANG string in both the query and an mhrParams header, appending RESULTS_PP/TOTAL_REC/REC_FROM/REC_TO for every page after the first.
Bootstrap a fresh USESSION from the .open page immediately before each JSON burst, and never store it in an external ID or metadata.
Preserve the exact tenant instance, WVID and route observed on the host, and only exchange the trailing .open route for the matching .json endpoint.
Continue until rec_to equals total_rec, and discard the snapshot if total_rec changes between pages before restarting.
- 1Bootstrap a fresh USESSION from the .open page right before each JSON call, and never persist it in identity or metadata.
- 2Send WVID/USESSION/LANG in both the query string and the mhrParams header, plus X-Requested-With: XMLHttpRequest.
- 3Preserve the tenant instance, WVID, and exact ETREC105GF/179GF route; only swap the trailing route to reach the JSON endpoints.
- 4Treat any response body starting with '<' as a session/error failure and retry with a new USESSION, not as an empty board.
- 5Page until rec_to equals total_rec, and restart the snapshot if total_rec changes between requests.
- 6Throttle to a single in-flight request roughly 2 seconds apart and back off on HTTP 403/429.
One endpoint. All MHR iTrent jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=mhr itrent" \
-H "X-Api-Key: YOUR_KEY" Access MHR iTrent
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.