All platforms

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.

Get API access
PeopleAdmin
Live
<3haverage discovery time
1hrefresh interval
Companies using PeopleAdmin
Indiana UniversityHofstra UniversityMorehouse College
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 PeopleAdmin.

Data fields
  • Stable Posting IDs
  • Full Job Descriptions
  • Department Names
  • Posting Numbers
  • Publication Dates
  • Apply URLs
Use cases
  1. 01Higher-Ed Job Aggregation
  2. 02Public-Sector Hiring Feeds
  3. 03Faculty & Staff Job Boards
  4. 04University Vacancy Monitoring
Trusted by
Indiana UniversityHofstra UniversityMorehouse College
DIY GUIDE

How to scrape PeopleAdmin.

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

HybridbeginnerNo auth

Resolve the tenant base URL

Every PeopleAdmin customer lives on its own subdomain of peopleadmin.com. Take everything to the left of `.peopleadmin.com` as the tenant key so multi-label subdomains (e.g. jobs.foo.peopleadmin.com) still resolve to a single canonical base URL.

Step 1: Resolve the tenant base URL
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.com

Fetch the authoritative Atom feed

The public, no-auth Atom feed at /postings/all_jobs.atom returns every active posting for the tenant in a single structured response. Prefer it over scraping the HTML search pages. Map the non-2xx responses PeopleAdmin returns so you can tell a missing tenant from a temporary block.

Step 2: Fetch the authoritative Atom feed
import 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.text

Parse Atom entries into postings

Each <entry> carries the posting identity in its rel="alternate" link (falling back to <id>). Pull the numeric posting ID out of the /postings/{id} path, then read title, content (the HTML description), published date, author name (department), and the custom posting_number element. Skip entries with no parseable ID and de-duplicate on posting ID.

Step 3: Parse Atom entries into postings
import 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")

Resolve a single posting and handle removals

Because the feed already carries every active posting, resolve a job's details by matching its posting ID inside the same feed rather than fetching each page. Treat absence from the feed as inconclusive: only expire a posting after a complete tenant snapshot, since the feed can be temporarily incomplete.

Step 4: Resolve a single posting and handle removals
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)")
Common issues
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.

Best practices
  1. 1Prefer the /postings/all_jobs.atom feed over scraping HTML search pages: it returns every active posting in one structured, no-auth response.
  2. 2Use the numeric posting ID as your stable primary key; it stays constant across re-crawls and drives the canonical /postings/{id} URL.
  3. 3Keep concurrency at three or fewer and space requests around 400ms to avoid 403/429 blocks.
  4. 4Expire a posting only after a complete tenant snapshot, never on a single missing-from-feed fetch.
  5. 5Extract everything left of `.peopleadmin.com` as the tenant so multi-label subdomains resolve correctly.
  6. 6Strip HTML from the Atom <content> and fall back to the HTML posting page when the feed body is empty.
Or skip the complexity

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

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

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.

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