All platforms

PageUp Jobs API.

Pull every posting from enterprise PageUp career sites — universities, health systems, and government tenants — through one normalized jobs API, without probing each numeric portal by hand.

Get API access
PageUp
Live
<3haverage discovery time
1hrefresh interval
Companies using PageUp
Flight CentreUF HealthArizona Department of Administration
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.

What's in every response.

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

Data fields
  • Full Job Descriptions
  • Employment Type & Category
  • Department & Team
  • Location Details
  • Open & Close Dates
  • External Job Numbers
Use cases
  1. 01Enterprise Job Aggregation
  2. 02Healthcare & Government Job Boards
  3. 03University Careers Feeds
  4. 04Talent Market Research
Trusted by
Flight CentreUF HealthArizona Department of Administration
DIY GUIDE

How to scrape PageUp.

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

HybridadvancedNo published limit; 403/429 under load — throttle to ~2 req/s, max 3 concurrentNo auth

Resolve the tenant and discover the portal shape

Every PageUp career site lives at careers.pageuppeople.com under a numeric tenant id (the leading path segment). Each tenant serves its listings under exactly one /{cw|ci}/{locale} path, so probe the known combinations until one returns a payload with a non-zero count. Sending the XHR headers makes PageUp return JSON instead of the full HTML page.

Step 1: Resolve the tenant and discover the portal shape
import requests

HOST = "careers.pageuppeople.com"
PAGE_ITEMS = 100

# PageUp fronts every payload as server-rendered HTML, but returns the same
# content as JSON when the request carries these XHR headers. The JSON wrapper
# also gives a deterministic total 'count' that drives pagination.
XHR_HEADERS = {
    "X-Requested-With": "XMLHttpRequest",
    "Accept": "application/json, text/javascript, */*; q=0.01",
}

# Each tenant picks exactly ONE (cwci, locale) path shape. Probe page 1 of
# each in turn until one responds with count > 0.
PORTAL_PROBES = [
    ("cw", "en-us"), ("ci", "en"), ("cw", "en-au"), ("ci", "en-au"),
    ("cw", "en-gb"), ("ci", "en-us"), ("cw", "en"), ("ci", "en-gb"),
]

def listing_url(tenant, cwci, locale, page):
    return (f"https://{HOST}/{tenant}/{cwci}/{locale}/listing/"
            f"?page={page}&page-items={PAGE_ITEMS}")

def discover_portal(session, tenant):
    for cwci, locale in PORTAL_PROBES:
        resp = session.get(listing_url(tenant, cwci, locale, 1),
                           headers=XHR_HEADERS, timeout=30)
        if resp.status_code in (403, 404):
            continue
        resp.raise_for_status()
        data = resp.json()
        if data.get("count", 0) > 0:
            return cwci, locale, data
    raise RuntimeError(f"No PageUp portal responded for tenant {tenant}")

session = requests.Session()
tenant = "889"  # numeric tenant id — the leading path segment of the careers URL
cwci, locale, first_page = discover_portal(session, tenant)
print(f"Portal: /{tenant}/{cwci}/{locale}  total jobs: {first_page['count']}")

Parse the listing row fragment

The 'results' field is a JSON string containing a sequence of bare <tr> rows. Wrap it in <table><tbody> before parsing, otherwise the HTML parser discards the <td> siblings that hold location, department and close-date. Each row links to a detail page via an a.job-link anchor; the 6-digit job id lives in the /job/{id} path segment.

Step 2: Parse the listing row fragment
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse

def job_id_from_url(url):
    # Detail path shape: /{tenant}/{cwci}/{locale}/job/{jobId}/{slug}
    path = urlparse(url).path
    if "/job/" not in path:
        return None
    tail = path.split("/job/", 1)[1].strip("/").split("/")
    return tail[0] if tail and tail[0].isdigit() else None

def clean(el):
    return " ".join(el.get_text().split()) if el else None

def parse_rows(results_html, seen):
    # 'results' is a sequence of BARE <tr> rows. Wrap them in <table><tbody>
    # or the parser drops the <td> siblings (location/department/close-date).
    soup = BeautifulSoup(f"<table><tbody>{results_html}</tbody></table>", "html.parser")
    rows = []
    for anchor in soup.select("a.job-link"):
        href = anchor.get("href")
        if not href:
            continue
        url = urljoin(f"https://{HOST}/", href)
        job_id = job_id_from_url(url)
        if not job_id or job_id in seen:
            continue
        seen.add(job_id)
        tr = anchor.find_parent("tr")
        rows.append({
            "job_id": job_id,
            "title": clean(anchor),
            "listing_url": url,
            "department": clean(tr.select_one(".job-department, .department")) if tr else None,
            "location": clean(tr.select_one(".location")) if tr else None,
        })
    return rows

Page through the listing endpoint by total count

The first payload's 'count' is the authoritative total. Keep requesting pages (page-items=100) until you have collected that many postings. Bail out if a page returns no results or yields zero new rows — a defensive stop that prevents an infinite loop on a misbehaving tenant.

Step 3: Page through the listing endpoint by total count
def fetch_all_listings(session, tenant):
    cwci, locale, data = discover_portal(session, tenant)
    total = data.get("count", 0)
    seen = set()
    listings = parse_rows(data.get("results", ""), seen)

    page = 1
    while len(listings) < total:
        page += 1
        resp = session.get(listing_url(tenant, cwci, locale, page),
                           headers=XHR_HEADERS, timeout=30)
        resp.raise_for_status()
        results = resp.json().get("results")
        if not results:
            break
        before = len(listings)
        listings.extend(parse_rows(results, seen))
        if len(listings) == before:
            break  # no new rows — stop to avoid an infinite loop
    return listings

listings = fetch_all_listings(session, "889")
print(f"Collected {len(listings)} postings")

Fetch and parse each job detail fragment

Request each detail URL with the same XHR headers; PageUp returns a JSON envelope whose 'results' holds the rendered detail HTML. Pull the title from <h2>, the description from #job-details, and the structured fields from the labelled spans (.work-type, .categories, .job-externalJobNo, .location). A 404 or 410 means the posting was removed — treat it as a delisting signal rather than an error.

Step 4: Fetch and parse each job detail fragment
import time

def fetch_detail(session, listing):
    resp = session.get(listing["listing_url"], headers=XHR_HEADERS, timeout=30)
    if resp.status_code in (404, 410):
        return None  # posting removed — treat as a delisting signal
    resp.raise_for_status()
    fragment = resp.json().get("results")
    if not fragment:
        return None

    soup = BeautifulSoup(fragment, "html.parser")
    body = soup.select_one("#job-details")
    apply_anchor = soup.select_one("a.apply-link")
    apply_url = listing["listing_url"]
    if apply_anchor and apply_anchor.get("href"):
        apply_url = urljoin(listing["listing_url"], apply_anchor["href"])

    return {
        **listing,
        "title": clean(soup.select_one("h2")) or listing["title"],
        "external_job_no": clean(soup.select_one(".job-externalJobNo")),
        "employment_type": clean(soup.select_one(".work-type")),
        "category": clean(soup.select_one(".categories")),
        "locations": [clean(el) for el in soup.select(".location")],
        "apply_url": apply_url,
        "description_html": body.decode_contents() if body else None,
    }

for listing in listings[:3]:
    job = fetch_detail(session, listing)
    if job:
        print(job["title"], "-", job["employment_type"])
    time.sleep(0.5)  # respect ~500ms between requests
Common issues
highThe endpoint returns a full HTML page instead of JSON

PageUp only switches to the JSON envelope when the request carries X-Requested-With: XMLHttpRequest (plus a JSON Accept header). Send both on every listing and detail request; without them you get the surrounding page chrome and no reliable total count.

highEvery tenant uses a different /{cw|ci}/{locale} path

The cwci segment (cw or ci) and locale (en, en-us, en-au, en-gb) vary per portal, and only one combination is live for a given tenant. Probe page 1 of each known combination until one returns count > 0, then cache the winning shape for that tenant.

mediumListing rows lose their location and department columns

The 'results' string is a sequence of bare <tr> elements. HTML parsers discard <tr>/<td> that are not inside a <table>, silently stripping the per-row cells. Wrap the fragment in <table><tbody>...</tbody></table> before parsing so the row structure survives.

mediumPagination never terminates

Drive the loop off the JSON 'count' field and stop once the collected total is reached. Add a defensive guard that breaks when a page returns empty results or contributes zero new job ids, so a misbehaving tenant can't spin the crawler forever.

mediumRequests start returning 403 or 429 under load

Aggressive crawling triggers blocking (403) or rate limiting (429). Throttle to roughly two requests per second, cap concurrent detail fetches at three, and back off on 429 before retrying rather than hammering the tenant.

Best practices
  1. 1Send the XHR headers on every request so PageUp returns JSON, not the full HTML page
  2. 2Probe the (cw|ci)/locale combinations once per tenant, then cache the shape that returns count > 0
  3. 3Request page-items=100 and page until the collected total reaches the JSON count field
  4. 4Wrap the bare <tr> results fragment in <table><tbody> before parsing to keep the location and department cells
  5. 5Throttle to ~2 requests/second and cap concurrent detail fetches at three
  6. 6Treat a 404 or 410 on a detail URL as a delisting signal, not a hard failure
Or skip the complexity

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

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=pageup" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access PageUp
job data today.

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

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