- criticalThe listing endpoint returns an unrelated result set
- LoadJobs is the site-wide search with filters applied, so a missing or mistyped districtID silently returns every district's postings. Check that the response's search object echoes back the districtID, page and rows you sent before mapping any rows.
- highPosting dates parse as nonsense or null
- EDJOIN emits the legacy Microsoft JSON date format — a millisecond epoch wrapped in a Date() call — not ISO 8601. Extract the integer and convert from milliseconds, otherwise every posting date is dropped or read as a string.
- highDescriptions come through short or empty
- The JobPosting block splits content across description, jobSummary, experienceRequirements and skills, and many districts leave description nearly empty. Concatenate whichever of those properties are present rather than reading description alone.
- mediumA posting is attributed to the wrong district
- /Home/JobPosting/{id} carries no account in the URL, so any inbound link resolves to the same shape. Read the account ID embedded alongside districtJobPosting.js on the page and require it to match the district you are scraping before writing the record.
EDJOIN Jobs API.
EDJOIN is the education job board used by California school districts and county offices. Each district is a numeric account whose postings come from one JSON search endpoint, with full descriptions on the posting page.
What's in every response.
Data fields, real-world applications, and the companies already running on EDJOIN.
Data fields
- Full Job Descriptions
- District Name & Account ID
- Salary Information
- Job Type & FTE
- Posting & Display-Until Dates
- JobPosting JSON-LD
Use cases
- 01K-12 Education Job Aggregation
- 02Public Sector Hiring Trackers
- 03Teacher Recruitment Feeds
- 04Regional Labour Market Research
Trusted by
- Strathmore Union Elementary
- Porterville Unified School District
- Farmersville Unified
How to scrape EDJOIN.
Step-by-step guide to extracting jobs from EDJOIN-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse, parse_qs
HOST = "www.edjoin.org"
def parse_board(url: str) -> str | None:
"""Return the numeric districtID for an EDJOIN board URL."""
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.netloc.lower() != HOST:
return None
if parsed.path.lower() != "/home/jobs":
return None
values = parse_qs(parsed.query).get("districtID") or []
if len(values) != 1:
return None
district = values[0]
# Reject the tenantless site-wide search and padded IDs.
if not district.isdigit() or district.startswith("0"):
return None
return district
print(parse_board("https://www.edjoin.org/Home/Jobs?districtID=1007")) # '1007'import requests
PAGE_SIZE = 100
def listings_url(district_id: str, page: int) -> str:
return (
f"https://{HOST}/Home/LoadJobs"
f"?rows={PAGE_SIZE}&page={page}&sort=postingDate&sortVal=0&order=DESC"
"&keywords=&location=&searchType=all®ions=&jobTypes=&days=0&empType="
"&catID=0&onlineApps=null&recruitmentCenterID=0&stateID=0®ionID=0"
f"&districtID={district_id}&searchID=0"
)
def fetch_page(session, district_id: str, page: int) -> dict:
resp = session.get(
listings_url(district_id, page),
headers={
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"https://{HOST}/Home/Jobs?districtID={district_id}",
},
timeout=30,
)
resp.raise_for_status()
payload = resp.json()
search = payload.get("search") or {}
if search.get("districtID") != district_id or search.get("page") != page:
raise RuntimeError("EDJOIN response did not echo the requested account")
if search.get("rows") != PAGE_SIZE:
raise RuntimeError("EDJOIN response did not echo the requested page size")
return payload
session = requests.Session()
first = fetch_page(session, "1007", 1)
print(first["total_records"], "postings across", first["total_pages"], "pages")import re
from datetime import datetime, timezone
MS_DATE = re.compile("[/]Date[(](-?[0-9]+)")
def parse_ms_date(value: str | None):
if not value:
return None
match = MS_DATE.search(value)
if not match:
return None
return datetime.fromtimestamp(int(match.group(1)) / 1000, tz=timezone.utc)
def map_rows(payload: dict) -> list[dict]:
rows = []
for row in payload.get("data") or []:
posting_id = row.get("postingId")
title = (row.get("positionTitle") or "").strip()
district = (row.get("districtName") or "").strip()
if not posting_id or not title or not district:
continue
rows.append({
"id": str(posting_id),
"title": title,
"district": district,
"job_type": row.get("jobType"),
"employment_type": row.get("fullTimePartTime"),
"salary": row.get("salaryInfo"),
"online_application": row.get("onlineApp"),
"posted_at": parse_ms_date(row.get("postingDate")),
"closes_at": parse_ms_date(row.get("displayUntil")),
"url": f"https://{HOST}/Home/JobPosting/{posting_id}",
})
return rows
def fetch_all(session, district_id: str) -> list[dict]:
payload = fetch_page(session, district_id, 1)
if payload.get("total_records") == 0:
return []
listings = map_rows(payload)
for page in range(2, int(payload["total_pages"]) + 1):
listings.extend(map_rows(fetch_page(session, district_id, page)))
return listingsimport json
from bs4 import BeautifulSoup
DESCRIPTION_FIELDS = ["description", "jobSummary", "experienceRequirements", "skills"]
def find_job_posting(html: str) -> dict | None:
soup = BeautifulSoup(html, "html.parser")
for tag in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(tag.string or "")
except (json.JSONDecodeError, TypeError):
continue
for node in (data if isinstance(data, list) else [data]):
if isinstance(node, dict) and node.get("@type") == "JobPosting":
return node
return None
def fetch_detail(session, listing: dict) -> dict | None:
resp = session.get(listing["url"], headers={"Accept": "text/html"}, timeout=30)
if resp.status_code in (404, 410):
return None # posting removed
resp.raise_for_status()
posting = find_job_posting(resp.text)
if not posting:
return None
sections = [str(posting[field]) for field in DESCRIPTION_FIELDS
if isinstance(posting.get(field), str) and posting[field].strip()]
address = ((posting.get("jobLocation") or {}).get("address")) or {}
return {
"id": listing["id"],
"title": posting.get("title") or listing["title"],
"description_html": "".join(sections),
"employment_type": posting.get("employmentType"),
"posted_at": posting.get("datePosted") or listing.get("posted_at"),
"closes_at": posting.get("validThrough") or listing.get("closes_at"),
"employer": (posting.get("hiringOrganization") or {}).get("name"),
"city": address.get("addressLocality"),
"state": address.get("addressRegion"),
"url": listing["url"],
}POSTING_ID = re.compile("postingId[\"' :=]+([0-9]+)")
ACCOUNT_ID = re.compile("accountId[\"' :=]+([0-9]+)")
def prove_ownership(html: str, expected_posting_id: str) -> str | None:
"""Return the district account ID the page proves, or None."""
if "/Scripts/pages/districtJobPosting.js" not in html:
return None
posting = POSTING_ID.search(html)
account = ACCOUNT_ID.search(html)
if not posting or not account:
return None
if posting.group(1) != str(expected_posting_id):
return None
return account.group(1)
def fetch_verified(session, listing: dict, district_id: str) -> dict | None:
resp = session.get(listing["url"], headers={"Accept": "text/html"}, timeout=30)
resp.raise_for_status()
if prove_ownership(resp.text, listing["id"]) != district_id:
return None # posting belongs to a different district
return fetch_detail(session, listing)- 1Require a numeric districtID before treating a URL as a board
- 2Send X-Requested-With and a board Referer on every LoadJobs call
- 3Verify the echoed districtID, page and rows on every response
- 4Drive pagination off total_pages and total_records, not off an empty page
- 5Convert the Microsoft Date() epoch to a real timestamp when mapping rows
- 6Prove district ownership on the posting page before attributing a syndicated link
One endpoint. All EDJOIN jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=edjoin" \
-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 EDJOIN
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.