DEV Community

Cover image for How to monitor Greenhouse and Lever job changes with Apify
Emmanuel Uchenna
Emmanuel Uchenna

Posted on Originally published at eunit.me

How to monitor Greenhouse and Lever job changes with Apify

If you have ever tried to track company job boards using a spreadsheet or a basic web scraper, you know how quickly the data falls apart (as detailed in our breakdown of why Playwright and headless browser scrapers fail in production). Every scheduled refresh spits out hundreds of duplicate rows. A hiring manager fixes a typo in a job title, and your script alerts you to a "brand new role." Worse, a transient network error returns an empty page, and your system flags every active vacancy at the company as filled.

A dependable Greenhouse job change monitor requires a strict operational contract:

  • Track each opening by its persistent source ID rather than guessing by title.
  • Compare observations only when an employer's board fetch is 100% complete.
  • Freeze baseline state automatically if an API request fails, preventing false mass removals.
  • Categorize changes into distinct, actionable events: new, updated, removed-from-board, and no-longer-matches.

In this article, you will set up a reliable Apify hiring monitor workflow using public Greenhouse and Lever endpoints and the ATS Job Change Monitor Actor on the Apify platform. An Applicant Tracking System (ATS) manages hiring pipelines for thousands of tech employers. While broader scrapers (such as our guide on how to scrape LinkedIn job postings with Python) are effective for multi-platform job postings scraping, querying primary ATS endpoints directly eliminates aggregator lag, anti-bot walls, and duplicate records.

We verified this workflow against live employer boards, including Greenhouse setups like Stripe and Lever's public demo board, validating complete board pagination, state preservation during simulated failures, and zero-noise runs on unchanged boards.

How to monitor Greenhouse and Lever job changes with Apify

What this monitor observes

The Actor interacts directly with the public, unauthenticated APIs that ATS providers use to power company career portals. Greenhouse documents these public endpoints in its Job Board API, while Lever details its public endpoints in the Lever postings API repository.

To monitor a company, you only need its public board token (slug):

  • Greenhouse token: The identifier in boards.greenhouse.io/{boardToken} (for example, stripe).
  • Lever token: The site slug in jobs.lever.co/{boardToken} (for example, leverdemo).

You do not need an applicant account, recruiter credentials, or an internal ATS API key. The Actor observes public job postings exactly as prospective applicants see them.

Each record extracted from these endpoints is normalized into a predictable schema:

  • Core identifiers: id (a composite unique key), source_job_id, provider, and board_token.
  • Job metadata: title, job_url, apply_url, location, department, and employment_type.
  • Change tracking: change_type, changed_fields, observed_at, and source timestamps (source_created_at, source_updated_at) when exposed by the ATS.
  • Optional content: Job description text (with configurable character limits).

Missing fields remain null rather than placeholder defaults. This tool observes public listings; it does not collect applicant records, reveal hiring managers, guess salary ranges, or determine whether an employer sponsors visas.

Why a snapshot and a change feed are different

A snapshot and a change feed answer fundamentally different operational questions. When deciding how to monitor job board changes, choosing the right mode determines whether you get an audit of current openings or an event stream of what just happened for competitive intelligence and market research.

Output mode / event Question it answers Ideal use case
Snapshot What matching jobs are currently published on this board right now? Initial data ingestion, directory seeding, and point-in-time hiring audits.
New Which matching job IDs appeared that were not in your saved baseline? Instant alerts for newly published openings, lead generation, and competitor expansion signals.
Updated Which specific fields (such as title, location, or department) changed on an existing job? Tracking job title revisions, compensation updates, or location policy shifts.
Removed from board Which previously tracked job IDs vanished from a confirmed complete board fetch? Detecting closed positions, filled roles, or paused requisitions.
No longer matches Which tracked jobs remain active on the board but no longer meet your filters? Keeping saved baselines clean when refining keywords or team criteria.

Precision in language is critical. An event labeled removed-from-board does not definitively mean a role was filled. An employer might temporarily unpublish a post, pull a requisition, migrate to another ATS, or consolidate listings. Similarly, new means new to your monitored workflow baseline, not necessarily drafted five minutes ago.

Always keep your collection timestamp (observed_at) separate from the employer's publication timestamps (source_created_at or source_updated_at). If an ATS does not expose when a listing was updated, leave the field null. Never substitute your scraping timestamp for the employer's actual publication date.

The workflow architecture

To keep your pipeline dependable and easy to debug, separate the source collection, state comparison, and downstream notification steps:

The workflow architecture

This decoupled architecture enforces two crucial reliability principles:

  1. Fail-safe state preservation: If an employer's board fetch fails, times out, or returns truncated data, comparison stops immediately for that board. The Actor preserves the previous baseline intact. When the employer board recovers on a subsequent run, your feed resumes cleanly without triggering phantom mass deletions.
  2. Delivery decoupling: Writing detected changes to the Apify dataset is separate from pushing data to your external CRM, database, or Slack channel. If an external webhook fails, your dataset records remain safely preserved with their run IDs, allowing you to retry delivery without re-scraping the ATS.

Step 1: Use the ATS Job Change Monitor Actor

First of all, we will use the ATS Job Change Monitor Actor on the Apify platform.

Then click on "Try for free" to get started.

ATS Job Change Monitor Actor

Step 2: Choose a small employer watchlist

Start with a focused watchlist of companies you actively want to monitor. A balanced initial test pairs one Greenhouse board with one Lever board:

  1. Open the employer's public careers page in your browser.
  2. Locate the board slug in the URL:    - For Greenhouse: https://boards.greenhouse.io/stripe yields the token stripe.    - For Lever: https://jobs.lever.co/leverdemo yields the token leverdemo.
  3. Verify that the token loads valid public jobs before configuring automated monitoring.

The optional companyLabel parameter is your custom display name for reporting. It is not an identity validated by the ATS.

The Actor supports between 1 and 10 employer boards per run. Begin with two or three verified boards so you can cross-reference the extracted fields directly against the live career pages before adding broad keyword filters.

If an employer does not use Greenhouse or Lever, omit them from this configuration. Avoid guessing slugs, as invalid tokens produce source errors that complicate your monitoring logs.

Step 3: Inspect a public source response

Before scheduling recurring runs, inspect the raw data the ATS returns. You can verify how the Lever postings API handles data extraction using this quick Python script:

import httpx

endpoint = "https://api.lever.co/v0/postings/leverdemo"
with httpx.Client(timeout=30) as client:
 response = client.get(endpoint, params={"mode": "json", "skip": 0, "limit": 2})
 response.raise_for_status()
 jobs = response.json()
    for job in jobs:
        print(job["id"], job["text"], job["hostedUrl"])
Enter fullscreen mode Exit fullscreen mode

While this snippet requests just two records to preview the schema, a complete monitoring run cannot stop at two. Lever paginates results using skip and limit, and the Actor automatically follows pagination until the entire catalog is retrieved before running comparisons.

For Greenhouse boards, the Actor fetches the full job listing and compares the number of received records against any summary totals provided by the API. If an ATS endpoint responds with data exceeding safety limits (such as a 10 MB payload cap or 5,000 jobs per board), the Actor flags the fetch as incomplete rather than risking an incorrect baseline comparison.

Step 4: Run a bounded snapshot

To verify how the Actor processes and normalizes real records, execute a small snapshot run in Apify Console:

{
  "boards": [
 {
      "provider": "lever",
      "boardToken": "leverdemo",
      "companyLabel": "Lever demo"
 }
 ],
  "mode": "snapshot",
  "maxItems": 2,
  "includeDescriptions": false
}
Enter fullscreen mode Exit fullscreen mode

Notice the distinction between data limits:

  • maxItems sets a cap on the number of result records saved to the default dataset.
  • It does not truncate the source fetch. The Actor still inspects the full board so that completeness rules remain intact.

Once the run finishes, check the default Dataset tab, along with the RUN-SUMMARY and ERRORS records in the Key-value store tab:

  • Dataset: Contains clean records with stable IDs, titles, and valid job URLs.
  • RUN-SUMMARY: Confirms total jobs observed, active board counts, and execution metrics.
  • ERRORS: Documents any non-fatal network or parsing problems without generating billable result records.

Each board is protected by sensible bounds: a 90-second fetch timeout, a 5,000-job per-board ceiling, and a 10 MB response limit. These safeguards prevent unexpected runaway runs on massive enterprise boards.

Step 5: Create a reusable baseline

To track changes across time, set mode to changes and define a stateStoreName. This tells the Actor which named Key-Value store to use for preserving baseline snapshots between runs.

When monitoring an active company for the first time, you usually do not want 200 immediate notifications announcing every pre-existing vacancy as "new." Use baselineOnly: true to perform a silent initial sync:

{
  "boards": [{"provider": "lever", "boardToken": "leverdemo"}],
  "mode": "changes",
  "stateStoreName": "my-employer-watchlist",
  "baselineOnly": true,
  "includeDescriptions": false,
  "maxItems": 100
}
Enter fullscreen mode Exit fullscreen mode

With baselineOnly: true, the Actor ingests and saves the complete matching catalog into your named Key-Value store without emitting rows to the dataset. You establish a clean starting baseline without triggering notification noise or custom result charges.

The state key generated internally incorporates the provider, board token, active keyword filters, and description settings. If you later change these core filters, the Actor treats the run as a new comparison scope to avoid invalid diffs.

Step 6: Compare the next observation

Once your baseline is established, set baselineOnly to false for all subsequent runs using the same stateStoreName.

If no openings were created, modified, or removed since the previous run, a successful execution emits zero rows. In job change monitoring, zero emitted rows is an ideal outcome: it proves the board was checked completely and produced no false positives.

When changes do occur, the Actor outputs specific event types:

  • Title or attribute updates: Emits an updated record with changed_fields detailing what was modified (e.g., ["title", "location"]).
  • Description modifications: When description tracking is enabled, the Actor checks a cryptographic hash of the normalized description text. It catches edits to job requirements even if you truncate long descriptions to save storage.
  • Partial result advancement: If your maxItems cap is reached during a high-activity run, state advances only for the events successfully written to the dataset. Unemitted changes are preserved and processed during the next run.
  • At-least-once delivery: State updates and dataset writes occur in distinct steps. In the rare event of a network interruption between them, a change might be re-emitted on the subsequent run. Downstream systems should deduplicate events using the composite id and observed_at fields.

Step 7: Add filters deliberately

Refine your feed using case-insensitive keyword filters for job titles and locations:

{
  "boards": [{"provider": "greenhouse", "boardToken": "YOUR_VERIFIED_TOKEN"}],
  "mode": "changes",
  "stateStoreName": "engineering-watchlist",
  "titleKeywords": ["engineer", "developer"],
  "locationKeywords": [],
  "includeDescriptions": false,
  "includeRemoved": true,
  "maxItems": 100
}
Enter fullscreen mode Exit fullscreen mode

Keep your filter strategy focused:

  • Title filters: Match substrings within the role title. A filter for engineer catches both "Frontend Engineer" and "Engineering Manager."
  • Location filters: Evaluate the raw location text provided by the ATS. Keep in mind that labels like "Remote" or "US Remote" don't confirm legal work eligibility or state-specific tax compliance.
  • Managing removals (includeRemoved): When set to true, roles that disappear from complete board fetches emit removed-from-board events. If you set this to false, removed jobs are silently retired from baseline state without cluttering your destination feed.

Step 8: Automate with the Apify client

You can automate recurring checks and build reliable workflow automation using the Apify SDK. Store your API token and Actor ID in environment variables rather than hardcoding them in source files:

from datetime import timedelta
import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor(os.environ["ATS_ACTOR_ID"]).call(run_input={
    "boards": [{"provider": "lever", "boardToken": "leverdemo"}],
    "mode": "changes",
    "stateStoreName": "my-employer-watchlist",
    "baselineOnly": False,
    "maxItems": 100
}, run_timeout=timedelta(seconds=120), memory_mbytes=512, max_total_charge_usd=0.25)
if run is None:
    raise RuntimeError("Actor run was not found.")

if run.status != "SUCCEEDED":
    raise RuntimeError("Inspect the failed run before using its observations")
for change in client.dataset(run.default_dataset_id).iterate_items():
    print(change["change_type"], change["title"], change["job_url"])
Enter fullscreen mode Exit fullscreen mode

Scheduling tips

  • Choose a practical cadence: For recruitment research or competitive analysis, once or twice daily is typically ideal. Tech companies rarely publish or withdraw job requisitions by the minute. Running every hour increases compute overhead without surfacing meaningful hiring velocity.
  • Prevent run collisions: Avoid scheduling overlapping runs that target the same stateStoreName. Ensure previous runs finish before starting the next cycle.

Step 9: Deliver a traceable report

When sending change feeds downstream to Slack (similar to our architecture for automated lead routing with Slack alerts), Notion (as explored in our guides on building automated pipelines connected to Notion and automating content into Notion), Airtable, or a database, structure your records around traceable facts rather than assumptions:

  1. Maintain primary keys: Use the composite id (or provider + source_job_id) as your table's unique record identifier.
  2. Preserve live links: Always include the normalized job_url in notifications so team members can verify the live posting with a single click.
  3. Use accurate status labels: Label departures as "Unpublished from public board" rather than "Candidate hired." If an employer renames a role, display the previous and updated values side by side using changed_fields.
  4. Isolate external sinks: Treat webhook notifications and database synchronizations as independent consumer processes. If a downstream API experiences downtime, your Apify dataset preserves the raw records until your delivery workers recover.

Wrapping up

Building a dependable Greenhouse job change monitor comes down to respecting source data boundaries: tracking persistent IDs, verifying complete board fetches, preserving baseline state during outages, and categorizing changes into unambiguous events.

By combining public Greenhouse and Lever endpoints with the ATS Job Change Monitor on the Apify platform, you can automate clean, noise-free hiring intelligence feeds that your recruiting team, analysts, or job board users can trust.

Start by verifying one or two company board tokens, seed a clean baseline with baselineOnly: true, and test an unchanged follow-up run before connecting downstream automations.

For more guides on production scrapers and web automation, explore our Technical Articles hub, including our hands-on Python web scraping tutorials.

Top comments (2)

Collapse
 
kevinhall profile image
Kevin Hall •

The removed-from-board event is where I'd add one more state: "came back". On LinkedIn backend postings, where a posting stays in the data after it closes, about one employer role in eight was closed and then posted again within 90 days, under a new posting id each time. One 33-person firm posted the same two roles almost every Friday for six months.

Tracking by source id is right for dedup, but it reads each of those as a brand new role. A second key on company plus normalized title, with "the earlier one closed at least three days before this one opened", turns them into one role with a history, and that history is the part a job seeker actually wants. Do you know if Greenhouse keeps the job id when a role is reopened, or does it mint a new one too?

Collapse
 
sid_mishra profile image
Sid Mishra •

The rule to only emit removals after a 100% complete board fetch is the one most monitors skip, so it's good to see it spelled out. Two small additions that have helped me with the same pattern:

  1. Keep a third state next to new/removed for "board fetch incomplete this run". Downstream can then tell "nothing changed" from "we couldn't see", instead of both looking like a quiet day.
  2. Before emitting removed-from-board, require a job to be missing on two consecutive complete fetches. It costs one extra run of latency and filters out postings that blip out for a single fetch while they're being edited or re-published.

And +1 on keeping delivery separate from scraping. Retrying a webhook from the dataset instead of re-fetching the boards saves a lot of pain.