- highA posting that disappears from the feed is not necessarily closed. The Atom feed lists only active postings, and it can be temporarily incomplete, so treating a single missing fetch as a removal produces false closures.
- 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.
- mediumStructured location is frequently missing. PeopleAdmin postings often carry no location in the feed, because it lives in tenant-specific custom fields, so geocoding is not universal across university and government tenants.
- 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.
- lowDescription content is optional on some feeds. Certain tenants publish Atom entries with an empty <content> element, leaving you with identity and title but no body.
- 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.
- lowSome feed entries have no parseable posting ID or repeat an ID already seen, which can silently skew your posting count.
- 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.
- mediumAggressive crawling triggers HTTP 403 (blocked) or 429 (rate limited) on a tenant.
- 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.
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.
What's in every response.
Data fields, real-world applications, and the companies already running on PeopleAdmin.
Data fields
- Stable Posting IDs
- Full Job Descriptions
- Department Names
- Posting Numbers
- Publication Dates
- Apply URLs
Use cases
- 01Higher-Ed Job Aggregation
- 02Public-Sector Hiring Feeds
- 03Faculty & Staff Job Boards
- 04University Vacancy Monitoring
Trusted by
- Indiana University
- Hofstra University
- Morehouse College
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)")- 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"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 PeopleAdmin
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.