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.

Get API access

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

  1. 01K-12 Education Job Aggregation
  2. 02Public Sector Hiring Trackers
  3. 03Teacher Recruitment Feeds
  4. 04Regional Labour Market Research

Trusted by

  • Strathmore Union Elementary
  • Porterville Unified School District
  • Farmersville Unified
DIY GUIDE

How to scrape EDJOIN.

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

API type
Hybrid
Difficulty
intermediate
Rate limit
No published limit; ~250ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Resolve the district account

An EDJOIN board is a numeric districtID on www.edjoin.org/Home/Jobs. Without that parameter the same path is the site-wide search across every district, which is not a board. Postings live at /Home/JobPosting/{postingId} and carry no district in the URL.

Step 1: Resolve the district account
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'

Page the LoadJobs endpoint

LoadJobs is EDJOIN's own search backend and it answers only to XHR-shaped requests. Send X-Requested-With and a Referer of the district board. The response echoes the districtID, page and rows you asked for, and publishes total_pages and total_records as the authoritative totals.

Step 2: Page the LoadJobs endpoint
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&regions=&jobTypes=&days=0&empType="
        "&catID=0&onlineApps=null&recruitmentCenterID=0&stateID=0&regionID=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")

Map rows and decode the dates

Rows carry the posting ID, title, district name, salary text, job type and full-time/part-time flag. Posting and display-until dates arrive in the legacy Microsoft JSON format, a millisecond epoch wrapped in a Date() call, so convert them before storing.

Step 3: Map rows and decode the dates
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 listings

Read the posting page for the full text

Search rows carry no body. The posting page publishes JobPosting JSON-LD, but EDJOIN splits the content across several properties — description, jobSummary, experienceRequirements and skills — so concatenate the ones that are present instead of taking description alone.

Step 4: Read the posting page for the full text
import 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"],
    }

Confirm the posting belongs to the district

Because posting URLs carry no district, a syndicated link can point at any account. The posting page loads districtJobPosting.js and embeds the account and posting IDs; check both before attributing the job, otherwise one district's snapshot absorbs another's postings.

Step 5: Confirm the posting belongs to the district
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)
Common issues
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.
Best practices
  1. 1Require a numeric districtID before treating a URL as a board
  2. 2Send X-Requested-With and a board Referer on every LoadJobs call
  3. 3Verify the echoed districtID, page and rows on every response
  4. 4Drive pagination off total_pages and total_records, not off an empty page
  5. 5Convert the Microsoft Date() epoch to a real timestamp when mapping rows
  6. 6Prove district ownership on the posting page before attributing a syndicated link
Or skip the complexity

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

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

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