PowerSchool Applicant Tracking Enterprise Jobs API.

Extract K-12 district vacancies from PowerSchool Applicant Tracking Enterprise, the former SearchSoft ATS, where districts share numbered pods and are separated only by a company id.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on PowerSchool Applicant Tracking Enterprise.

Data fields

  • Full Vacancy Descriptions
  • Job Numbers
  • School and Worksite Columns
  • City, State and Postal Code
  • Open and Close Dates
  • Vendor-Driven Paging

Use cases

  1. 01K-12 Education Job Aggregation
  2. 02School District Careers Feeds
  3. 03Statewide Teaching Job Boards
  4. 04ATS Data Pipelines

Trusted by

  • Charlotte-Mecklenburg Schools
  • Pinellas County Schools
  • Murray Community School District
DIY GUIDE

How to scrape PowerSchool Applicant Tracking Enterprise.

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

API type
HTML
Difficulty
intermediate
Rate limit
No published limit; ~300ms between requests and at most 2 concurrent detail fetches
Authentication
No auth

Pair the pod with the company id

Districts do not get their own hostname here: they share numbered pods at ats1 through ats5, and are separated only by a COMPANY_ID query parameter. The same company id resolves on several pods, so the pod is part of the board's address. Company ids are case-insensitive on the server, so lowercase them for a stable key.

Step 1: Pair the pod with the company id
import re
from urllib.parse import urlparse, parse_qs

SUFFIX = ".atenterprise.powerschool.com"
POD = re.compile(r"^ats[1-9][0-9]?$", re.IGNORECASE)

def parse_url(url: str) -> dict | None:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    if not host.endswith(SUFFIX):
        return None
    pod = host[: -len(SUFFIX)]
    if not POD.match(pod):
        return None

    parts = parsed.path.strip("/").split("/")
    if len(parts) != 2 or parts[0] != "ats":
        return None
    query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
    company = query.get("COMPANY_ID")
    if not company:
        return None

    # A syndicated apply URL can name a statewide aggregator in COMPANY_ID while
    # the employing district sits in REPRESENTATIVE_COMPANY_ID. The latter wins.
    company = query.get("REPRESENTATIVE_COMPANY_ID") or company
    return {
        "pod": pod.lower(),
        "company_id": company.lower(),
        "job_id": query.get("JOB_ID") if parts[1] == "job_board_form" else None,
    }

def board_url(pod: str, company_id: str, start_index: int = 0) -> str:
    base = f"https://{pod}{SUFFIX}/ats/job_board?COMPANY_ID={company_id}"
    return base if start_index <= 0 else f"{base}&start_index={start_index}"

print(parse_url(
    "https://ats5.atenterprise.powerschool.com/ats/job_board_form"
    "?op=view&JOB_ID=8600046793&COMPANY_ID=JA002638&REPRESENTATIVE_COMPANY_ID=JA003062"
))

Fetch the board and detect an unknown company id

The board is fully server-rendered — every row is in the initial HTML and no XHR returns data. An unknown COMPANY_ID answers HTTP 200 with a roughly 130-byte script that redirects to /ats/error.jsp, so check for that marker and report a dead board rather than an empty one.

Step 2: Fetch the board and detect an unknown company id
import requests
from bs4 import BeautifulSoup

def fetch_board(session: requests.Session, pod: str, company_id: str, start_index: int = 0):
    response = session.get(
        board_url(pod, company_id, start_index),
        headers={"Accept": "text/html,application/xhtml+xml"},
        timeout=30,
    )
    response.raise_for_status()
    # Unknown company: 200 with a tiny script that bounces to the error page.
    if "/ats/error.jsp" in response.text:
        raise RuntimeError(f"PowerSchool has no board for {company_id} on {pod}")
    return BeautifulSoup(response.text, "html.parser")

session = requests.Session()
soup = fetch_board(session, "ats3", "oa002067")

Build a header map — column layouts differ per tenant

Each district configures its own results table: the location column is System/School on one board, School or Worksite on another and District/Location on a third, and some tenants add Posting Date and Closing Date while others do not. Map header labels to column indices and read every cell by name.

Step 3: Build a header map — column layouts differ per tenant
def normalize(value: str) -> str:
    return " ".join((value or "").split())

def header_columns(soup) -> dict:
    columns: dict[str, list[int]] = {}
    for index, cell in enumerate(soup.select("div.rs table thead th")):
        label = normalize(cell.get_text())
        if label:
            columns.setdefault(label, []).append(index)
    return columns

LOCATION_HEADERS = ("System/School", "School or Worksite", "District/Location", "Location")

def cell(cells, columns: dict, header: str) -> str | None:
    for index in columns.get(header, []):
        if index < len(cells):
            text = normalize(cells[index].get_text())
            if text:
                return text
    return None

def parse_rows(soup, pod: str, company_id: str) -> list[dict]:
    columns = header_columns(soup)
    listings = []
    for row in soup.select("div.rs table tbody tr"):
        anchor = row.select_one("a[href*='job_board_form']")
        cells = row.find_all("td")
        if not anchor or not cells:
            continue
        job_id = (parse_qs(urlparse(anchor["href"]).query).get("JOB_ID") or [""])[0]
        title = cell(cells, columns, "Job Title") or normalize(anchor.get_text())
        if not job_id or not title:
            continue
        listings.append({
            "id": job_id,
            "title": title,
            "location": next(
                (v for v in (cell(cells, columns, h) for h in LOCATION_HEADERS) if v), None
            ),
            "posted_at": cell(cells, columns, "Posting Date"),
            "closes_at": cell(cells, columns, "Closing Date"),
            "listing_url": (
                f"https://{pod}{SUFFIX}/ats/job_board_form"
                f"?op=view&JOB_ID={job_id}&COMPANY_ID={company_id}"
            ),
        })
    return listings

listings = parse_rows(soup, "ats3", "oa002067")
print(f"{len(listings)} vacancies on this page")

Follow the board's own start_index links

Paging is done with the vendor's own start_index range links, 100 rows per page. Take the next offset from the smallest start_index the current page advertises above the one you requested, rather than synthesising offsets — that way the crawl stops exactly where the board stops.

Step 4: Follow the board's own start_index links
def next_start_index(soup, current: int) -> int | None:
    offsets = set()
    for anchor in soup.select("a[href*='start_index=']"):
        values = parse_qs(urlparse(anchor["href"]).query).get("start_index") or []
        if len(values) == 1 and values[0].isdigit():
            offsets.add(int(values[0]))
    forward = sorted(o for o in offsets if o > current)
    return forward[0] if forward else None

def crawl(session: requests.Session, pod: str, company_id: str) -> list[dict]:
    listings, start, seen = [], 0, set()
    while True:
        soup = fetch_board(session, pod, company_id, start)
        for row in parse_rows(soup, pod, company_id):
            if row["id"] not in seen:
                seen.add(row["id"])
                listings.append(row)
        following = next_start_index(soup, start)
        if following is None:
            return listings
        start = following

listings = crawl(session, "ats3", "oa002067")
print(f"{len(listings)} vacancies in total")

Parse the detail page and check the job number

Detail pages carry no JSON-LD, but the markup is class-named and stable: .job-header .job-name for the title, .job-address for the city, state and postal code, .job-details for the job number and dates, and div.message.job-description .richtextarea for the body. An unknown JOB_ID renders the shell with an empty title and no .job-name.

Step 5: Parse the detail page and check the job number
def text_of(soup, selector: str) -> str | None:
    node = soup.select_one(selector)
    return normalize(node.get_text()) if node else None

def fetch_detail(session: requests.Session, listing: dict) -> dict | None:
    response = session.get(listing["listing_url"], timeout=30)
    if response.status_code in (404, 410):
        return None  # canonical removal
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")

    title = text_of(soup, ".job-header .job-name")
    if not title:
        # Unknown JOB_ID: the shell renders with an empty title and no job header.
        return None

    number = text_of(soup, ".job-details .job-number .value")
    if number and number != listing["id"]:
        raise RuntimeError("PowerSchool detail job number disagreed with the requested JOB_ID")

    body = soup.select_one("div.message.job-description .richtextarea") \
           or soup.select_one("div.message.job-description")
    return {
        **listing,
        "title": title,
        "employer": text_of(soup, ".job-header .job-location"),
        "category": text_of(soup, ".job-header .job-description"),
        "city": text_of(soup, ".job-address .city"),
        "state": text_of(soup, ".job-address .state"),
        "postal_code": text_of(soup, ".job-address .zip"),
        "posted_at": text_of(soup, ".job-details .job-open-date .value") or listing["posted_at"],
        "closes_at": text_of(soup, ".job-details .job-close-date .value") or listing["closes_at"],
        "description_html": body.decode_contents().strip() if body else None,
    }

for listing in listings[:3]:
    job = fetch_detail(session, listing)
    print(job["title"] if job else f"{listing['id']} is no longer posted")
Common issues
criticalA district is filed under a statewide aggregator board
Syndicated apply URLs can name an aggregator in COMPANY_ID while the employing district sits in REPRESENTATIVE_COMPANY_ID. When both are present the representative id wins — otherwise a single district's postings end up attributed to a board carrying every district in the state.
highThe company id alone is not unique
The same company id resolves on several pods, so identity must be the pod plus the company id. Take the pod from the hostname, lowercase the company id since the server is case-insensitive, and keep the pair together as the board key.
highAn unknown company id looks like an empty board
The vendor answers an unknown COMPANY_ID with HTTP 200 and a small script redirecting to /ats/error.jsp. Check the response for that marker and report a dead board, rather than recording zero jobs for a district that never existed on that pod.
highColumn positions differ from district to district
The location column is labelled System/School, School or Worksite or District/Location depending on the tenant, and optional date columns come and go. Build a header-name to index map from the table head and read every cell by name instead of by position.
mediumThere is no feed to fall back on
job_board_rss, job_board_xml, job_board_feed, the /ats/api routes and the format and output query switches all return the vendor's short error stub, and the detail page carries no JSON-LD. The server-rendered board and detail pages are the only sources, so parse the class-named markup rather than hunting for an API.
Best practices
  1. 1Treat the pod plus the lowercased company id as one board identity
  2. 2Prefer REPRESENTATIVE_COMPANY_ID over COMPANY_ID whenever both are present
  3. 3Check every board response for the /ats/error.jsp marker before mapping rows
  4. 4Map results-table columns by header label, not by index
  5. 5Page with the board's own start_index links so the crawl ends where the board ends
  6. 6Treat a detail page with no .job-name as a removal candidate and a mismatched .job-number as a parse error
Or skip the complexity

One endpoint. All PowerSchool Applicant Tracking Enterprise jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=powerschool applicant tracking enterprise" \
  -H "X-Api-Key: YOUR_KEY"
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.

Ready to integrate

Access PowerSchool Applicant Tracking Enterprise
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.

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