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.

Get API access

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

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

How to scrape SchoolSpring.

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

API type
REST
Difficulty
intermediate
Rate limit
No published limit; ~100ms between requests, max 3 concurrent detail calls
Authentication
No auth

Identify the district subdomain

SchoolSpring serves two route shapes. A district vanity board is https://{tenant}.schoolspring.com and carries the employer in the hostname; the national aggregator at https://www.schoolspring.com/jobdetail?jobId={n} carries only a job id and no employer at all.

Step 1: Identify the district subdomain
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'}

Page the district listings API

The board is a React shell backed by api.schoolspring.com. GetPagedJobsWithSearch takes the district's own domain name as its scope, returns 25 rows per page, and wraps everything in a success/message/value envelope. A short page means you have reached the end.

Step 2: Page the district listings API
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")

Fetch the full job detail record

Each listing row carries only jobId, employer, title, location, and displayDate. The detail endpoint adds the description HTML, pay range, job type, external job code, close date, and the jobBoards array that names every district board publishing the job.

Step 3: Fetch the full job detail record
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)

Attribute a national /jobdetail link to a district

The aggregator URL has no employer in it, so the only honest way to attribute the job is the detail payload's jobBoards array. Accept the row only when exactly one distinct district board appears; more than one means the job is cross-posted and cannot be assigned from the URL alone.

Step 4: Attribute a national /jobdetail link to a district
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))

Do not treat every schoolspring-tagged job as SchoolSpring

Aggregated job feeds file several other K-12 products under a schoolspring label. In one production corpus of 2,220 rows, only 2,067 were genuine SchoolSpring boards; the rest were nine other systems the districts had moved to. Route on the URL host, never on an upstream source tag.

Step 5: Do not treat every schoolspring-tagged job as SchoolSpring
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
Common issues
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.
Best practices
  1. 1Derive the district from the subdomain, never from an upstream source label
  2. 2Pass the district's own domainName on every listings and details call
  3. 3Check the success flag on every response before reading value
  4. 4Stop paging on the first page shorter than 25 rows, and dedupe on jobId
  5. 5Treat 'JobDetail not found' as a job removal rather than a parse error
  6. 6Reject a national /jobdetail row when its jobBoards array names more than one district
Or skip the complexity

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

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

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