All platforms

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.

Get API access
MHR iTrent
Live
<3haverage discovery time
1hrefresh interval
Companies using MHR iTrent
UWE BristolRotherham CouncilReading Borough Council
Developer tools

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.

Data fields
  • Full Job Descriptions
  • Salary Text & Pay Bands
  • Locations & Regions
  • Contract Basis & Hours
  • Business Reference Codes
  • Posted & Closing Dates
Use cases
  1. 01UK Public-Sector Job Boards
  2. 02Council & University Vacancy Feeds
  3. 03Salary Benchmarking
  4. 04Recruitment Market Analytics
Trusted by
UWE BristolRotherham CouncilReading Borough Council
DIY GUIDE

How to scrape MHR iTrent.

Step-by-step guide to extracting jobs from MHR iTrent-powered career pages—endpoints, authentication, and working code.

HybridadvancedNo published limit; throttle to 1 request at a time ~2s apart and back off on HTTP 403/429No auth

Bootstrap the guest session (USESSION)

Each iTrent customer is an instance on {instance}.webitrent.com serving its own {instance}_webrecruitment app. The tenant's exact ETREC105GF.open or ETREC179GF.open search page seeds a short-lived guest USESSION into its own markup — fetch it first, no credentials required.

Step 1: Bootstrap the guest session (USESSION)
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})

Call the listings JSON with the mhrParams header

The search route resolves to etrec106gf.json in the same /wrd/run/ path. iTrent gates the JSON on an mhrParams XHR header carrying the WVID/USESSION/LANG string (plus pagination on later pages) alongside X-Requested-With. A response body starting with '<' is an HTML session/error page, never data.

Step 2: Call the listings JSON with the mhrParams header
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()

Page through to the authoritative total

The response carries search.total_rec, search.rec_from, search.rec_to and search.results_pp. Continue paging until rec_to reaches total_rec, and abort the snapshot if total_rec shifts between pages. Each result exposes vacancy_id, job_title, salary, location_id, basis_id and a yyyyMMdd app_close_d.

Step 3: Page through to the authoritative total
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")

Hydrate each vacancy from etrec107gf.json

Detail lives at etrec107gf.json and needs its own fresh USESSION — bootstrap the stable ETREC107GF.open route first, then request the profile with the complete vacancy_id. The endpoint returns a JSON array of exactly one object; validate its vacancy_id and require substantive description text before trusting it.

Step 4: Hydrate each vacancy from etrec107gf.json
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)
Common issues
highThe etrec106gf.json / etrec107gf.json routes return a server-rendered HTML session or error page instead of JSON, usually after the guest USESSION has lapsed.

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.

highHitting the JSON endpoints without the mhrParams header (and X-Requested-With: XMLHttpRequest) returns an error page rather than vacancy data.

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.

mediumUSESSION is volatile guest state, not a durable key; reusing a stale token across a long crawl or persisting it in job identity breaks later requests.

Bootstrap a fresh USESSION from the .open page immediately before each JSON burst, and never store it in an external ID or metadata.

mediumTenants publish either the ETREC105GF or ETREC179GF search route while detail lives on ETREC107GF; swapping one route for another yields the wrong board or a 404.

Preserve the exact tenant instance, WVID and route observed on the host, and only exchange the trailing .open route for the matching .json endpoint.

mediumPagination runs against an authoritative total_rec, so stopping early or when the total shifts mid-crawl produces a partial or inconsistent snapshot.

Continue until rec_to equals total_rec, and discard the snapshot if total_rec changes between pages before restarting.

Best practices
  1. 1Bootstrap a fresh USESSION from the .open page right before each JSON call, and never persist it in identity or metadata.
  2. 2Send WVID/USESSION/LANG in both the query string and the mhrParams header, plus X-Requested-With: XMLHttpRequest.
  3. 3Preserve the tenant instance, WVID, and exact ETREC105GF/179GF route; only swap the trailing route to reach the JSON endpoints.
  4. 4Treat any response body starting with '<' as a session/error failure and retry with a new USESSION, not as an empty board.
  5. 5Page until rec_to equals total_rec, and restart the snapshot if total_rec changes between requests.
  6. 6Throttle to a single in-flight request roughly 2 seconds apart and back off on HTTP 403/429.
Or skip the complexity

One endpoint. All MHR iTrent jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=mhr itrent" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

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.

99.9%API uptime
<200msAvg response
50M+Jobs processed