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.

Get API access

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

  1. 01Hourly & Shift Work Aggregation
  2. 02Restaurant and Senior-Care Hiring
  3. 03Multi-Location Brand Tracking
  4. 04ATS Data Pipelines

Trusted by

  • Daniel Hospitality Group
  • Andalusia Manor
  • BayWoods of Annapolis
DIY GUIDE

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.

API type
Hybrid
Difficulty
intermediate
Rate limit
No published limit; ~100ms between requests and at most 4 concurrent detail fetches
Authentication
No auth

Resolve the organization UUID behind the alias

Canonical RapidHire routes are jobs.rapidhire.ukg.net/{orgUuid} and /{orgUuid}/{jobUuid}. Legacy jobs.chattr.ai/{alias}/{jobUuid} links redirect into the same portal but the alias is not an identity — aliases get renamed and shared. Fetch the page and read org.id from the Next.js configuration instead.

Step 1: Resolve the organization UUID behind the alias
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"))

Read the portal's own configuration from __NEXT_DATA__

Every RapidHire page embeds a Next.js __NEXT_DATA__ script whose pageProps carry config.apiPath, config.baseHost and the org object. Take the API base from that configuration rather than hard-coding it, and require the config and org tuple to be complete before trusting anything else on the page.

Step 2: Read the portal's own configuration from __NEXT_DATA__
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)

Post the org UUID to getJobPortalJobs

The listing collection comes from a single POST with an unfiltered body: source, locationId, brandQueryString and internal all left empty. The response is an unpaged result.jobs array alongside a result.hasJobs flag — the two must agree, and a valid empty response is an authoritative empty board.

Step 3: Post the org UUID to getJobPortalJobs
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")

Map rows and build canonical job URLs

Each row carries a UUID id, a title, and a nested location object with the brand name, store number and address. Require both the UUID and the title before accepting a row, and build the canonical /{orgUuid}/{jobUuid} URL — the collection deliberately omits the full description.

Step 4: Map rows and build canonical job URLs
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")

Hydrate the description from the job page

The public collection omits the advert, so fetch the canonical job URL and read pageProps.job, which holds the full native record. Require job.id and job.orgId to match what you requested and status to be Active — anything else means the posting has come off the board.

Step 5: Hydrate the description from the job page
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")
Common issues
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.
Best practices
  1. 1Key the employer on the organization UUID, never on a legacy chattr.ai alias
  2. 2Read config.apiPath from the portal's own __NEXT_DATA__ rather than hard-coding the Cloud Function host
  3. 3Send the unfiltered request body exactly as the portal does — empty source, locationId, brand and internal
  4. 4Check result.jobs against result.hasJobs before treating the snapshot as authoritative
  5. 5Require job.id and job.orgId to match the request before mapping a detail record
  6. 6Read liveness from the native status field, not from the HTTP status alone
Or skip the complexity

One endpoint. All UKG RapidHire (formerly Chattr) jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=ukg rapidhire (formerly chattr)" \
  -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 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.

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