# EdJoin.org Scraper - Education Jobs, Salaries & Districts (`abotapi/edjoin-scraper`) Actor

Scrape EdJoin.org K-12 and higher-ed jobs by keyword, location, filters or URL. Extract districts, locations, salaries, openings, deadlines, requirements and full descriptions, with optional contact and address details.

- **URL**: https://apify.com/abotapi/edjoin-scraper.md
- **Developed by:** [Abot API](https://apify.com/abotapi) (community)
- **Categories:** Jobs, Developer tools, Automation
- **Features:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 32.1% runs succeeded, 1 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.00 / 1,000 results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## EdJoin Scraper: Education Job Listings, Salary Ranges & Detail Enrichment

EdJoin Scraper turns EdJoin, the K-12 and higher-education hiring board used by school districts and county offices, into structured job data. Search by keyword, location and filters, or paste EdJoin search and job-posting links. Every job comes back as one flat record with district, location, salary range, category, openings and deadlines, plus optional contact details, full address, requirements and full descriptions. Export to JSON, CSV or Excel, or pull the results straight into your app through the API.

### Why This Scraper?

- **Two ways in.** Search by keyword, location and filters, or paste EdJoin search and job-posting links directly. No need to build query strings by hand.
- **One flat record per job.** Every listing merges the search-result card and, optionally, its detail page into a single record: about 40 fields on a listing-only run, more once detail enrichment is on.
- **Full detail enrichment on demand.** Contact name and email, complete street address, education and experience requirements, skills, the full job summary and description, employer logo and the offsite apply link, one extra request per job.
- **Filters that narrow the search at the source.** Job type, category, online-application-only, posted-within-days and sort order are all applied server-side, not filtered out after the fact.
- **Walks a whole search, or an entire catalogue, forward automatically.** Set a hard job or page cap, or let it run until results run out.
- **Built for monitoring.** Incremental mode tracks a recurring search over time and classifies every job NEW, UPDATED, UNCHANGED, REAPPEARED or EXPIRED, so a scheduled run bills only for what changed.
- **Fails loudly, not quietly.** If EdJoin refuses every request in a run, the run fails with a clear message instead of returning an empty dataset that looks like "no jobs."

### Use Cases

- **School HR and staffing teams:** track new certificated and classified openings across districts to prioritize sourcing and outreach.
- **Recruiting and HR-tech platforms:** mirror EdJoin postings into your own job board or ATS without sending users to EdJoin directly.
- **Compensation and market-rate teams:** collect posted salary ranges and requirements to benchmark against your own pay bands.
- **Education researchers and journalists:** build a dataset of job postings, salary ranges and hiring activity across school districts over time.
- **Districts and agencies monitoring their own postings:** schedule a recurring pull with Incremental mode to know the moment a listing changes or is taken down.

### Data You Get

> Sample shape: values are illustrative placeholders, not from a live posting.

| Field | Example |
| --- | --- |
| `id` | `20000001` |
| `url` | `"https://www.edjoin.org/Home/JobPosting/20000001"` |
| `title` | `"Elementary Teacher, 1.0 FTE, 2026-2027"` |
| `districtName` | `"Example Unified School District"` |
| `district`, `county`, `contact`, `date`, `state` | Alias columns: the same values as `districtName`, `countyName`, `contactName`, `datePosted` and `stateName`, for users migrating from other EdJoin scrapers. They never trigger an UPDATED change in incremental mode. |
| `city` / `stateName` | `"Exampleton"` / `"California"` |
| `countyName` / `countyFullName` | `"Example"` / `"Example County, CA"` |
| `zip` | `"90000"` |
| `location` | `"Exampleton, California"` |
| `jobType` / `jobTypeId` | `"Teacher - K-6"` / `48` |
| `categoryName` / `categoryId` | `"Certificated"` / `1` |
| `salaryDisplay` | `"$60,000 - $117,000 Annually"` |
| `payRangeFrom` / `payRangeTo` | `"$60,000"` / `"$117,000"` |
| `employmentType` / `numberOpenings` | `"Full Time"` / `2` |
| `onlineApp` | `true` |
| `datePosted` | `"2026-01-01T00:00:00.000Z"` |
| `applicationDeadline` | `"2026-02-01T00:00:00.000Z"` |
| `contactName` / `contactEmail` | `"Jane Example"` / `"jobs@example.org"` |
| `streetAddress` | `"100 Example Blvd."` |
| `educationRequirements` | `"Valid California Teaching Credential"` |
| `description` | `"Full job description text appears here when detail enrichment is on."` |
| `employerLogo` | link to the district's logo image |
| `offsiteUrl` | link to apply, when the district uses its own application system |
| `scrapedAt` | `"2026-01-01T00:00:00.000Z"` |

Listing-only runs (Fetch full job details off) still return about 40 fields. The detail-only fields (`contactName`, `contactEmail`, `streetAddress`, `addressLocality`, `addressRegion`, `postalCode`, `addressCountry`, `educationRequirements`, `experienceRequirements`, `skills`, `jobSummary`, `description`, `workYearLength`, `employerName`, `employerUrl`, `employerLogo`, `offsiteUrl`, `attachmentName`) are `null` until Fetch full job details is on. In Incremental mode, records also carry `changeType`, `changedFields`, `firstSeenAt` and `lastSeenAt`; an `EXPIRED` row repeats the fields from that job's last successful scrape.

### How to Use

1. Pick a **mode**: `search` (keyword + filters) or `url` (paste EdJoin search or job-posting links).
2. Fill in the fields for that mode, and turn on **Fetch full job details** if you want contact info, full address, requirements and the full description.
3. Set **Max jobs** (and **Max pages per search** if you want an explicit ceiling) to control run size and cost, then click **Start**.
4. Download the dataset as JSON, CSV or Excel, or read it through the API.

**Basic keyword search:**

```json
{
  "mode": "search",
  "keywords": "teacher",
  "maxListings": 20,
  "fetchDetails": true
}
```

**Filtered search (special-education teaching jobs that take an online application, posted in the last week):**

```json
{
  "mode": "search",
  "keywords": "special education",
  "jobTypeIds": ["9"],
  "onlineApplicationOnly": true,
  "postedWithinDays": 7,
  "maxListings": 100
}
```

**Browse a whole category, walking the full catalogue:**

```json
{
  "mode": "search",
  "keywords": "",
  "categoryId": 2,
  "maxListings": 0,
  "maxPages": 0,
  "fetchDetails": false
}
```

**Paste URLs (a search page and a single job posting):**

```json
{
  "mode": "url",
  "urls": [
    "https://www.edjoin.org/Home/Jobs?keywords=principal&location=&searchType=all",
    "https://www.edjoin.org/Home/JobPosting/20000002"
  ],
  "fetchDetails": true
}
```

#### Run it from your code

Python:

```python
from apify_client import ApifyClient

client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("abotapi/edjoin-scraper").call(run_input={"mode": "search", "keywords": "teacher", "maxListings": 20})
for job in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(job["title"], job["districtName"], job["salaryDisplay"])
```

JavaScript:

```javascript
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('abotapi/edjoin-scraper').call({ mode: 'search', keywords: 'teacher', maxListings: 20 });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

Or connect it to Make, Zapier, n8n, Google Sheets or webhooks from the **Integrations** tab.

#### Caps: Max jobs and Max pages

**Max jobs** (`maxListings`) is the overall stop: the run ends as soon as this many jobs have been collected across every search or URL in the input, and it is split evenly across multiple searches so each one gets representation. **Max pages per search** (`maxPages`), at its default of `0`, walks every page of a search until a page comes back empty or holds no job the run has not already seen, or until Max jobs is reached, whichever happens first. Set Max pages to a specific number only if you want an explicit ceiling below that natural stop.

**Known limitation when pasted search URLs overlap.** The "no new job on this page" check compares each page against every job the whole run has already seen, not just the current search's own jobs. So if a later search URL hits a page made up entirely of jobs an earlier search URL already returned, that later search stops right there and its deeper pages are never requested, even with Max pages at `0`. This can happen on its very first page, or midway after it has already returned new jobs. A page with even one job the run has not seen yet does not trigger it. Example, with Max jobs at `0`: URL A (`keywords=teacher`) returns jobs 1 to 3 on its only page. URL B (`keywords=elementary teacher`) would serve jobs 1 to 3 again on page 1 and new jobs 10 and 11 on page 2. Because page 1 of URL B holds only jobs URL A already returned, URL B stops on page 1 and jobs 10 and 11 are never collected. The same happens if URL B's page 1 is new but its page 2 repeats URL A's jobs: its page 3 is never reached. Raising Max pages does not help. **Workaround:** when your search URLs are likely to share results (similar keywords, a broad search plus a narrower one inside it, nearby locations), run each one as its own run, for example one saved task per URL.

**Known limitation with Max jobs across several searches.** When a search's share of Max jobs is smaller than the page it just read (a page holds up to 200 jobs), the jobs past that share are skipped but still counted as seen for the rest of the run. Two consequences: a job-posting URL pasted in the same run for one of those skipped jobs is silently dropped (no row, no detail request), and a later search whose page consists only of those skipped jobs stops early as described above. Example: Max jobs `6` with two search URLs gives each a share of 3. URL A's page lists jobs 1 to 10, so jobs 1 to 3 are returned and 4 to 10 are skipped; a pasted `/Home/JobPosting/7` in the same run then returns nothing. **Workaround:** put job-posting URLs in their own run, or set Max jobs to `0` (or high enough to cover every search) when mixing searches and posting URLs.

#### Resume and recurring updates

- **Resume from a prior run** (`resumeFromRunId`): paste a prior run or dataset ID and the actor loads every job ID already saved there before scraping starts, so those jobs are never pushed or billed again. The run also keeps its own checkpoint of the job IDs it has saved, so a platform migration or a Resurrect of a failed run of the same input does not push those jobs twice.
- **Incremental mode** (`incrementalMode`) is for scheduled runs over the same search or URL set. Each job is classified `NEW`, `UPDATED` (with `changedFields`), `UNCHANGED` (suppressed and not billed unless `emitUnchanged` is on), `REAPPEARED` or `EXPIRED` (only after a run that reached the natural end of every tracked search, did not stop at Max jobs and was not a resume, and only with `emitExpired` on). `UPDATED` compares every output field with the previous scrape except `scrapedAt` and any field names you list in `ignoreFieldsForChanges`; ignored fields are still returned. `stateKey` names or shares the stored baseline. A run that stops at the run time limit, or starts from a pasted URL with `page=2` or later, never reports `EXPIRED`. **One-time baseline reset:** in URL mode, a task whose pasted URLs carry `districtID`, `recruitmentCenterID`, `regions` or `empType` now starts a fresh baseline on its first run after this update, because those parameters used to be ignored (the whole board was scraped) and now narrow the search. That first run reports the narrowed jobs as `NEW` instead of reporting the rest of the old baseline as `EXPIRED`. Tasks without those parameters keep their existing baseline. With Incremental mode off, output is exactly as before.

**Known limitation: a resumed walk restarts from page 1 and can stop there.** Resume (and a migrated or Resurrected run reloading its checkpoint) does not remember which page the earlier run reached; it walks the search again from its first page and relies on the "no new job on this page" stop described under Caps. On a newest-first search that has not changed much since the interrupted run, page 1 consists only of jobs that run already saved, so the walk stops on page 1. Example: a walk-all pull of a 4-page search is interrupted after page 2. A resumed run makes exactly one page request, pushes zero jobs (the dataset holds only a single "No jobs found" note row), and pages 3 and 4 are never collected, although no run ever read them. The same happens if page 1 has a few new postings but page 2 is fully saved already: the walk stops on page 2. **Workaround:** resume through URL mode instead. Build the search as an EdJoin search URL with the same filters, add `&page=N`, where N is the last `Page N` number in the interrupted run's log (the actor's pages hold up to 200 jobs each), and paste the prior run's ID into Resume from a prior run as usual. The walk then starts at page N, already-saved jobs are still skipped, and it continues to the end. If that run stops at once with "Page N: 0 new jobs", use `&page=N+1`. Jobs posted at the top of the search since the interrupted run are not picked up by this; a normal or Incremental run catches them.

**Expired-job reporting caveats.** With several search URLs in one run: if one of them stops early because of the overlap limitation above, the run does not count as a complete scan, so no `EXPIRED` rows are reported that run (nothing is wrongly expired, but real removals are not reported either). And when a search returns fewer jobs than its page holds because its share of Max jobs ran out while the run as a whole stayed under Max jobs, the jobs skipped on that page are not checked, so previously tracked jobs among them can be reported `EXPIRED` although they are still live. Example: Max jobs `6`, two search URLs, URL A's single page grows from 5 to 8 jobs as 3 new postings appear at the top; its share of 3 returns only the 3 new ones, URL B returns 1, and URL A's previously tracked jobs 1 to 3 come back as `EXPIRED`. For reliable `EXPIRED` rows, use one search per Incremental run (or non-overlapping search URLs) with Max jobs at `0`.

### Send results into your apps (MCP connectors)

Optionally pipe the scraped results into the apps you already use, via Model Context Protocol (MCP) connectors. This is an extra delivery step **after** the scrape - the Apify dataset is never changed.

**What gets written to the connector:** a condensed, human-readable **summary** of each record - not the full JSON. Each item becomes one entry with a **title** and its key fields flattened to plain text. The **complete record always stays in the Apify dataset**.

1. Authorize a connector once under **Apify -> Settings -> Integrations** (Notion, Linear, Airtable, or Apify).
2. Select it in the **"Pipe results into your apps"** input field. (If the picker is empty, you haven't authorized a connector yet.)
3. For **Notion**, also set `notionParentPageUrl` to the page where items should be created.

The connection is mediated by Apify's MCP proxy, so this actor never sees your third-party credentials. Leave the field empty to skip.

### Input Parameters

| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | string | `search` | `search` = keyword + filters; `url` = paste EdJoin search or job-posting links. |
| `keywords` | string | (empty; prefilled `teacher`) | Job title, keyword or organization to search for (search mode). Leave empty to list everything. |
| `location` | string | (empty) | Free-text location to narrow the search: a city, county or ZIP. Leave empty for statewide / all. |
| `jobTypeIds` | array | (empty) | Restrict to specific EdJoin job types by numeric ID (see `jobTypeId` in the output). Multiple IDs are OR-combined. |
| `categoryId` | integer | (empty) | One EdJoin category: `1` Certificated, `2` Classified, `3` Management, `4` Other. Leave empty for all. |
| `stateId` | integer | (empty) | Retired: the live source no longer honors a state filter, so any value is accepted for saved-task compatibility and then ignored. |
| `onlineApplicationOnly` | boolean | `false` | Only return jobs that accept an EdJoin online application. |
| `postedWithinDays` | integer | `0` | Only jobs posted within the last N days. `0` = no date limit. |
| `urls` | array | (8 example URLs, each a distinct target) | EdJoin search or job-posting URLs to paste (url mode); `startUrls` is accepted as an alias. A `page=` value in a search URL makes the walk start at that page (pages hold up to 200 jobs). **Url mode with this field left untouched scrapes all 8 example URLs.** Variants that resolve to an already-listed posting (non-www host, legacy `/Home/DistrictJobPosting/<id>`, extra query string) are accepted but not in the default list because they return the same row. |
| `sortBy` | string | `newest` | `newest` or `oldest` first. Applies to both modes. |
| `fetchDetails` | boolean | `true` | On: each job also gets contact name and email, full street address, education and experience requirements, skills, job summary, full description, employer logo, offsite apply link and attachment (one extra request per job). Off: listing-card fields only. |
| `maxListings` | integer | `20` | Overall cap on jobs across every search or URL in the run. `0` = unlimited. Values above 1,000,000 are capped at 1,000,000. |
| `maxPages` | integer | `0` | Cap on pages walked per search. `0` = walk until a page comes back empty or holds no job the run has not already seen (including jobs from another search in the same run, or from a resumed run), or Max jobs is reached, whichever comes first. |
| `proxy` | object | Apify datacenter | Connection settings. Datacenter is the default and the cheapest; residential is optional (not on free plans). See Plan Requirement. |
| `maxResidentialRequests` | integer | `0` | Cap on requests that may use residential proxy in one run. `0` = unlimited. Only relevant if residential proxy is enabled above. |
| `mcpConnectors` | array | (none) | Optional: send a summary of each job to apps you authorized under Integrations. |
| `notionParentPageUrl` | string | (none) | Notion connector only: page under which job pages are created. |
| `maxNotifyListings` | integer | `50` | Cap on items written to each connector per run. Does not affect the dataset. |
| `resumeFromRunId` | string | (empty) | Paste a previous run ID or dataset ID to continue a large walk-all pull: jobs already saved by that run are loaded before scraping starts and skipped, so this run only appends new jobs. The walk restarts from page 1 and can stop there; see the resume limitation under How to Use for the `page=` workaround. |
| `incrementalMode` | boolean | `false` | On: remembers this search/URL set's results between scheduled runs and classifies each job as NEW / UPDATED / UNCHANGED / REAPPEARED / EXPIRED. Off: every run is a plain fresh pull. |
| `stateKey` | string | (empty) | Optional label for the incremental-mode baseline. Leave empty to derive it automatically from your search/URL settings. |
| `emitUnchanged` | boolean | `false` | On: also return (and bill) jobs unchanged since the last run. Only applies when Incremental mode is on. |
| `ignoreFieldsForChanges` | array | `[]` | Field names (any depth, or dotted path) that do not count as a change in Incremental mode; they are still returned. Only applies when Incremental mode is on. |
| `emitExpired` | boolean | `false` | On: also return (and bill) a row for each previously tracked job no longer found. Only emitted after a run that reached the natural end of every search, did not stop at Max jobs and was not a resume. See the expired-job caveats under How to Use for multi-search runs. Only applies when Incremental mode is on. |

#### URL mode: supported URL shapes

| Shape | Example | Behaviour |
|---|---|---|
| Keyword search | `https://www.edjoin.org/Home/Jobs?keywords=teacher&location=&searchType=all` | Walks every page of the search. |
| Paginated search | `.../Home/Jobs?keywords=teacher&page=2&searchType=all` | Starts at the given page. |
| Filtered / sorted search | `.../Home/Jobs?keywords=teacher&catID=1&jobTypes=4&days=7&onlineApps=true&order=asc&searchType=all` | `catID`, `jobTypes`, `days`, `onlineApps` and `order` are honoured. |
| District or recruitment-centre search | `.../Home/Jobs?districtID=3672&searchType=all` | `districtID` and `recruitmentCenterID` narrow the listing to that district or centre (a district link on edjoin.org carries the ID). |
| County/region and employment-type search | `.../Home/Jobs?regions=1,2&empType=Full&searchType=all` | `regions` (a comma list of county IDs, as in the site's Region filter) and `empType` (the site's employment-type word, for example `Full` or `Part`) narrow the listing exactly as they do on edjoin.org. A `regions` entry that is not a county ID number is dropped and a warning names it. |
| Location-only search | `.../Home/Jobs?location=Los+Angeles&searchType=all` | Works without keywords. |
| Bare listing | `https://www.edjoin.org/Home/Jobs` | All current postings. |
| Posting | `https://www.edjoin.org/Home/JobPosting/2287270` | One row; the `edjoin.org` host without `www` and extra query strings also work. |
| Legacy posting | `https://www.edjoin.org/Home/DistrictJobPosting/2287270` | Treated as the posting with the same ID. |
| Expired posting | `https://www.edjoin.org/Home/JobPosting/2231157` | An expired or removed posting (HTTP 410 or 404) pushes no row and charges nothing; the log shows a warning naming the posting ID. |

`regionID` (no effect on EdJoin's listing endpoint) and `stateID` (retired by EdJoin) in a pasted URL are not applied (a warning names them), and URLs of any other shape are skipped with a warning.

#### Dataset views

The Output tab offers three views: **Listings** (the core columns for each job), **Job details** (contact, address, requirements and description, filled when Fetch full job details is on) and **Changes (incremental)** (`changeType`, `changedFields`, `firstSeenAt`, `lastSeenAt`, for Incremental runs).

#### Alternative input names

`searchKeyword` (= `keywords`), `searchLocation` (= `location`), `maxItems` (= `maxListings`), `collectDescriptionFields` (= `fetchDetails`) and `startUrls` (= `urls`) are accepted. The canonical name wins; an alias is used only when the canonical field is empty or still at its default (`maxListings` 20, `fetchDetails` true, `urls` empty or still the 8 example URLs).

### Output Example

> Sample shape: values are illustrative placeholders, not from a live posting.

```json
{
  "id": 20000001,
  "url": "https://www.edjoin.org/Home/JobPosting/20000001",
  "title": "Elementary Teacher, 1.0 FTE, 2026-2027",
  "districtName": "Example Unified School District",
  "city": "Exampleton",
  "countyName": "Example",
  "countyFullName": "Example County, CA",
  "stateName": "California",
  "stateId": 24,
  "zip": "90000",
  "location": "Exampleton, California",
  "jobType": "Teacher - K-6",
  "jobTypeId": 48,
  "categoryName": "Certificated",
  "categoryId": 1,
  "salaryInfo": "See attached certificated salary schedule",
  "salaryDisplay": "$60,000 - $117,000 Annually",
  "payRangeFrom": "$60,000",
  "payRangeTo": "$117,000",
  "payRangeUnit": "Annually",
  "employmentType": "Full Time",
  "numberOpenings": 2,
  "onlineApp": true,
  "datePosted": "2026-01-01T00:00:00.000Z",
  "applicationDeadline": "2026-02-01T00:00:00.000Z",
  "workYearLength": "2026-2027 School Year",
  "contactName": "Jane Example",
  "contactEmail": "jobs@example.org",
  "streetAddress": "100 Example Blvd.",
  "addressLocality": "Exampleton",
  "addressRegion": "California",
  "postalCode": "90000-0000",
  "addressCountry": "US",
  "employerName": "Example Unified School District",
  "employerUrl": "https://www.edjoin.org/example",
  "employerLogo": "https://example.org/logos/example-000000.png",
  "educationRequirements": "Valid California Teaching Credential",
  "experienceRequirements": "Online application required; incomplete applications are screened out.",
  "skills": "BA/BS plus subject-matter competence",
  "jobSummary": "Full job summary text appears here when detail enrichment is on.",
  "description": "See attachment on original job posting",
  "offsiteUrl": "https://www.example.org/apply",
  "attachmentName": "Job-Description-00000000.pdf",
  "searchQuery": "teacher",
  "scrapedAt": "2026-01-01T00:00:00.000Z"
}
```

### Plan Requirement

This actor runs on any Apify plan and is priced pay per event, not a monthly rental. Three events exist: **Run start** (once per run), **Job result** (once per job pushed to the dataset) and **Job detail enrichment** (once per pushed job whose detail page was actually fetched and read, only when Fetch full job details is on; listing-only runs and suppressed unchanged jobs never see it). The **Pricing** tab has the current numbers.

**Connection.** The default is Apify datacenter proxy, the cheapest tier. We have not verified that datacenter exits alone are always accepted by EdJoin, so no guarantee is made: plain requests to EdJoin pages were served normally in our checks, but the datacenter lane itself has not been measured. Residential proxy is optional: turn it on under **Proxy** if you see rejections, and use **Residential request budget** to cap how many requests may use it in one run. After repeated rejections the run moves up to the next connection automatically, and a backup connection is used if Apify proxy is unavailable.

**Your own proxy.** If you enter your own proxy URLs under **Proxy**, they are the only connection the run uses: no Apify proxy, no backup connections and no direct fallback. If Proxy is switched off with no URLs entered, the run connects directly and uses no backup connections either.

**Free Apify plans** do not include residential proxy or, on some accounts, any proxy. When you pick Residential and Apify refuses it, the run logs this warning once and continues on the backup connection:

```
----------------------------------------------------------------
The selected connection is unavailable to this run.
Apify free plans do not include residential proxies.
Continuing on a backup connection; some pages may still be unavailable.
For residential access, upgrade at https://apify.com/pricing or supply your own proxy URLs.
----------------------------------------------------------------
```

Whenever a page is actually served by a backup connection, the log says once, at info level: "Served over the backup connection (the default connection was unavailable)."

**If a run cannot read any page it fails with one of these messages** instead of returning an empty dataset:

- "The proxy connection was refused for this account, so no page could be read. Your Apify plan does not include proxy access; a paid plan is required: https://apify.com/pricing. If you entered your own proxy URLs, check that their credentials are valid."
- "No results: residential proxies are unavailable on this run (Apify free plans do not include them), and the backup connection could not load the requested edjoin.org pages. Upgrade at https://apify.com/pricing or supply your own proxy URLs, then try again."
- "Could not load any results page (the requests failed), so nothing was collected. This is a connection problem, not an empty search. Please try again later."

Each page request has a 25 second budget across all connection rotations. A removed posting (HTTP 410 or 404) is treated as not found, not as a block.

### FAQ

#### How much does it cost?

You pay per event: once when a run starts, once for each job pushed to the dataset, and a small extra amount for each job whose detail page is fetched. The **Pricing** tab shows the current rates. Use **Max jobs** to cap the cost of any run.

#### Is it legal to scrape EdJoin?

This actor collects only publicly available job listing data. You are responsible for how you use it: follow EdJoin's terms and the laws that apply to you, and get legal advice if you plan commercial redistribution.

#### Can I get only new or changed jobs on a schedule?

Yes. Schedule the actor from the **Schedules** tab and turn on **Incremental mode**. Each run then returns only new, updated, reappeared or expired jobs for the same search, and unchanged ones are not billed unless you turn on **Emit unchanged**.

#### What's the difference between Resume and Incremental mode?

**Resume from a prior run** continues ONE interrupted walk-all pull using a pasted run or dataset ID: a one-time continuation that skips every job that run already saved (pair it with a search URL carrying `&page=N`, as described above, so the walk does not stop on already-saved first pages). **Incremental mode** remembers a search's baseline itself between scheduled runs, so a recurring run only returns what changed, with no ID to paste. They solve different problems and can be combined to seed Incremental mode's baseline from a prior resume pull.

#### Why did a resumed run, or my second search URL, return far fewer jobs than expected?

A search stops as soon as it reads a page holding only jobs the run has already seen, and that includes jobs from another search URL in the same run and jobs loaded from a resumed run. So a resumed walk-all pull can stop on page 1 with zero jobs, and a second search URL that overlaps the first can stop before its own new jobs. Run overlapping search URLs as separate runs, and resume a large pull through a search URL with `&page=N` as described under **Resume and recurring updates** above. A job-posting URL pasted next to searches can also be dropped if one of those searches skipped that job because of its share of Max jobs; paste posting URLs in their own run.

#### Why did my run fail instead of returning an empty dataset?

If EdJoin refuses every request a run makes, the run stops with a clear message so "no jobs match this search" is never confused with "nothing could be read." Run it again in a few minutes. A search that genuinely comes back empty still succeeds and returns a note instead of a job.

#### Can I use it with AI agents or MCP?

Yes. Call it from any Apify integration or MCP client, and use the connector field to push results into Notion, Linear or Airtable.

### 🔗 Want more jobs data?

Pair this actor with these related scrapers from the same team:

<table>
<tr><td>🧩 <a href="https://apify.com/abotapi/naukri-com-scraper"><b>Naukri.com Scraper</b></a><br>Scrape Naukri.com jobs by keyword or URL. Extract job title, company, skills, experience...</td><td>💼 <a href="https://apify.com/abotapi/irishjobs-ie"><b>IrishJobs.ie Scraper</b></a><br>Scrape job listings from IrishJobs.ie by keyword, location, job type, salary, recency, or...</td></tr>
<tr><td>💼 <a href="https://apify.com/abotapi/jobs-cz-scraper"><b>Jobs.cz</b></a><br>Scrape Czech job listings from Jobs.cz. Search builder (keyword, location, field, salary...</td><td>💼 <a href="https://apify.com/abotapi/careerbuilder-scraper"><b>CareerBuilder Scraper</b></a><br>Scrape CareerBuilder.com job listings by search or URL. Returns title, company profile...</td></tr>
<tr><td>💼 <a href="https://apify.com/abotapi/hh-ru-jobs-scraper"><b>HH.ru Jobs Scraper</b></a><br>Scrape HH.ru job listings with 50+ structured fields. Search by filters or URLs and...</td><td>💼 <a href="https://apify.com/abotapi/hiring-cafe-scraper"><b>Hiring.Cafe Jobs</b></a><br>Unlock powerful Hiring.Cafe job data extraction. Use advanced filters or URLs to get 100+...</td></tr>
</table>

👉 [Browse all abotapi scrapers](https://apify.com/abotapi)

### 💬 Support

- 🐞 **Found a bug or a missing field?** Open a ticket on the [Issues tab](https://apify.com/abotapi/edjoin-scraper/issues/open). We usually reply within hours.
- 🛠️ **Need another site, extra fields or a private build?** Email <contact@abotapi.com>.
- ⭐ **Enjoying it?** A quick review on the actor page helps other users find it.

# Actor input Schema

## `mode` (type: `string`):

search = enter keyword, location and filters. url = paste EdJoin search or job-posting URLs.

## `keywords` (type: `string`):

Job title, keyword or organization to search for, e.g. 'teacher', 'principal', 'speech pathologist'. Leave empty to list everything.

## `location` (type: `string`):

Free-text location to narrow the search, e.g. 'Los Angeles', 'San Diego', a county or a ZIP. Leave empty for statewide / all.

## `jobTypeIds` (type: `array`):

Restrict to specific EdJoin job types by numeric ID (see the jobTypeId field in any output record). Example: 4 = Teacher - Middle Grades, 9 = Teacher - Special Education, 23 = Teacher Assistant / Aide. Multiple IDs are OR-combined.

## `categoryId` (type: `integer`):

Restrict to one EdJoin category by numeric ID (see categoryId in output). 1 = Certificated, 2 = Classified, 3 = Management, 4 = Other. Leave empty for all.

## `stateId` (type: `integer`):

Retired: the live source no longer honors a state filter (every value returns the unfiltered total), so this value is ignored. Saved tasks keep working.

## `onlineApplicationOnly` (type: `boolean`):

ON: only return jobs that accept an EdJoin online application. OFF / unset: include all jobs.

## `postedWithinDays` (type: `integer`):

Only jobs posted within the last N days. 0 = no date limit.

## `urls` (type: `array`):

EdJoin search URLs (/Home/Jobs?...) or job-posting URLs (/Home/JobPosting/<id>, /Home/DistrictJobPosting/<id>). Multi-URL supported. Also accepted under the name startUrls. Left untouched, url mode scrapes the 8 example URLs listed here (one distinct target each; the non-www host, legacy /Home/DistrictJobPosting/<id> and extra-query-string variants of a posting URL are also accepted but resolve to the same posting, so they are not listed).

## `sortBy` (type: `string`):

Order of results. Newest first is the EdJoin default.

## `fetchDetails` (type: `boolean`):

ON: each job gets contact name + email, full street address, application deadline, length of work year, education + experience requirements, skills, job summary, full description, employer logo, offsite application link and attachment (one extra request per job). OFF: faster, listing-card fields only (still ~35 fields).

## `maxListings` (type: `integer`):

Primary limit: overall cap on jobs across all searches/URLs. The run stops as soon as this many jobs are collected. Set to 0 for unlimited (then Max pages becomes the ceiling).

## `maxPages` (type: `integer`):

0 (default) means walk the whole catalogue for each search: pagination stops naturally once a page comes back empty or repeats jobs already collected, or sooner once Max jobs is reached. Set a number only if you want an explicit page cap below that natural stop.

## `proxy` (type: `object`):

Datacenter (the default) is the cheapest and is sufficient for EdJoin. Switch to Residential only if you see rejections.

## `maxResidentialRequests` (type: `integer`):

Hard cap on how many requests may use residential proxy in one run. When the cap is reached the run downgrades to the backup pool / datacenter so a flapping site cannot silently run up residential cost. Only relevant if you enable Residential proxy above.

## `resumeFromRunId` (type: `string`):

Optional. Paste a previous run ID or dataset ID to continue a large walk-all pull: jobs already saved by that run are loaded before scraping starts and skipped, so this run only appends new jobs. Leave empty for a normal fresh run. Distinct from Incremental mode below, which tracks a RECURRING search rather than replaying one run.

## `incrementalMode` (type: `boolean`):

ON: remembers this search/URL set's results between scheduled runs and classifies each job as NEW / UPDATED / UNCHANGED / REAPPEARED / EXPIRED, so a recurring run only bills for what changed. OFF (default): every run is a plain fresh pull, same as today. Each job's fields are compared with its previous scrape (every output field except scrapedAt and any you list under 'Ignore fields when detecting changes'; detail-only fields are skipped on a run where the detail page did not load). Unrelated to 'Resume from a prior run' above (that continues one interrupted run; this tracks a recurring search).

## `stateKey` (type: `string`):

Optional label for the incremental-mode baseline. Leave empty to derive it automatically from your search/URL settings (recommended). Set your own text only if you want to deliberately share one baseline across otherwise-different search settings, or split one search into several independently-tracked campaigns. Only applies when Incremental mode is ON.

## `emitUnchanged` (type: `boolean`):

ON: also return jobs that have not changed since the last run (changeType UNCHANGED) — returns and BILLS extra rows every run. OFF (default): unchanged jobs are skipped; only NEW/UPDATED/REAPPEARED/EXPIRED are returned. Only applies when Incremental mode is ON.

## `emitExpired` (type: `boolean`):

ON: also return a row for each previously-tracked job no longer found (changeType EXPIRED) — returns and BILLS extra rows every run. Only emitted after a run that scanned the full tracked search, with no cap or resume in effect. OFF (default): expired jobs are not reported. Only applies when Incremental mode is ON.

## `ignoreFieldsForChanges` (type: `array`):

Field names that must NOT count as a change when Incremental mode compares a job with its previous scrape (for example applicationDeadline or numberOpenings). Matches the name at any depth, or an exact dotted path. Ignored fields are still returned in the output; they only stop a job being flagged UPDATED. Leave empty to compare every field except scrapedAt. Only applies when Incremental mode is ON.

## `mcpConnectors` (type: `array`):

Optionally send the scraped results into the apps you already use, via Model Context Protocol (MCP) connectors. Authorize a connector once under Apify → Settings → Integrations, then select it here. The connector receives a condensed, human-readable summary per item (title + key fields), not the full JSON — the complete record stays in the dataset. Leave empty to skip. Supported: Notion (https://mcp.notion.com/mcp), Linear (https://mcp.linear.app/sse), Airtable (https://mcp.airtable.com/mcp), Apify (https://mcp.apify.com).

## `notionParentPageUrl` (type: `string`):

URL (or id) of the Notion page under which item pages are created. Required to enable the Notion export; ignored by other connectors.

## `maxNotifyListings` (type: `integer`):

Cap on items written to each connector per run. Does not affect the dataset.

## Actor input object example

```json
{
  "mode": "search",
  "keywords": "teacher",
  "onlineApplicationOnly": false,
  "postedWithinDays": 0,
  "urls": [
    "https://www.edjoin.org/Home/Jobs?keywords=teacher&location=&searchType=all",
    "https://www.edjoin.org/Home/JobPosting/2287270",
    "https://www.edjoin.org/Home/Jobs?keywords=librarian&page=2&searchType=all",
    "https://www.edjoin.org/Home/Jobs?keywords=teacher&catID=1&jobTypes=4&days=7&onlineApps=true&order=asc&searchType=all",
    "https://www.edjoin.org/Home/Jobs?location=Los+Angeles&searchType=all",
    "https://www.edjoin.org/Home/Jobs",
    "https://www.edjoin.org/Home/JobPosting/2287330",
    "https://www.edjoin.org/Home/Jobs?districtID=3672&searchType=all"
  ],
  "sortBy": "newest",
  "fetchDetails": true,
  "maxListings": 20,
  "maxPages": 0,
  "proxy": {
    "useApifyProxy": true
  },
  "maxResidentialRequests": 0,
  "incrementalMode": false,
  "emitUnchanged": false,
  "emitExpired": false,
  "ignoreFieldsForChanges": [],
  "maxNotifyListings": 50
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

## `detail` (type: `string`):

No description

## `changes` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "mode": "search",
    "keywords": "teacher",
    "urls": [
        "https://www.edjoin.org/Home/Jobs?keywords=teacher&location=&searchType=all",
        "https://www.edjoin.org/Home/JobPosting/2287270",
        "https://www.edjoin.org/Home/Jobs?keywords=librarian&page=2&searchType=all",
        "https://www.edjoin.org/Home/Jobs?keywords=teacher&catID=1&jobTypes=4&days=7&onlineApps=true&order=asc&searchType=all",
        "https://www.edjoin.org/Home/Jobs?location=Los+Angeles&searchType=all",
        "https://www.edjoin.org/Home/Jobs",
        "https://www.edjoin.org/Home/JobPosting/2287330",
        "https://www.edjoin.org/Home/Jobs?districtID=3672&searchType=all"
    ],
    "proxy": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("abotapi/edjoin-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "mode": "search",
    "keywords": "teacher",
    "urls": [
        "https://www.edjoin.org/Home/Jobs?keywords=teacher&location=&searchType=all",
        "https://www.edjoin.org/Home/JobPosting/2287270",
        "https://www.edjoin.org/Home/Jobs?keywords=librarian&page=2&searchType=all",
        "https://www.edjoin.org/Home/Jobs?keywords=teacher&catID=1&jobTypes=4&days=7&onlineApps=true&order=asc&searchType=all",
        "https://www.edjoin.org/Home/Jobs?location=Los+Angeles&searchType=all",
        "https://www.edjoin.org/Home/Jobs",
        "https://www.edjoin.org/Home/JobPosting/2287330",
        "https://www.edjoin.org/Home/Jobs?districtID=3672&searchType=all",
    ],
    "proxy": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("abotapi/edjoin-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "mode": "search",
  "keywords": "teacher",
  "urls": [
    "https://www.edjoin.org/Home/Jobs?keywords=teacher&location=&searchType=all",
    "https://www.edjoin.org/Home/JobPosting/2287270",
    "https://www.edjoin.org/Home/Jobs?keywords=librarian&page=2&searchType=all",
    "https://www.edjoin.org/Home/Jobs?keywords=teacher&catID=1&jobTypes=4&days=7&onlineApps=true&order=asc&searchType=all",
    "https://www.edjoin.org/Home/Jobs?location=Los+Angeles&searchType=all",
    "https://www.edjoin.org/Home/Jobs",
    "https://www.edjoin.org/Home/JobPosting/2287330",
    "https://www.edjoin.org/Home/Jobs?districtID=3672&searchType=all"
  ],
  "proxy": {
    "useApifyProxy": true
  }
}' |
apify call abotapi/edjoin-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,abotapi/edjoin-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/bMVNHphqcifK0IWmf/builds/xGNluyDVgm7gCNlO8/openapi.json
