# Greenhouse Jobs Scraper & Change Monitor (`flowmint/greenhouse-jobs-change-monitor`) Actor

Scrape public Greenhouse job boards and track new, updated, removed, and reappearing jobs across runs. Export job details and descriptions for recruitment workflows, hiring analysis, and job alerts. Pay per successful board check, regardless of job count.

- **URL**: https://apify.com/flowmint/greenhouse-jobs-change-monitor.md
- **Developed by:** [Flowmint](https://apify.com/flowmint) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$10.00 / 1,000 board checkeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## Greenhouse Jobs Scraper & Change Monitor

Export public Greenhouse job listings or track how selected boards change across runs. This Greenhouse jobs scraper uses the public job board API, without a browser, Greenhouse login, or candidate data. The job change monitor maintains a complete baseline for each board and emits changes into an Apify dataset.

You supply the boards. This Actor does not discover every company or claim complete coverage of the hiring market. It is an independent product with no official Greenhouse affiliation.

### Three-minute quick start

In Apify Console, enter a board token such as `stripe`, choose **Snapshot**, and start the Actor. Open **Output**, select **Snapshot jobs**, and export JSON or CSV. Read **Run summary** for successful checks and source errors.

For monitoring, choose **Monitor**, enter a stable ID such as `daily-greenhouse`, and run again with that same ID. The first successful check initializes each board. Existing jobs are labeled `initial`; they are not presented as newly published. Later checks can emit `added`, `changed`, `removed`, or `reappeared`. An empty event dataset is a normal result when nothing changed.

Locally, use Node.js 22.14+ or Node.js 24 LTS:

```powershell
npm ci
npm run check
npm start -- examples/snapshot-input.json
npm start -- examples/monitor-input.json
npm start -- examples/monitor-input.json
```

Each direct local invocation creates a separate `storage/datasets/run-<runId>/` and `storage/key_value_stores/run-<runId>/`. The log prints the run ID through the summary and output location. Named monitor stores persist under `storage/key_value_stores/gh-monitor-v1-<hash>/`. Keep the storage directory between monitor runs. On Apify, results and `SUMMARY` use the run's default storages.

### Input

| Field                   | Default                  | Meaning                                                                                      |
| ----------------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
| `boards`                | required                 | 1–100 unique tokens or approved URLs after deduplication; at most 1000 raw entries           |
| `mode`                  | `snapshot`               | Current job records or persistent monitoring events                                          |
| `monitorId`             | required in monitor mode | 1–64 safe letters/digits/underscores/hyphens; starts with a letter or digit                  |
| `titleKeywords`         | `[]`                     | Case-insensitive substring phrases; OR within the list                                       |
| `locationKeywords`      | `[]`                     | Same matching rule against source location; AND with title filter                            |
| `includeDescription`    | `true`                   | Show full snapshot descriptions or monitor excerpts; comparison always includes descriptions |
| `firstRunOutput`        | `all`                    | `initial` events or `none`, independently for each newly initialized board                   |
| `removalConfirmations`  | `2`                      | 2–5 complete successful checks before removal is confirmed                                   |
| `maxConcurrency`        | `3`                      | 1–5 simultaneous HTTP fetches; commits and charges are sequential                            |
| `requestTimeoutSeconds` | `30`                     | 5–120 seconds per attempt, including response body                                           |

Keyword lists allow up to 100 nonempty phrases, each at most 200 characters. Unknown input fields are rejected. Snapshot mode ignores monitoring-specific behavior and never reads or changes monitor history.

Approved inputs include `stripe`, `https://boards.greenhouse.io/stripe`, `https://boards.greenhouse.io/stripe/jobs/123`, and `https://boards-api.greenhouse.io/v1/boards/stripe/jobs`. Tracking queries and fragments are ignored; even a job URL selects the whole board. Only the constructed HTTPS API endpoint is fetched. Encoded paths, credentials, custom ports, unrelated company domains, regional endpoints, and other hosts, including `job-boards.greenhouse.io`, are outside the verified v1 input allowlist. Tokens are canonicalized to lowercase. `region: "global"` identifies the supported API endpoint; it does not classify a job's geography.

### Output

Snapshot datasets contain only job records. Monitor datasets contain only event records. Errors and totals are stored in `SUMMARY.json` in the default key-value store, using the API record key `SUMMARY`. All boards failing makes the run fail. A mix of successful and source-failing boards preserves good results with a partial-success report. Budget exhaustion stops further checks and is explained in the report.

Job records contain `source`, `boardToken`, `region`, `boardName`, `jobId`, `globalId`, `internalJobId`, `title`, `location`, `departments`, `offices`, `language`, `jobUrl`, optional `descriptionHtml` and `descriptionText`, `sourceUpdatedAt`, `firstSeenAt`, `lastSeenAt`, `fetchedAt`, and `metadata`. Missing nullable values are `null`; missing collections are `[]`. Board names are used only when supplied by the response, otherwise `null`. `sourceUpdatedAt` is an update timestamp, not a publication date. Snapshot `firstSeenAt` and `lastSeenAt` are `null`.

Entity arrays contain `{id, name, location, parentId, childIds}`. Metadata contains `{id, name, valueType, value}`; `value` remains source JSON. There is no inferred salary, seniority, or remote-work status. The Actor does not call the individual-job endpoint to obtain salaries or first-publication dates.

Events contain `eventId`, `eventType`, `observedAt`, `previousObservedAt`, `globalId`, `boardToken`, `region`, `job`, `changedFields`, and `snapshotKey`. The monitor's nested job has a maximum 500-character text excerpt when descriptions are enabled. Full descriptions remain in the unfiltered KV snapshot and monitor baseline. Description diffs contain before/after SHA-256 hashes and readable excerpts of at most 500 characters around the difference. Other diffs contain the actual before/after values.

See [snapshot example](examples/snapshot-output.json) and [change event example](examples/monitor-output.json); both are generated from deterministic fixtures, not customer data. On Console, use the **Monitor events** view for nested event fields. JSON preserves arrays and objects. For CSV, select fields such as `eventType,boardToken,job.title,job.location,eventId`; complex values should be retained as JSON or flattened by your downstream tool. API exports support pagination; do not assume the first response page contains every result.

Large KV records are JSON envelopes with `_format: "greenhouse-json-gzip-v1"`, a checksum, logical byte length, and `gzipBase64`. Download and decode a snapshot or baseline with:

```powershell
node scripts/unpack-record.mjs downloaded-record.json decoded-record.json
```

Small KV records, including `SUMMARY`, remain plain JSON. The dataset itself always contains normal JSON records.

### Monitoring rules

The baseline contains the entire board, independently of output filters. Changing filters does not reclassify existing jobs as new or removed. `changed` is emitted when title, location, departments, offices, description HTML/text, or job URL changes after canonicalization. Arrays and object keys are ordered and incidental whitespace is normalized; numbers and meaningful text are retained. A change to `sourceUpdatedAt`, metadata, language, or internal ID alone does not trigger an event.

`removed` means **no longer listed on the source board**. It does not prove that someone was hired or recruitment permanently ended. Failed checks do not increase absence counters. A return before confirmation resets the counter; a return after confirmation produces `reappeared`. Confirmed tombstones expire after 90 days, on the next complete check; a later return can then be `added`.

Run one active Actor per `monitorId`. Best-effort leases detect common conflicts but are not atomic locks. Use one sequential task/schedule and avoid manual or API runs that overlap it. [Monitoring details](docs/MONITORING.md) cover journals, migration, recovery, retention, and reset.

### Billing and limits

When PPE is enabled, the only paid event is `board-checked`, charged once per unique successfully processed board in a run. A check is billable even if the board is empty, the filters match nothing, or no job changed. Input errors, failed/incomplete fetches, and HTTP retries are not charged. Local runs never issue real charges; private free cloud runs remain uncharged by this adapter.

Results are durably prepared and delivered before charging and committing the baseline. A budget refusal leaves the baseline uncommitted. Delivery is **at least once**; deduplicate events by `eventId`. Dataset, KV, and billing are separate systems. An ambiguous charge stops processing and may require manual review; exactly-once billing and output are not claimed.

The proposed launch price is **$10 per 1000 board checks ($0.01 each)**, pending private cloud cost measurements and approval. At that proposal, 10 boards checked daily for 30 days cost **$3 in event fees**. This is a proposal, not an active paid offering or a measured cloud cost. [Pricing](docs/PRICING.md) separates measurements, estimates, and Console configuration.

Safety ceilings are 20,000 raw jobs and 32 MiB of HTTP response per board; logical journals/state are capped at 48 MiB and compressed stored records at 8 MiB. Exceeding any ceiling makes the board incomplete without advancing its baseline. Lists are never truncated to simulate a complete check. HTML is parsed as data, never executed; consumers should treat exported HTML and URLs as untrusted source content.

### Deployment and responsible use

[Deployment](docs/DEPLOYMENT.md) explains private upload, limited permissions, two sequential cloud tests, and publication. [Validation report](docs/VALIDATION_REPORT.md) records what was actually executed. Use Apify Tasks, Schedules, and its Integrations tab for automation; this Actor sends no emails or arbitrary webhooks itself.

Use source listings for job portals, recruiting research, and market analysis with appropriate attribution and retention. Respect source terms and applicable rights. Descriptions can include publicly provided contact information; restrict access and retain only what your workflow needs. Monitoring stores full descriptions even if `includeDescription=false`. No candidate profiles, applications, or private Harvest API data are collected.

Original project code is MIT licensed. Dependency licenses remain applicable; see [dependency inventory](docs/DEPENDENCY_LICENSES.md).

# Changelog

This Actor's version history is a separate document: https://apify.com/flowmint/greenhouse-jobs-change-monitor/changelog.md

# Actor input Schema

## `boards` (type: `array`):

Required: 1–100 unique board tokens or approved URLs. Examples: stripe, https://boards.greenhouse.io/stripe, https://boards.greenhouse.io/stripe/jobs/123. API URLs on boards-api.greenhouse.io are accepted. Tracking queries are ignored. Other hosts, regional endpoints, encoded paths, and company domains are rejected; no company discovery is performed.

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

Snapshot leaves monitor history untouched. Monitor compares full boards, then applies output filters.

## `monitorId` (type: `string`):

Required in monitor mode. Keep the same ID for later runs. Use 1–64 letters, digits, underscores, or hyphens, beginning with a letter or digit. Each ID has independent persistent history. Run only one active check for each ID.

## `titleKeywords` (type: `array`):

Optional phrases; case-insensitive OR. Title and location filters are combined with AND. Filters do not reduce the monitor baseline.

## `locationKeywords` (type: `array`):

Optional phrases; case-insensitive OR against the source location. No remote-work classification is inferred.

## `includeDescription` (type: `boolean`):

Snapshot: full decoded HTML and readable text. Monitor events: a text excerpt; full descriptions remain in the KV snapshot. Descriptions are always fetched and retained for comparison, even when hidden from output.

## `firstRunOutput` (type: `string`):

Only for each board's first successful monitor check. Initial jobs are existing listings, not newly published jobs.

## `removalConfirmations` (type: `integer`):

Complete successful checks without the job before removed is emitted. Errors do not advance this counter.

## `maxConcurrency` (type: `integer`):

Number of concurrent HTTP board requests. Durable commits and charges are processed sequentially.

## `requestTimeoutSeconds` (type: `integer`):

Timeout per attempt including response body. At most three attempts for transient failures; each retry wait is capped at 30 seconds.

## Actor input object example

```json
{
  "boards": [
    "stripe"
  ],
  "mode": "snapshot",
  "includeDescription": true,
  "firstRunOutput": "all",
  "removalConfirmations": 2,
  "maxConcurrency": 3,
  "requestTimeoutSeconds": 30
}
```

# Actor output Schema

## `results` (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 = {
    "boards": [
        "stripe"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("flowmint/greenhouse-jobs-change-monitor").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 = { "boards": ["stripe"] }

# Run the Actor and wait for it to finish
run = client.actor("flowmint/greenhouse-jobs-change-monitor").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 '{
  "boards": [
    "stripe"
  ]
}' |
apify call flowmint/greenhouse-jobs-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,flowmint/greenhouse-jobs-change-monitor"
        }
    }
}
```

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/OjfBjZFJlZUujss4w/builds/FmyNU9i6xKBDWwkHb/openapi.json
