Infor HCM Candidate Experience Jobs API.

Read enterprise Infor CloudSuite and Infor Government careers boards — health systems, agriculture groups and state agencies — through the same anonymous Landmark JSON resources the candidate portal loads.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on Infor HCM Candidate Experience.

Data fields

  • Structured Position Descriptions
  • Salary Range and Pay Fields
  • Category & Work Type
  • Location Of Job
  • Posting Begin and End Dates
  • Server-Issued Paging Cursors

Use cases

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

Trusted by

  • AgReserves
  • Akron Children's Hospital
  • State of Idaho
  • Hillsborough County
DIY GUIDE

How to scrape Infor HCM Candidate Experience.

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

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

Read the deployment host and the board scope

An Infor board is identified by three things: the full deployment host, the HR organization, and the job board key. The host is css-{deployment}-prd.inforcloudsuite.com, optionally with a regional .tam.{region} segment, or css-{deployment}-prd.tam.inforgov.com for government tenants. The organization and board come from the csk.HROrganization and csk.JobBoard query keys — neither has a usable default, so both must come from the URL.

Step 1: Read the deployment host and the board scope
import re
from urllib.parse import urlparse, parse_qs, quote

CLOUDSUITE = re.compile(
    r"^css-[a-z0-9][a-z0-9-]*-prd(?:\.tam\.[a-z0-9][a-z0-9-]*)?\.inforcloudsuite\.com$",
    re.IGNORECASE,
)
INFORGOV = re.compile(
    r"^css-[a-z0-9][a-z0-9-]*-prd\.tam\.inforgov\.com$", re.IGNORECASE
)

def parse_board(url: str) -> tuple[str, str, str] | None:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    if parsed.scheme != "https" or not (CLOUDSUITE.match(host) or INFORGOV.match(host)):
        return None
    query = parse_qs(parsed.query)
    org = (query.get("csk.HROrganization") or [None])[0]
    board = (query.get("csk.JobBoard") or [None])[0]
    if not org or not board:
        return None
    # Board keys vary per tenant: EXTERNAL is common but far from universal.
    return host, org.lower(), board.lower()

host, org, board = parse_board(
    "https://css-akronchildrens-prd.inforcloudsuite.com/hcm/Jobs/banner/home"
    "?csk.HROrganization=1&csk.JobBoard=external"
)
print(host, org, board)

Load the first page of the SearchForJobsResults list

The listing resource is /hcm/Jobs/list/JobPosting.SearchForJobsResults with pageop=load and a bounded pagesize. An anonymous request is redirected through Infor's SSO servlet and back to the same resource as JSON, so use a session that keeps cookies and follows redirects, and treat any response whose status is not COMPLETED as a failure rather than an empty board.

Step 2: Load the first page of the SearchForJobsResults list
import requests

PAGE_SIZE = 100

def list_url(host: str, org: str, board: str) -> str:
    return (
        f"https://{host}/hcm/Jobs/list/JobPosting.SearchForJobsResults"
        f"?pageop=load&pagesize={PAGE_SIZE}"
        f"&csk.HROrganization={quote(org)}&csk.JobBoard={quote(board)}"
    )

def load(session: requests.Session, url: str) -> dict:
    # Infor 302s an anonymous caller through /sso/SSOServlet and back to the same
    # resource; a cookie-keeping session that follows redirects lands on the JSON.
    response = session.get(
        url,
        headers={"Accept": "application/json, text/plain, */*"},
        timeout=60,
        allow_redirects=True,
    )
    response.raise_for_status()
    payload = response.json()

    status = payload.get("status")
    if status != "COMPLETED":
        # UNAUTHORIZED means the board is gated, not that it is empty.
        raise RuntimeError(f"Infor HCM returned status {status}")
    return payload

session = requests.Session()
first = load(session, list_url(host, org, board))
view = first["dataViewSet"]
print(view["header"]["resourceId"])  # JobPosting[JobPostingSet](1,_niu_,_niu_)

Follow the server-issued next-page URL

Landmark returns its own cursor: pagingInfo.hasNext tells you whether another set exists and pagingUrls.nextPageUrl is the exact URL to request. Never synthesise an offset — follow the provided URL, and first check that it still names the same host, resource, organization and board, so a foreign cursor cannot pull you onto another tenant.

Step 3: Follow the server-issued next-page URL
def next_page_url(view: dict, host: str, org: str, board: str) -> str | None:
    if not (view.get("pagingInfo") or {}).get("hasNext"):
        return None
    candidate = (view.get("pagingUrls") or {}).get("nextPageUrl")
    if not candidate:
        raise RuntimeError("Infor HCM reported another page with no cursor")

    parsed = urlparse(candidate)
    query = parse_qs(parsed.query)
    same_scope = (
        parsed.netloc.lower() == host
        and "SearchForJobsResults" in parsed.path
        and (query.get("csk.HROrganization") or [""])[0].lower() == org
        and (query.get("csk.JobBoard") or [""])[0].lower() == board
    )
    if not same_scope:
        raise RuntimeError("Infor HCM issued a cursor outside the requested board")
    return candidate

def fetch_all(session: requests.Session, host: str, org: str, board: str) -> list[dict]:
    payload = load(session, list_url(host, org, board))
    rows, view = [], payload["dataViewSet"]
    while True:
        rows.extend(view.get("data") or [])
        following = next_page_url(view, host, org, board)
        if not following:
            return rows
        view = load(session, following)["dataViewSet"]

rows = fetch_all(session, host, org, board)
print(f"{len(rows)} postings")

Map each row from its native resource id

Every row names itself as JobPosting[JobPostingSet]({organization},{requisition},{posting}). Parse that triple and require the row's own HROrganization, JobRequisition and JobPosting fields to agree with it before mapping. Titles and locations arrive under Landmark's encoded field names, where _op_ and _cp_ stand for the parentheses in the original expression.

Step 4: Map each row from its native resource id
RESOURCE = re.compile(
    r"^JobPosting\[JobPostingSet\]\(([^,]+),([1-9][0-9]*),([1-9][0-9]*)\)$"
)

def field(fields: dict, name: str):
    value = fields.get(name)
    return value.get("value") if isinstance(value, dict) else value

def detail_url(host: str, org: str, board: str, req: str, posting: str) -> str:
    resource = quote(f"JobPosting[JobPostingSet]({org},{req},{posting})", safe="")
    return (
        f"https://{host}/hcm/Jobs/navigation/{resource}.JobPostingDisplayNav"
        f"?csk.HROrganization={quote(org)}&csk.JobBoard={quote(board)}"
    )

def map_row(row: dict, host: str, org: str, board: str) -> dict | None:
    match = RESOURCE.match(row.get("resourceId") or "")
    fields = row.get("fields") or {}
    if not match or match.group(1).lower() != org:
        return None
    req, posting = match.group(2), match.group(3)
    if str(field(fields, "JobRequisition")) != req or str(field(fields, "JobPosting")) != posting:
        return None  # a row that contradicts its own resource id is never trusted

    return {
        "external_id": f"{req}|{posting}",
        "requisition": req,
        "posting": posting,
        "title": field(fields, "_op_Description_spc_translation_cp_") or field(fields, "Description"),
        "location": field(fields, "LocationOfJobDescriptionForSort"),
        "category": field(fields, "CategoryDescriptionForSort"),
        "work_type": field(fields, "WorkType"),
        "posted_at": field(fields, "PostingDateRange_prd_Begin"),
        "listing_url": detail_url(host, org, board, req, posting),
    }

listings = [m for m in (map_row(r, host, org, board) for r in rows) if m]
print(f"{len(listings)} mapped of {len(rows)} received")

Hydrate the advert from the JobPostingDisplay form

The full position description lives on /hcm/Jobs/form/{resource}.JobPostingDisplay with pageop=load and pagesize=1. Confirm the response's resourceId and its HROrganization, JobBoard, JobRequisition and JobPosting fields all match what you requested. Ignore ShowApplyButtonApplicationProcess as a liveness signal — active boards do publish rows with that flag false.

Step 5: Hydrate the advert from the JobPostingDisplay form
def detail_data_url(host: str, org: str, board: str, req: str, posting: str) -> str:
    resource = quote(f"JobPosting[JobPostingSet]({org},{req},{posting})", safe="")
    return (
        f"https://{host}/hcm/Jobs/form/{resource}.JobPostingDisplay"
        f"?pageop=load&pagesize=1"
        f"&csk.HROrganization={quote(org)}&csk.JobBoard={quote(board)}"
    )

def fetch_detail(session: requests.Session, listing: dict, host: str, org: str, board: str) -> dict:
    payload = load(
        session,
        detail_data_url(host, org, board, listing["requisition"], listing["posting"]),
    )
    fields = payload.get("fields") or {}
    expected = f"JobPosting[JobPostingSet]({org},{listing['requisition']},{listing['posting']})"
    if (payload.get("resourceId") or "").lower() != expected.lower():
        raise RuntimeError("Infor HCM detail contradicted its requisition tuple")

    return {
        **listing,
        "title": field(fields, "_op_Description_spc_translation_cp_") or listing["title"],
        "description_html": field(fields, "_op_PositionDescription_spc_translation_cp_"),
        "salary": field(
            fields,
            "_op_FormattedSalaryRangeAmountWithCurrencyCodeAndPayRate_spc_translation_cp_",
        ),
        "closes_at": field(fields, "PostingDateRange_prd_End"),
        # Metadata only: a live posting can carry apply_visible = False.
        "apply_visible": field(fields, "ShowApplyButtonApplicationProcess"),
    }

for listing in listings[:3]:
    job = fetch_detail(session, listing, host, org, board)
    print(job["title"], "-", job["salary"])
Common issues
criticalThere is no single default board key
csk.JobBoard is EXTERNAL on most tenants but many deployments publish under their own key, and csk.HROrganization is numeric on some and symbolic on others. Take both from the first-party URL for that deployment; a hard-coded pair silently returns another board or nothing at all.
highThe request lands on an SSO page instead of JSON
Anonymous calls are redirected through Infor's /sso/SSOServlet and back to the same Landmark resource. Use a session that retains cookies across that chain and follows redirects; a client that drops cookies or refuses redirects ends up holding the login page.
highPagination is rebuilt with a guessed offset
Landmark issues its own continuation URL in pagingUrls.nextPageUrl and signals more data with pagingInfo.hasNext. Follow that exact URL after checking it still names the same host, resource, organization and board — a synthesised offset can skip or repeat whole result sets.
mediumA missing apply button is read as a closed job
ShowApplyButtonApplicationProcess is false on plenty of postings that are live on the board — verified on active health-system tenants. Keep it as metadata and take liveness from the board's own collection, reserving removal for an explicit 404 or 410 on the canonical form resource.
mediumA non-COMPLETED status is treated as an empty board
Every Landmark response carries a status field, and only COMPLETED means the payload is authoritative. UNAUTHORIZED indicates a gated board and any other value indicates a service problem; neither is evidence that the tenant has no jobs.
Best practices
  1. 1Treat the complete deployment host as the tenant — regional and opaque CloudSuite hosts cannot be rebuilt from a short token
  2. 2Carry csk.HROrganization and csk.JobBoard from the first-party URL on every request
  3. 3Use a cookie-retaining session that follows Infor's SSO redirect chain back to the JSON resource
  4. 4Require status COMPLETED and a matching header resourceId before mapping any row
  5. 5Follow pagingUrls.nextPageUrl verbatim and validate its scope before requesting it
  6. 6Fetch descriptions from the JobPostingDisplay form resource, not from the listing rows
Or skip the complexity

One endpoint. All Infor HCM Candidate Experience jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=infor hcm candidate experience" \
  -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 Infor HCM Candidate Experience
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