- highThe legacy chattr.ai alias is used as the employer identity
- jobs.chattr.ai/{alias}/{job} redirects into the UKG portal, but aliases are renamed and shared between brands, so two aliases can point at one organization. Resolve org.id from the portal's Next.js configuration and key the employer on that UUID.
- highThe listing collection has no descriptions
- getJobPortalJobs deliberately returns titles, locations and categories but not the advert. Fetch the canonical /{orgUuid}/{jobUuid} page and read pageProps.job, which carries the complete native record including the description and salary fields.
- highA removed job still returns a valid page
- A withdrawn posting redirects to the organization's board, which proves the company but carries no job object. Treat a missing job, or a native status other than Active, as evidence the posting has ended rather than as a parse failure to retry.
- mediumresult.hasJobs disagrees with the collection
- The response carries both an array and a boolean, and they should always agree. When they do not, the snapshot is untrustworthy and must not be used to expire missing jobs — fail the run instead of writing a partial board.
UKG RapidHire (formerly Chattr) Jobs API.
Collect hourly and shift roles from UKG RapidHire portals — the former Chattr product — using the public Cloud Function that returns an organization's whole job collection in one call.
What's in every response.
Data fields, real-world applications, and the companies already running on UKG RapidHire (formerly Chattr).
Data fields
- Whole Collection In One Call
- Full Native Descriptions
- Brand and Store Numbers
- Salary Type and Ranges
- Formatted Addresses
- Posted and Closed Dates
Use cases
- 01Hourly & Shift Work Aggregation
- 02Restaurant and Senior-Care Hiring
- 03Multi-Location Brand Tracking
- 04ATS Data Pipelines
Trusted by
- Daniel Hospitality Group
- Andalusia Manor
- BayWoods of Annapolis
How to scrape UKG RapidHire (formerly Chattr).
Step-by-step guide to extracting jobs from UKG RapidHire (formerly Chattr)-powered career pages—endpoints, authentication, and working code.
import json
import re
from urllib.parse import urlparse
import requests
HOST = "jobs.rapidhire.ukg.net"
LEGACY_HOST = "jobs.chattr.ai"
UUID = 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
)
def parse_canonical(url: str) -> tuple[str, str | None] | None:
parsed = urlparse(url)
if parsed.netloc.lower() != HOST:
return None
parts = parsed.path.strip("/").split("/")
if not parts or not UUID.match(parts[0]):
return None
job = parts[1] if len(parts) > 1 and UUID.match(parts[1]) else None
return parts[0].lower(), job
def board_url(org_id: str) -> str:
return f"https://{HOST}/{org_id}"
print(parse_canonical("https://jobs.rapidhire.ukg.net/c9558425-2f20-4425-9b0b-2bfadaabe577"))def page_props(session: requests.Session, url: str) -> tuple[dict, str]:
response = session.get(
url, headers={"Accept": "text/html,application/xhtml+xml"}, timeout=30,
allow_redirects=True, # legacy chattr.ai aliases redirect into the portal
)
response.raise_for_status()
match = re.search(
r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', response.text, re.S
)
if not match:
raise RuntimeError("RapidHire page carried no __NEXT_DATA__ payload")
props = json.loads(match.group(1))["props"]["pageProps"]
config, org = props.get("config") or {}, props.get("org") or {}
if config.get("baseHost") != HOST or not UUID.match(org.get("id") or ""):
raise RuntimeError("RapidHire page did not prove a complete organization tuple")
return props, config["apiPath"]
session = requests.Session()
props, api_base = page_props(session, board_url("c9558425-2f20-4425-9b0b-2bfadaabe577"))
print(props["org"]["name"], api_base)def fetch_jobs(session: requests.Session, api_base: str, org_id: str) -> list[dict]:
response = session.post(
f"{api_base.rstrip('/')}/getJobPortalJobs",
json={"data": {
"orgId": org_id,
"source": None,
"locationId": None,
"brandQueryString": "",
"internal": None,
}},
headers={"Content-Type": "application/json"},
timeout=60,
)
response.raise_for_status()
result = (response.json() or {}).get("result") or {}
jobs = result.get("jobs")
has_jobs = result.get("hasJobs")
if jobs is None or not isinstance(has_jobs, bool):
raise RuntimeError("RapidHire response omitted result.jobs or result.hasJobs")
# An inconsistent flag means the snapshot cannot be trusted to expire anything.
if bool(jobs) != has_jobs:
raise RuntimeError("RapidHire returned a job collection inconsistent with hasJobs")
return jobs
org_id = props["org"]["id"].lower()
rows = fetch_jobs(session, api_base, org_id)
print(f"{len(rows)} jobs on the board")def location_text(location: dict) -> str | None:
parts = [location.get("address1"), location.get("city"),
location.get("state"), location.get("zip")]
return location.get("formattedAddress") or ", ".join(p for p in parts if p) or None
def map_row(row: dict, org_id: str) -> dict | None:
job_id = (row.get("id") or "").lower()
title = (row.get("title") or "").strip()
if not UUID.match(job_id) or not title:
return None
location = row.get("location") or {}
url = f"https://{HOST}/{org_id}/{job_id}"
return {
"id": job_id,
"title": title,
"company": ((location.get("brand") or {}).get("name")) or location.get("companyName"),
"location": location_text(location),
"location_id": row.get("locationId"),
"store_number": location.get("storeNumber"),
"employment_type": row.get("type"),
"category": (row.get("category") or {}).get("name"),
"listing_url": url,
"apply_url": url,
}
listings = [m for m in (map_row(r, org_id) for r in rows) if m]
print(f"{len(listings)} mapped of {len(rows)} received")def fetch_job(session: requests.Session, listing: dict, org_id: str) -> dict | None:
try:
props, _ = page_props(session, listing["listing_url"])
except requests.HTTPError as error:
if error.response is not None and error.response.status_code in (404, 410):
return None # canonical removal
raise
job = props.get("job")
if not isinstance(job, dict):
# A removed job redirects to a proved organization board with no job object.
return None
if (job.get("id") or "").lower() != listing["id"] \
or (job.get("orgId") or "").lower() != org_id:
raise RuntimeError("RapidHire job did not match the requested native identity")
if (job.get("status") or "").lower() != "active":
return None # a non-active native status is removal evidence
description = (job.get("description") or "").strip()
if len(re.sub(r"<[^>]+>", "", description).strip()) < 80:
raise RuntimeError("RapidHire detail omitted a substantive description")
location = job.get("location") or {}
return {
**listing,
"title": (job.get("title") or listing["title"]).strip(),
"company": (props.get("org") or {}).get("nameDba") or listing["company"],
"description_html": description,
"location": location.get("formattedAddress") or listing["location"],
"salary_type": job.get("salaryType"),
"min_salary": job.get("minSalary"),
"max_salary": job.get("maxSalary"),
"posted_at": job.get("postedDtStr"),
"closed_at": job.get("closedDtStr"),
}
for listing in listings[:3]:
job = fetch_job(session, listing, org_id)
print(job["title"] if job else f"{listing['id']} is no longer active")- 1Key the employer on the organization UUID, never on a legacy chattr.ai alias
- 2Read config.apiPath from the portal's own __NEXT_DATA__ rather than hard-coding the Cloud Function host
- 3Send the unfiltered request body exactly as the portal does — empty source, locationId, brand and internal
- 4Check result.jobs against result.hasJobs before treating the snapshot as authoritative
- 5Require job.id and job.orgId to match the request before mapping a detail record
- 6Read liveness from the native status field, not from the HTTP status alone
One endpoint. All UKG RapidHire (formerly Chattr) jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=ukg rapidhire (formerly chattr)" \
-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 UKG RapidHire (formerly Chattr)
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.