- 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.
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.
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
- 01Enterprise Job Aggregation
- 02Healthcare & Public-Sector Feeds
- 03Government Hiring Trends
- 04ATS Data Pipelines
Trusted by
- AgReserves
- Akron Children's Hospital
- State of Idaho
- Hillsborough County
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.
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)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_)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")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")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"])- 1Treat the complete deployment host as the tenant — regional and opaque CloudSuite hosts cannot be rebuilt from a short token
- 2Carry csk.HROrganization and csk.JobBoard from the first-party URL on every request
- 3Use a cookie-retaining session that follows Infor's SSO redirect chain back to the JSON resource
- 4Require status COMPLETED and a matching header resourceId before mapping any row
- 5Follow pagingUrls.nextPageUrl verbatim and validate its scope before requesting it
- 6Fetch descriptions from the JobPostingDisplay form resource, not from the listing rows
One endpoint. All Infor HCM Candidate Experience jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=infor hcm candidate experience" \
-H "X-Api-Key: YOUR_KEY"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.
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.