HireTrue Jobs API.

Pull complete requisition collections from HireTrue's ce3 candidate-experience boards — counties, state agencies and private employers — through the same anonymous JSON API the Angular front end calls.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on HireTrue.

Data fields

  • Complete Requisition Collections
  • Five-Section Job Descriptions
  • Salary and Salary Type
  • Department, Division & Facility
  • Open and Close Timestamps
  • Requisition Numbers

Use cases

  1. 01Public-Sector Job Aggregation
  2. 02County & State Agency Feeds
  3. 03Government Hiring Trends
  4. 04ATS Data Pipelines

Trusted by

  • State of Missouri
  • York County
  • Franklin County
  • Alabama State Department of Education
  • Navajo Housing Authority
DIY GUIDE

How to scrape HireTrue.

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

API type
REST
Difficulty
intermediate
Rate limit
No published limit; ~150ms between requests and at most 3 concurrent detail calls
Authentication
No auth

Take the board GUID out of the ce3 route

Every HireTrue board lives on hiretrue-prod.com under /hiretrue/ce3/job-board/{boardGuid}, with an optional second GUID for the requisition. Both are plain UUIDs and both come from the path only — the ?jb=1 flag that appears on shared links carries no identity and can be discarded.

Step 1: Take the board GUID out of the ce3 route
import re
from urllib.parse import urlparse

HOST = "hiretrue-prod.com"
APP_ROOT = "/hiretrue/ce3"
API_ROOT = "/hiretrue/api/ce3"
GUID = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    re.IGNORECASE,
)
ROUTES = {"job-board", "job-board-welcome", "job-application"}

def parse_route(url: str) -> tuple[str, str | None] | None:
    parsed = urlparse(url)
    if parsed.netloc.lower().removeprefix("www.") != HOST:
        return None
    parts = parsed.path.strip("/").split("/")
    if len(parts) < 4 or parts[0] != "hiretrue" or parts[1] != "ce3":
        return None
    if parts[2] not in ROUTES or not GUID.match(parts[3]):
        return None
    requisition = parts[4].lower() if len(parts) == 5 and GUID.match(parts[4]) else None
    return parts[3].lower(), requisition

board_id, requisition_id = parse_route(
    "https://hiretrue-prod.com/hiretrue/ce3/job-board/"
    "a9a97c37-cc24-43d0-a7c3-6a931bc18276?jb=1"
)
print(board_id)

Resolve the board and get its primary key

The listings call is keyed by a numeric primary key, not the GUID, so resolve the board first. Verify that the response echoes back the externalId you asked for, and refuse an empty body: an unknown GUID answers HTTP 200 with nothing at all, and reading that as an empty board would expire every job on the tenant.

Step 2: Resolve the board and get its primary key
import requests

def board_api_url(board_id: str) -> str:
    return f"https://{HOST}{API_ROOT}/job-board?externalId={board_id}"

def resolve_board(session: requests.Session, board_id: str) -> dict:
    response = session.get(
        board_api_url(board_id),
        headers={"Accept": "application/json"},
        timeout=30,
    )
    response.raise_for_status()

    # An unrecognised GUID returns 200 with an EMPTY body. That is "board not
    # found", never "board with no jobs".
    if not response.text.strip():
        raise RuntimeError(f"HireTrue does not recognise board {board_id}")

    board = response.json()
    if int(board.get("primaryKey") or 0) <= 0:
        raise RuntimeError(f"HireTrue does not recognise board {board_id}")
    if (board.get("externalId") or "").lower() != board_id:
        raise RuntimeError("HireTrue answered with a different board than requested")
    if board.get("permissioned") or board.get("requiresInvitation"):
        raise RuntimeError("HireTrue board is gated behind a password or invitation")
    return board

session = requests.Session()
board = resolve_board(session, "a9a97c37-cc24-43d0-a7c3-6a931bc18276")
print(board["name"], board["primaryKey"], "internal:", board.get("internal"))

Read the whole requisition collection in one request

The requisitions endpoint takes the board's primary key and returns every published requisition as a flat array — there is no cursor, page or limit parameter, and boards with thousands of rows still answer in one response. Each row carries its own numeric primaryKey and GUID externalId, plus title, location, division and open/close timestamps in epoch milliseconds.

Step 3: Read the whole requisition collection in one request
from datetime import datetime, timezone

def epoch_ms(value) -> str | None:
    if not value or value <= 0:
        return None
    return datetime.fromtimestamp(value / 1000, tz=timezone.utc).isoformat()

def fetch_requisitions(session: requests.Session, board: dict) -> list[dict]:
    response = session.get(
        f"https://{HOST}{API_ROOT}/job-board/requisitions",
        params={"jobBoardPrimaryKey": board["primaryKey"]},
        headers={"Accept": "application/json"},
        timeout=60,
    )
    response.raise_for_status()
    rows = response.json()
    if not isinstance(rows, list):
        raise RuntimeError("HireTrue requisitions API omitted its collection")
    return rows

def map_row(row: dict, board_id: str, board: dict) -> dict | None:
    requisition_id = (row.get("externalId") or "").lower()
    if int(row.get("primaryKey") or 0) <= 0 or not GUID.match(requisition_id):
        return None
    title = (row.get("title") or row.get("position") or "").strip()
    if not title:
        return None
    return {
        "primary_key": str(row["primaryKey"]),
        "requisition_id": requisition_id,
        "requisition_number": row.get("requisitionNumber"),
        "title": title,
        "company": board.get("companyName"),
        "location": row.get("location"),
        "department": row.get("department"),
        "division": row.get("division"),
        "position_type": row.get("positionType"),
        "opened_at": epoch_ms(row.get("openTimestamp")),
        "closes_at": epoch_ms(row.get("closeTimestamp")),
        "listing_url": f"https://{HOST}{APP_ROOT}/job-board/{board_id}/{requisition_id}",
        "apply_url": f"https://{HOST}{APP_ROOT}/job-application/{board_id}/{requisition_id}",
    }

rows = fetch_requisitions(session, board)
listings = [m for m in (map_row(r, board_id, board) for r in rows) if m]
print(f"{len(listings)} requisitions mapped of {len(rows)} returned")

Hydrate the advert from the numeric primary key

The listing row only carries a descriptionPreview truncated at roughly 300 characters. The full advert is split across descriptionSection1 through descriptionSection5 on the single-requisition resource, which is keyed by the numeric primaryKey — passing the GUID there returns HTTP 400.

Step 4: Hydrate the advert from the numeric primary key
MISSING = "Incorrect result size: expected 1, actual 0"

def fetch_detail(session: requests.Session, listing: dict) -> dict | None:
    response = session.get(
        f"https://{HOST}{API_ROOT}/job-board/requisitions/{listing['primary_key']}",
        headers={"Accept": "application/json"},
        timeout=30,
    )
    if response.status_code in (404, 410):
        return None  # canonical removal
    # The endpoint answers an unknown key with 400 and this exact message. Any
    # other 400 (for example "Invalid UUID string") is a transport bug, not removal.
    if response.status_code == 400 and MISSING in response.text:
        return None
    response.raise_for_status()

    detail = response.json()
    sections = [
        (detail.get(f"descriptionSection{n}") or "").strip()
        for n in range(1, 6)
    ]
    description = "\n".join(s for s in sections if s)
    if not description:
        raise RuntimeError("HireTrue requisition omitted every description section")

    return {
        **listing,
        "description_html": description,
        "salary": detail.get("salary"),
        "salary_type": detail.get("salaryType"),
        "scheduled_hours": (detail.get("scheduledStartHour"), detail.get("scheduledEndHour")),
        "posted_at": epoch_ms(detail.get("openDate")) or listing["opened_at"],
    }

for listing in listings[:3]:
    job = fetch_detail(session, listing)
    if job:
        print(job["title"], "-", job["salary"])
Common issues
criticalAn unknown board GUID returns HTTP 200 with an empty body
HireTrue answers /job-board?externalId={guid} with a 200 and no body when the GUID is not a board. Treat an empty body, a non-positive primaryKey, or an echoed externalId that differs from the one you sent as 'board not found' — never as a board with zero jobs, which would expire the whole tenant.
highThe detail endpoint rejects the requisition GUID with HTTP 400
Requisition details are keyed by the numeric primaryKey from the listings row, not by the externalId GUID. Passing the GUID produces a 400 number-format error. Keep the primary key as the record identifier and use the GUID only for the canonical board and apply URLs.
mediumOnly a ~300 character preview of the description is available
The listings row carries a vendor-truncated descriptionPreview. Fetch /job-board/requisitions/{primaryKey} and concatenate descriptionSection1 through descriptionSection5 — a single section is often blank, so joining all five is what produces the complete advert.
mediumA requisition still resolves after it left the board
The detail endpoint is board-agnostic and keeps returning an unpublished requisition, so a successful detail response is not proof the job is still listed. Use the board's own requisition collection as the authority for what is open, and reserve HTTP 404/410 plus the exact 400 body 'Incorrect result size: expected 1, actual 0' for genuine removal.
Best practices
  1. 1Take both GUIDs from the path and ignore the ?jb=1 flag entirely
  2. 2Resolve the board first and require it to echo back the externalId you requested
  3. 3Refuse an empty board response instead of interpreting it as an empty board
  4. 4Skip boards whose permissioned or requiresInvitation flag is set — those need credentials
  5. 5Fetch requisition details by numeric primaryKey and keep the GUID for canonical URLs
  6. 6Read published state from the board collection, not from whether a detail call succeeds
Or skip the complexity

One endpoint. All HireTrue jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=hiretrue" \
  -H "X-Api-Key: YOUR_KEY"
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.

Ready to integrate

Access HireTrue
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the $5 free starting balance and scale as you grow.

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