PowerSchool TalentEd Hire Jobs API.

Pull a school district's entire TalentEd Hire board in one request from the vendor's own JobList XML feed, which already carries full descriptions, structured locations, and closing dates.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on PowerSchool TalentEd Hire.

Data fields

  • Complete Board In One Request
  • Full Job Descriptions
  • Structured Location Records
  • Posted & Close Dates
  • Job Codes & Categories
  • Full-Time / Part-Time Flags

Use cases

  1. 01K-12 Education Job Boards
  2. 02School District Hiring Trackers
  3. 03Teacher Recruitment Research
  4. 04Regional Education Feeds
DIY GUIDE

How to scrape PowerSchool TalentEd Hire.

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

API type
REST
Difficulty
beginner
Rate limit
No published limit; one feed request per district, ~250ms apart
Authentication
No auth

Read the district from the host label

Every board is {district}.tedk12.com or {district}.tedk12.ca. The leftmost host label is the district and the registry TLD is a separate scope, because the same district slug can legitimately exist on both the .com and .ca fleets.

Step 1: Read the district from the host label
from urllib.parse import urlparse

VALID_TLDS = {"com", "ca"}

def parse_tedk12(url: str) -> dict:
    parsed = urlparse(url)
    labels = parsed.netloc.lower().split(".")
    # Exactly {district}.tedk12.{com|ca} — nothing deeper, nothing shallower.
    if len(labels) != 3 or labels[1] != "tedk12" or labels[2] not in VALID_TLDS:
        raise ValueError("not a TalentEd Hire host")
    if labels[0] in {"www", ""}:
        raise ValueError("the marketing host carries no district")

    # Boards emit a doubled slash in real links: //hire/ViewJob.aspx?JobID=3212
    path = "/" + parsed.path.lstrip("/").lower()
    if not path.startswith("/hire"):
        raise ValueError("only /hire routes carry board identity")

    return {"district": labels[0], "tld": labels[2]}

print(parse_tedk12("https://alleganymd.tedk12.com//hire/ViewJob.aspx?JobID=3212"))
# {'district': 'alleganymd', 'tld': 'com'}

Fetch the JobList XML feed

The board at /hire/index.aspx is an ASP.NET WebForms table, but the same application publishes /hire/JobList.ashx — a first-party XML document containing the whole board. It answers a bare request with no special client and no User-Agent header.

Step 2: Fetch the JobList XML feed
import requests

def feed_url(district: str, tld: str) -> str:
    return f"https://{district}.tedk12.{tld}/hire/JobList.ashx"

def fetch_feed(district: str, tld: str) -> str:
    resp = requests.get(feed_url(district, tld), timeout=30, allow_redirects=True)

    # A decommissioned district still answers on its host but bounces /hire
    # requests to /hire/404.html, which is HTML. Catch it before parsing XML.
    if resp.url.rstrip("/").endswith("/404.html"):
        raise LookupError(f"district '{district}' no longer exists")

    resp.raise_for_status()
    return resp.text

xml = fetch_feed("garfieldco", "com")
print(len(xml), "bytes")

Strip the stale XML declaration before parsing

The handler advertises encoding="utf-16" in its prolog while the transport serves UTF-8. If you let the declaration drive decoding, parsing fails or produces mojibake. Drop the declaration and parse the already-decoded string.

Step 3: Strip the stale XML declaration before parsing
import re
import xml.etree.ElementTree as ET

DECLARATION = re.compile(r"^\s*<\?xml[^>]*\?>", re.IGNORECASE)

def parse_feed(xml: str) -> list[ET.Element]:
    if not xml.strip():
        raise ValueError("TalentEd Hire feed returned an empty body")

    root = ET.fromstring(DECLARATION.sub("", xml, count=1))
    if root.tag != "jobs":
        raise ValueError(f"unexpected feed root element <{root.tag}>")

    # An empty board returns a well-formed <jobs /> document. That is an
    # authoritative empty snapshot, not a failure.
    return list(root.findall("job"))

jobs = parse_feed(xml)
print(f"{len(jobs)} postings")

Map each feed row into a complete job record

Every field a detail page would supply is already inline: title, job code, category, full description HTML, employment type, posted and close dates, a structured location, a contact and the owning company. No per-job fetch is needed.

Step 4: Map each feed row into a complete job record
def text(node, path: str) -> str | None:
    found = node.find(path)
    return found.text.strip() if found is not None and found.text else None

def to_job(node: ET.Element, district: str, tld: str) -> dict:
    location = node.find("location")
    return {
        "title": text(node, "title"),
        "job_code": text(node, "job-code"),
        "category": text(node, "job-category"),
        "description_html": text(node, "description/summary"),
        "full_time": text(node, "full-time"),
        "part_time": text(node, "part-time"),
        "posted_at": text(node, "posted-date"),
        "closes_at": text(node, "close-date"),
        "company": text(node, "company"),
        "contact": text(node, "contact"),
        "location": {
            "name": text(location, "name") if location is not None else None,
            "city": text(location, "city") if location is not None else None,
            "state": text(location, "state") if location is not None else None,
            "postal_code": text(location, "zip") if location is not None else None,
            "country": text(location, "country") if location is not None else None,
        },
        "listing_url": text(node, "detail-url"),
        "district": district,
        "tld": tld,
    }

for node in jobs[:3]:
    job = to_job(node, "garfieldco", "com")
    print(job["title"], "|", job["category"], "|", job["closes_at"])

Check each row against the district you asked for

Feed rows carry their own job-board-url and detail-url. A row naming a different district must be dropped and the snapshot reported incomplete, rather than filed under the district you requested. Feed counts match the rendered board exactly, so a rejection is a real anomaly.

Step 5: Check each row against the district you asked for
def validate(node: ET.Element, district: str, tld: str) -> bool:
    for field in ("job-board-url", "detail-url"):
        url = text(node, field)
        if not url:
            continue
        try:
            claimed = parse_tedk12(url)
        except ValueError:
            return False
        if claimed["district"] != district or claimed["tld"] != tld:
            return False
    return True

accepted, rejected = [], 0
for node in jobs:
    if validate(node, "garfieldco", "com"):
        accepted.append(to_job(node, "garfieldco", "com"))
    else:
        rejected += 1

if rejected:
    print(f"snapshot incomplete: {rejected} rows named another district")
print(f"{len(accepted)} of {len(jobs)} rows accepted")
Common issues
highWhy does XML parsing fail with an encoding error?
The feed's prolog declares encoding="utf-16" while the transport actually serves UTF-8. Strip the XML declaration from the already-decoded response body before parsing, rather than letting the stale declaration drive the decoder.
highWhy does a district's feed return HTML instead of XML?
A decommissioned district keeps answering on its host but redirects every /hire request to /hire/404.html. Check the final URL after redirects and report that as a dead board, so an HTML error page never reaches the XML parser as a parse failure.
mediumShould an applicant-account page mint a district?
No. Only /hire, /hire/index.aspx, /hire/JobList.ashx and /hire/ViewJob.aspx carry board identity. A stale ViewJob link redirects to pages such as /hire/ProfileErrorPage.aspx, and a naive parser would create districts from those error pages.
lowWhy do TalentEd Hire URLs have a doubled slash?
The vendor's own board emits links like https://{district}.tedk12.com//hire/ViewJob.aspx?JobID=3212. Accept the doubled form rather than repairing it at ingest — those are the real URLs the district publishes and links back to.
lowIs an empty feed a scrape failure?
No. An empty board returns a well-formed <jobs /> document, which is an authoritative empty snapshot. Both audited Canadian tenants and two US districts were in that state at audit time with fully healthy boards.
Best practices
  1. 1Use /hire/JobList.ashx instead of parsing the WebForms board at /hire/index.aspx
  2. 2Take the district from the leftmost host label and keep the TLD as a separate scope
  3. 3Strip the XML declaration before parsing; the utf-16 claim is wrong
  4. 4Check the post-redirect URL for /hire/404.html before treating a response as a feed
  5. 5Validate each row's own job-board-url and detail-url against the district you requested
  6. 6Skip per-job requests entirely — the feed already carries the full description
Or skip the complexity

One endpoint. All PowerSchool TalentEd Hire jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=powerschool talented hire" \
  -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 TalentEd Hire
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