- highWhy does a /jobdetail URL not tell me which district is hiring?
- The national aggregator route carries only a job id. Fetch the detail record and read the jobBoards array, accepting the row only when exactly one distinct {tenant}.schoolspring.com board appears. Guessing the employer from the job title or description mints wrong companies.
- highWhy are non-SchoolSpring jobs showing up in a SchoolSpring feed?
- SchoolSpring aggregates postings from other K-12 systems, and districts that migrated keep the old label upstream. Filter on the actual URL host and route tedk12.com, applitrack.com, and atenterprise.powerschool.com rows to their own extractors instead.
- mediumWhy does the API return HTTP 200 with success=false?
- SchoolSpring wraps everything in a success/message/value envelope, so transport status alone is not enough. Check success on every response; the message 'JobDetail not found' is a structured removal signal for that job, while other messages are genuine failures worth retrying.
- mediumHow do I know when pagination is finished?
- The listings endpoint returns 25 rows per page and publishes no total. Keep requesting pages while the returned jobsList is exactly page-size long, and stop on the first short or empty page. Also dedupe on jobId, since the same posting can repeat across pages during an update.
SchoolSpring Jobs API.
Pull K-12 vacancies from a district's SchoolSpring board through the unauthenticated JSON API that backs the React careers shell, with pay bands, close dates, and full descriptions.
What's in every response.
Data fields, real-world applications, and the companies already running on SchoolSpring.
Data fields
- Full Job Descriptions
- Pay Minimum & Maximum
- Job Type & Category
- Employer & Location Names
- Post & Close Dates
- External Job Codes
Use cases
- 01K-12 Education Job Boards
- 02School District Hiring Trackers
- 03Teacher Recruitment Research
- 04Regional Education Feeds
How to scrape SchoolSpring.
Step-by-step guide to extracting jobs from SchoolSpring-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse, parse_qs
RESERVED = {"www", "api", "employer"}
def parse_schoolspring(url: str) -> dict:
parsed = urlparse(url)
labels = parsed.netloc.lower().split(".")
job_id = (parse_qs(parsed.query).get("jobId")
or parse_qs(parsed.query).get("jobid") or [None])[0]
if len(labels) != 3 or labels[1:] != ["schoolspring", "com"]:
raise ValueError("not a SchoolSpring URL")
tenant = labels[0]
if tenant in RESERVED:
# National /jobdetail links have no district in the URL — see step 5.
return {"tenant": None, "job_id": job_id}
return {"tenant": tenant, "job_id": job_id}
print(parse_schoolspring("https://huusd.schoolspring.com?jobid=5791873"))
# {'tenant': 'huusd', 'job_id': '5791873'}import requests
API_BASE = "https://api.schoolspring.com/api"
PAGE_SIZE = 25
def listings_url(tenant: str, page: int) -> str:
domain = f"{tenant}.schoolspring.com"
return (
f"{API_BASE}/Jobs/GetPagedJobsWithSearch?domainName={domain}"
"&keyword=&location=&category=&gradelevel=&jobtype=&organization="
"&swLat=&swLon=&neLat=&neLon="
f"&page={page}&size={PAGE_SIZE}&sortDateAscending=false"
)
def fetch_all(tenant: str, session: requests.Session) -> list[dict]:
jobs, page = [], 1
while True:
resp = session.get(listings_url(tenant, page), timeout=30)
resp.raise_for_status()
payload = resp.json()
if not payload.get("success"):
raise RuntimeError(payload.get("message") or "listings API returned success=false")
batch = (payload.get("value") or {}).get("jobsList") or []
jobs.extend(batch)
if len(batch) < PAGE_SIZE:
return jobs
page += 1
session = requests.Session()
listings = fetch_all("bsdvt", session)
print(f"{len(listings)} open jobs")import time
def detail_url(job_id: str, domain: str) -> str:
return f"{API_BASE}/Jobs/{job_id}?domainName={domain}"
def get_detail(tenant: str, job_id: str, session: requests.Session) -> dict | None:
resp = session.get(detail_url(job_id, f"{tenant}.schoolspring.com"), timeout=30)
if resp.status_code in (404, 410):
return None # canonical removal
resp.raise_for_status()
payload = resp.json()
if not payload.get("success"):
message = payload.get("message") or ""
if "JobDetail not found" in message:
return None # structured removal signal, not a parse failure
raise RuntimeError(message)
info = (payload.get("value") or {}).get("jobInfo") or {}
if str(info.get("jobId")) != str(job_id):
raise RuntimeError("detail API returned a mismatched job")
return {
"id": info.get("jobId"),
"title": info.get("jobTitle"),
"employer": info.get("employerName"),
"employer_id": info.get("employerID"),
"description_html": info.get("jobDescription"),
"job_type": info.get("jobTypeName"),
"external_code": info.get("externalJobCode"),
"posted_at": info.get("postDate"),
"closes_at": info.get("closeDate"),
"pay_min": info.get("payMin"),
"pay_max": info.get("payMax"),
}
for row in listings[:3]:
print(get_detail("bsdvt", row["jobId"], session))
time.sleep(0.1)def resolve_board(job_id: str, session: requests.Session) -> str | None:
resp = session.get(detail_url(job_id, "www.schoolspring.com"), timeout=30)
resp.raise_for_status()
payload = resp.json()
if not payload.get("success"):
return None
value = payload.get("value") or {}
if str((value.get("jobInfo") or {}).get("jobId")) != str(job_id):
return None
tenants = set()
for board in value.get("jobBoards") or []:
url = board.get("jobBoardUrl")
if not url:
continue
try:
parsed = parse_schoolspring(url)
except ValueError:
continue
if parsed["tenant"]:
tenants.add(parsed["tenant"])
# Exactly one district, or the job is unattributable.
return tenants.pop() if len(tenants) == 1 else None
print(resolve_board("5824140", session))OTHER_K12_HOSTS = (
"tedk12.com", "tedk12.ca", # PowerSchool TalentEd Hire
"applitrack.com", # Frontline / AppliTrack
"atenterprise.powerschool.com", # PowerSchool ATS Enterprise
)
def is_real_schoolspring(url: str) -> bool:
host = urlparse(url).netloc.lower()
if any(host.endswith(other) for other in OTHER_K12_HOSTS):
return False
return host == "www.schoolspring.com" or host.endswith(".schoolspring.com")
print(is_real_schoolspring("https://bsdvt.schoolspring.com")) # True
print(is_real_schoolspring("https://alleganymd.tedk12.com/hire/index.aspx")) # False- 1Derive the district from the subdomain, never from an upstream source label
- 2Pass the district's own domainName on every listings and details call
- 3Check the success flag on every response before reading value
- 4Stop paging on the first page shorter than 25 rows, and dedupe on jobId
- 5Treat 'JobDetail not found' as a job removal rather than a parse error
- 6Reject a national /jobdetail row when its jobBoards array names more than one district
One endpoint. All SchoolSpring jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=schoolspring" \
-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 SchoolSpring
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.