Red Rover K12 Jobs API.

Pull every opening from a US school district's Red Rover board through one anonymous GraphQL query that returns native job IDs, categories, pay bands, and structured school locations.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on Red Rover K12.

Data fields

  • Full Job Descriptions
  • Structured Pay Ranges
  • School & Site Locations
  • Job Categories
  • Posting Status Codes
  • Remote-Eligible Flag

Use cases

  1. 01K-12 Education Job Boards
  2. 02School District Hiring Trackers
  3. 03Public-Sector Talent Research
  4. 04Substitute & Support Staff Feeds

Trusted by

  • Santa Fe Public Schools
  • San Francisco Unified School District
  • Clarksville-Montgomery County School System
  • Wauseon Exempted Village School District
DIY GUIDE

How to scrape Red Rover K12.

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

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

Read the numeric organization ID from the board URL

Every district board is https://jobs.redroverk12.com/org/{organizationId}, and job pages add /opening/{openingId}. Red Rover's own route parameter is orgIdOrPath, so a vanity slug such as /org/wauseonschools addresses the same board as /org/1183 — only the numeric form is a usable API key.

Step 1: Read the numeric organization ID from the board URL
from urllib.parse import urlparse

BOARD_HOST = "jobs.redroverk12.com"

def parse_board(url: str) -> dict:
    parsed = urlparse(url)
    if parsed.netloc.lower() != BOARD_HOST:
        raise ValueError("not a Red Rover board URL")

    parts = [p for p in parsed.path.strip("/").split("/") if p]
    if len(parts) < 2 or parts[0].lower() != "org":
        raise ValueError("expected /org/{organizationId}")

    org = parts[1]
    opening = parts[3] if len(parts) >= 4 and parts[2].lower() == "opening" else None
    return {"org": org, "opening": opening, "is_numeric": org.isdigit()}

print(parse_board("https://jobs.redroverk12.com/org/3877"))
# {'org': '3877', 'opening': None, 'is_numeric': True}

Resolve a vanity org path to its numeric organization

When the URL carries a slug instead of a number, fetch the board HTML and read the SimpleJobSeekerSiteBranding record embedded in the Next.js flight payload. It binds the requested orgPath to the numeric orgId. Never fall back to the slug — the district would be registered twice.

Step 2: Resolve a vanity org path to its numeric organization
import re
import requests

BRANDING = re.compile(
    r'SimpleJobSeekerSiteBranding\\?".{0,240}?'
    r'orgId\\?"\s*:\s*\\?"(?P<org_id>[1-9][0-9]{0,17})\\?"\s*,\s*'
    r'orgPath\\?"\s*:\s*\\?"(?P<org_path>[A-Za-z0-9][A-Za-z0-9._-]{0,63})\\?"'
)

def resolve_org_id(slug: str) -> str | None:
    resp = requests.get(
        f"https://jobs.redroverk12.com/org/{slug}",
        headers={"Accept": "text/html"},
        timeout=30,
    )
    resp.raise_for_status()

    match = BRANDING.search(resp.text)
    # The board must brand the exact path we asked for; anything else is not proof.
    if not match or match.group("org_path").lower() != slug.lower():
        return None
    return match.group("org_id")

print(resolve_org_id("wauseonschools"))  # "1183"

Query the anonymous job posting search

The public careers app posts anonymous GraphQL to api.redroverk12.com/graphql with an rrClient: JobSeeker header. jobPostingSearch is the authoritative listing snapshot and returns native job and organization IDs, status, category, pay data, dates, and structured addresses.

Step 3: Query the anonymous job posting search
import requests

GRAPHQL = "https://api.redroverk12.com/graphql"

LISTINGS_QUERY = """
query GetJobPostings($search: JobPostingSearchInput!) {
  jobSeekerSiteUnauthenticated {
    jobPostingSearch(search: $search) {
      results {
        id orgId name statusId organizationName
        category { id name }
        jobPostingTypeId payTypeId minPay maxPay allowsRemote
        location { id name address { address1 city state postalCode country } }
        activePublicOnDateUtc pausedOnDateUtc closedOnDateUtc
      }
      offset limit hasMoreData totalCount
    }
  }
}
"""

def search_jobs(org_id: str) -> dict:
    resp = requests.post(
        GRAPHQL,
        json={
            "operationName": "GetJobPostings",
            "query": LISTINGS_QUERY,
            "variables": {"search": {"orgId": org_id}},
        },
        headers={"rrClient": "JobSeeker", "Content-Type": "application/json"},
        timeout=30,
    )
    resp.raise_for_status()
    payload = resp.json()
    if payload.get("errors"):
        raise RuntimeError(payload["errors"])
    return payload["data"]["jobSeekerSiteUnauthenticated"]["jobPostingSearch"]

search = search_jobs("3877")
print(search["totalCount"], "postings, hasMoreData:", search["hasMoreData"])

Check the snapshot accounting before trusting the result

The public JobPostingSearchInput exposes filters but no offset or limit fields, and the response window is capped at 500 rows. Treat hasMoreData=true as an incomplete snapshot rather than assuming the first 500 postings are the whole board, and filter to the published statuses you actually want.

Step 4: Check the snapshot accounting before trusting the result
PUBLISHED = {"PUBLISHED", "PUBLISHED_INTERNAL"}

def collect(search: dict) -> list[dict]:
    results = search.get("results") or []

    if search.get("hasMoreData"):
        # The 500-row cap was hit. Do not treat this page as the full board.
        raise RuntimeError("Red Rover truncated the snapshot; refuse to expire jobs from it")

    total = search.get("totalCount")
    if total is not None and total != len(results):
        raise RuntimeError(f"count mismatch: totalCount={total}, rows={len(results)}")

    return [job for job in results if (job.get("statusId") or "").upper() in PUBLISHED]

open_jobs = collect(search)
print(f"{len(open_jobs)} currently published openings")
# Other statuses seen in production: CLOSED, PAUSED, ARCHIVED

Hydrate each opening with jobPostingById

The search rows carry no description. Fetch each opening through jobPostingById, which returns the full HTML description, any uploaded description file, and custom field values. Verify the returned orgId still equals the organization you asked for before storing the row.

Step 5: Hydrate each opening with jobPostingById
import time

DETAILS_QUERY = """
query GetJobPosting($jobPostingId: ID!) {
  jobSeekerSiteUnauthenticated {
    jobPostingById(jobPostingId: $jobPostingId) {
      id orgId name statusId organizationName description
      category { id name }
      minPay maxPay payTypeId allowsRemote
      descriptionFileUpload { originalFileUrl uploadedFileName }
      location { name address { address1 city state postalCode country } }
      customFieldValues { id value customField { name customFieldType } }
    }
  }
}
"""

def get_opening(org_id: str, opening_id: str) -> dict | None:
    resp = requests.post(
        GRAPHQL,
        json={
            "operationName": "GetJobPosting",
            "query": DETAILS_QUERY,
            "variables": {"jobPostingId": opening_id},
        },
        headers={"rrClient": "JobSeeker", "Content-Type": "application/json"},
        timeout=30,
    )
    resp.raise_for_status()
    posting = resp.json()["data"]["jobSeekerSiteUnauthenticated"]["jobPostingById"]

    if posting is None:
        return None  # structured null: the opening is gone, the district is not
    if str(posting.get("orgId")) != str(org_id):
        raise RuntimeError("detail returned a different organization — reject the row")

    posting["listing_url"] = f"https://jobs.redroverk12.com/org/{org_id}/opening/{opening_id}"
    posting["apply_url"] = posting["listing_url"] + "/apply"
    return posting

for job in open_jobs[:3]:
    detail = get_opening("3877", job["id"])
    if detail:
        print(detail["name"], "-", detail["location"]["name"])
    time.sleep(0.1)
Common issues
highWhy does a Red Rover board URL work with a slug and a number?
Red Rover's route parameter is orgIdOrPath, so /org/wauseonschools and /org/1183 render the same district. The GraphQL API only accepts the numeric orgId. Resolve the slug through the board's SimpleJobSeekerSiteBranding record first; keying on the slug registers one district as two employers.
highWhy does the search return at most 500 postings?
The public JobPostingSearchInput has no offset or limit fields and the response window is capped at 500 rows. Read hasMoreData on every response and mark the snapshot incomplete when it is true, rather than treating the first 500 rows as the whole board and expiring the rest.
mediumWhy do listing rows have no description?
jobPostingSearch returns identity, status, category, pay, and location only. The full HTML description comes from jobPostingById, and some districts attach the description as an uploaded file exposed through descriptionFileUpload instead of inline HTML.
mediumWhy does jobPostingById return null for a job that used to exist?
A structured null is Red Rover's removal signal for a pulled opening; the organization itself stays resolvable. Treat it as a delisting for that job only, and keep scraping the board — a production audit found four stale openings whose districts still published 15 to 33 current jobs.
lowWhy do closed and paused jobs appear in the results?
The search returns every status the district has on file — CLOSED, PAUSED, ARCHIVED, PUBLISHED, and PUBLISHED_INTERNAL all appear. Filter on statusId and keep PUBLISHED (plus PUBLISHED_INTERNAL only if you want internal-only postings) before publishing rows.
Best practices
  1. 1Send the rrClient: JobSeeker header on every GraphQL request
  2. 2Resolve vanity /org/{slug} paths to the numeric orgId before querying the API
  3. 3Refuse the snapshot when hasMoreData is true instead of accepting the 500-row cap
  4. 4Filter statusId to PUBLISHED so closed, paused, and archived rows never reach your index
  5. 5Fall back to descriptionFileUpload when the inline description field is empty
  6. 6Treat a null jobPostingById as a job-level delisting, not a dead district
Or skip the complexity

One endpoint. All Red Rover K12 jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=red rover k12" \
  -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 Red Rover K12
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