- 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.
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.
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
- 01K-12 Education Job Boards
- 02School District Hiring Trackers
- 03Public-Sector Talent Research
- 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
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.
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}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"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"])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, ARCHIVEDimport 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)- 1Send the rrClient: JobSeeker header on every GraphQL request
- 2Resolve vanity /org/{slug} paths to the numeric orgId before querying the API
- 3Refuse the snapshot when hasMoreData is true instead of accepting the 500-row cap
- 4Filter statusId to PUBLISHED so closed, paused, and archived rows never reach your index
- 5Fall back to descriptionFileUpload when the inline description field is empty
- 6Treat a null jobPostingById as a job-level delisting, not a dead district
One endpoint. All Red Rover K12 jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=red rover k12" \
-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 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.