- 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.
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.
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
- 01K-12 Education Job Aggregation
- 02School District Careers Feeds
- 03Statewide Teaching Job Boards
- 04ATS Data Pipelines
Trusted by
- Charlotte-Mecklenburg Schools
- Pinellas County Schools
- Murray Community School District
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.
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"
))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")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")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")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")- 1Treat the pod plus the lowercased company id as one board identity
- 2Prefer REPRESENTATIVE_COMPANY_ID over COMPANY_ID whenever both are present
- 3Check every board response for the /ats/error.jsp marker before mapping rows
- 4Map results-table columns by header label, not by index
- 5Page with the board's own start_index links so the crawl ends where the board ends
- 6Treat a detail page with no .job-name as a removal candidate and a mismatched .job-number as a parse error
One endpoint. All PowerSchool Applicant Tracking Enterprise jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=powerschool applicant tracking enterprise" \
-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 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.