PeopleAdmin Jobs API.
Pull every active posting from higher-ed and public-sector employers through PeopleAdmin's authoritative Atom feed, one structured request per tenant returning titles, departments, and full descriptions.
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 PeopleAdmin.
- Stable Posting IDs
- Full Job Descriptions
- Department Names
- Posting Numbers
- Publication Dates
- Apply URLs
- 01Higher-Ed Job Aggregation
- 02Public-Sector Hiring Feeds
- 03Faculty & Staff Job Boards
- 04University Vacancy Monitoring
How to scrape PeopleAdmin.
Step-by-step guide to extracting jobs from PeopleAdmin-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
# Tenants live at https://{tenant}.peopleadmin.com
# Posting detail URLs look like https://{tenant}.peopleadmin.com/postings/{id}
def tenant_base_url(url: str) -> tuple[str, str]:
host = urlparse(url).hostname or ""
host = host.lower()
if not host.endswith(".peopleadmin.com"):
raise ValueError("Not a PeopleAdmin host")
# Everything left of ".peopleadmin.com" is the tenant.
tenant = host[: -len(".peopleadmin.com")]
if tenant in ("", "www"):
raise ValueError("Could not extract PeopleAdmin tenant")
return tenant, f"https://{tenant}.peopleadmin.com"
tenant, base = tenant_base_url("https://jeffco.peopleadmin.com/postings/5789")
print(tenant, base) # jeffco https://jeffco.peopleadmin.comimport requests
def fetch_feed(base: str, tenant: str) -> str:
url = f"{base}/postings/all_jobs.atom"
resp = requests.get(
url,
timeout=30,
headers={"User-Agent": "jobo-bot/1.0", "Accept": "application/atom+xml"},
)
if resp.status_code == 404:
raise RuntimeError(f"Tenant '{tenant}' not found on PeopleAdmin (HTTP 404)")
if resp.status_code == 401:
raise RuntimeError(f"Authentication required for '{tenant}' (HTTP 401)")
if resp.status_code in (403, 429):
raise RuntimeError(f"Blocked or rate limited for '{tenant}' (HTTP {resp.status_code})")
resp.raise_for_status()
if not resp.text.strip():
raise RuntimeError("PeopleAdmin returned an empty Atom feed")
return resp.textimport re
import xml.etree.ElementTree as ET
ATOM = "{http://www.w3.org/2005/Atom}"
POSTING_ID = re.compile(r"/postings/(\d+)(?:[/?#]|$)", re.IGNORECASE)
def _local(entry, name):
# Custom elements like posting_number are not Atom-namespaced.
for child in entry:
if child.tag.rsplit("}", 1)[-1].lower() == name:
return (child.text or "").strip() or None
return None
def parse_feed(xml: str, base: str) -> list[dict]:
root = ET.fromstring(xml)
postings, seen = [], set()
for entry in root.findall(f"{ATOM}entry"):
alternate = next(
(link.get("href") for link in entry.findall(f"{ATOM}link")
if (link.get("rel") or "").lower() == "alternate"),
None,
)
identity = alternate or (entry.findtext(f"{ATOM}id") or "")
match = POSTING_ID.search(identity)
if not match:
continue # reject entries without a posting ID
job_id = match.group(1)
if job_id in seen:
continue # de-duplicate repeated IDs
seen.add(job_id)
listing_url = f"{base}/postings/{job_id}"
postings.append({
"external_id": job_id,
"listing_url": listing_url,
"apply_url": f"{listing_url}/pre_apply",
"title": (entry.findtext(f"{ATOM}title") or "").strip(),
"description": entry.findtext(f"{ATOM}content"), # may be None on some feeds
"published": entry.findtext(f"{ATOM}published"),
"department": entry.findtext(f"{ATOM}author/{ATOM}name"),
"posting_number": _local(entry, "posting_number"),
})
return postings
feed = fetch_feed(base, tenant)
jobs = parse_feed(feed, base)
print(f"Found {len(jobs)} active postings")def resolve_posting(base: str, tenant: str, external_id: str) -> dict | None:
xml = fetch_feed(base, tenant)
for posting in parse_feed(xml, base):
if posting["external_id"] == external_id:
return posting
# Absent from the active-postings feed -> removal is NOT confirmed.
# Do not mark closed on a single fetch; reconcile against a full snapshot.
return None
detail = resolve_posting(base, tenant, "5789")
print(detail["title"] if detail else "absent (inconclusive)")Only expire a posting after a complete tenant snapshot. Never flip a job to closed on a single feed fetch where it happens to be absent.
Treat location as optional. Fall back to the HTML posting page's field table (labels like Location, Work Location, Position Location) when you need a place, and don't assume every posting geocodes.
Keep the partial record (ID, title, URLs) and fetch the HTML posting page to reconstruct the description from its field table when the feed body is blank.
Skip any entry whose alternate link or <id> does not yield a numeric /postings/{id} value, de-duplicate on posting ID, and treat a scrape with rejected entries as an incomplete snapshot.
Keep concurrency low (three or fewer parallel requests), space requests roughly 400ms apart, and back off when you see a 403 or 429 before retrying.
- 1Prefer the /postings/all_jobs.atom feed over scraping HTML search pages: it returns every active posting in one structured, no-auth response.
- 2Use the numeric posting ID as your stable primary key; it stays constant across re-crawls and drives the canonical /postings/{id} URL.
- 3Keep concurrency at three or fewer and space requests around 400ms to avoid 403/429 blocks.
- 4Expire a posting only after a complete tenant snapshot, never on a single missing-from-feed fetch.
- 5Extract everything left of `.peopleadmin.com` as the tenant so multi-label subdomains resolve correctly.
- 6Strip HTML from the Atom <content> and fall back to the HTML posting page when the feed body is empty.
One endpoint. All PeopleAdmin jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=peopleadmin" \
-H "X-Api-Key: YOUR_KEY" Access PeopleAdmin
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.