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.
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.
- Full Job Descriptions
- Employment Type & Category
- Department & Team
- Location Details
- Open & Close Dates
- External Job Numbers
- 01Enterprise Job Aggregation
- 02Healthcare & Government Job Boards
- 03University Careers Feeds
- 04Talent Market Research
How to scrape PageUp.
Step-by-step guide to extracting jobs from PageUp-powered career pages—endpoints, authentication, and working code.
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']}")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 rowsdef 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")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 requestsPageUp 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.
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.
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.
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.
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.
- 1Send the XHR headers on every request so PageUp returns JSON, not the full HTML page
- 2Probe the (cw|ci)/locale combinations once per tenant, then cache the shape that returns count > 0
- 3Request page-items=100 and page until the collected total reaches the JSON count field
- 4Wrap the bare <tr> results fragment in <table><tbody> before parsing to keep the location and department cells
- 5Throttle to ~2 requests/second and cap concurrent detail fetches at three
- 6Treat a 404 or 410 on a detail URL as a delisting signal, not a hard failure
One endpoint. All PageUp jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=pageup" \
-H "X-Api-Key: YOUR_KEY" 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.