# Greenhouse, Lever & Ashby Jobs Scraper + Change Monitor (`apterlabs/job-posting-change-monitor`) Actor

Scrape company job boards to JSON or CSV, then track new listings, edits and confirmed removals. Exact before/after values and persistent history help keep job databases current. Includes an n8n/Google Sheets starter. $0.03 per successful board check, with platform usage included.

- **URL**: https://apify.com/apterlabs/job-posting-change-monitor.md
- **Developed by:** [Apter Enterprises](https://apify.com/apterlabs) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$30.00 / 1,000 successful board checks

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, Lever & Ashby Jobs Scraper + Change Monitor

Keep a job database current with public Greenhouse, Lever and Ashby postings. The first scan exports an inventory; later scans return newly observed listings, exact edits and confirmed removals. Export JSON or CSV and retain before/after values for title, location, reported salary, workplace type and description changes.

Designed for recruiting-data workflows, job-board maintenance and company watchlists. Uses the providers' public APIs. No employer login, candidate data, proxy configuration or external AI key is needed.

### Try it with three companies

[Open the Sentry, PostHog & Clerk example](https://apify.com/apterlabs/job-posting-change-monitor/examples/update-a-dev-tools-job-board-sentry-posthog-clerk) to start with three company boards already filled in. Choose **Try for free** on that page to copy the example into your Apify account, review the inputs, then start your run. No spreadsheet or n8n setup is needed.

**Three successful board checks cost $0.09 in total**, including platform usage. First and unchanged checks count; available Apify credits may cover the charge.

Your first scan exports current jobs as `baseline` records plus a `board_checked` receipt for each successful board. Export the dataset as JSON or CSV. Reuse the same monitor name on later runs to see changes; if nothing changed, the receipts still confirm the checks completed.

### Quick start with your own boards

1. Paste the employer's public Greenhouse, Lever or Ashby board URL. Existing `provider:board-slug` identifiers also work.
2. Choose a monitor name and run the Actor. The first successful scan saves a baseline.
3. Reuse that monitor name on later runs. The output then contains newly observed listings and changes.
4. Save an Apify task and schedule it daily if you want ongoing monitoring.

```json
{
  "boards": ["https://job-boards.greenhouse.io/stripe", "https://jobs.ashbyhq.com/ramp"],
  "monitorName": "company-watchlist",
  "includeBaseline": true,
  "includeCheckRecords": true
}
```

Supported URL hosts are `boards.greenhouse.io`, `job-boards.greenhouse.io`, `jobs.lever.co`, `jobs.eu.lever.co` and `jobs.ashbyhq.com`. A supported job-detail link selects its **whole company board**, not only that job. Custom careers domains and Greenhouse embed links are not supported. Tracking query parameters are ignored. Identifiers such as `greenhouse:stripe`, `lever:company`, `lever-eu:company` and `ashby:ramp` remain supported. Switching between a URL and its matching identifier preserves history with the same monitor name. Do not include both versions in one run.

This Actor monitors boards you supply; it does not discover employers or search a global jobs database. The optional n8n starter below still uses `provider:board-slug` identifiers in its configuration.

### Keep an existing job board current

Use `(provider, board, job_id)` as the source record key. On the first run, import `baseline` records. On subsequent runs, upsert `newly_observed`, `changed` and `reappeared` records using their `job` object; mark a record inactive only after `no_longer_listed`. Deduplicate deliveries with `event_id` and keep the same monitor name. Never interpret an empty changes dataset as an empty company board.

Save your input as an Apify task and schedule it daily after verifying a manual run. A failed source check preserves its previous history and cannot confirm removals. At $0.03 per successful board check, 10 company boards checked daily for 30 days cost $9; first and unchanged checks count, with platform usage included. Any external database or integration service costs are separate.

### Recruiter watchlist starter

**Follow the companies your recruiting desk cares about.** Use the free n8n starter below to deliver backend, platform, infrastructure, DevOps and SRE listing changes to Google Sheets. Every matching event keeps a source URL and observation date. A separate Runs tab shows successful checks, failures and incomplete exports.

A live demonstration on **October 8, 2026 (Eastern time)** checked 25 public company boards containing 3,077 listing records. Its title filter selected **158 initial records across 21 companies**. These were an initial inventory, **not 158 newly posted jobs**. A second scan succeeded on all 25 boards and found one legal-role description change, correctly excluded by the engineering filter; there were zero matching changes during that short interval.

Examples from that dated initial inventory; live listings may have changed:

| Company | Public posting | Source location |
|---|---|---|
| Ramp | [Software Engineer, Data Platform](https://jobs.ashbyhq.com/ramp/bca0346c-b843-4795-96df-6091f51e421b) | New York, NY (HQ) |
| Vanta | [Backend Senior Software Engineer, Identity](https://jobs.ashbyhq.com/vanta/470689af-bbce-4c96-818e-408bf4a6c8e5) | Remote U.S. |
| Figma | [Data Platform Engineer](https://boards.greenhouse.io/figma/jobs/6201407004?gh_jid=6201407004) | San Francisco, CA • New York, NY • United States |
| Modal | [Member of Technical Staff - Platform Engineering](https://jobs.ashbyhq.com/modal/84467a68-6876-4730-9d80-6c6f3d0c2d71) | New York |
| Render | [Software Engineer, Infrastructure (all levels)](https://jobs.ashbyhq.com/render/d52dc923-1641-4386-b8ba-5c15e1d7028d) | Remote: Canada | Remote: United States |

This watchlist covers global locations, not only US roles. Job IDs are source records and may include overlapping requisitions or multiple locations. Relevant jobs with generic titles can be missed. A listing does not prove the employer wants an agency or has a new hiring budget.

#### Cost and setup

The starter workflow has no separate license charge. At the current **$0.03 per successful board check**, 25 companies checked daily cost **$22.50 over 30 days** in JobDelta usage, including platform usage for this Actor. Your n8n plan, taxes and other services are separate. First and unchanged successful scans count.

1. Create a **native Google Sheets** spreadsheet with tabs named `Signals` and `Runs`. Paste the two header lines below into cell A1 of their respective tabs. If the paste stays in one cell, use **Data → Split text to columns → Comma**. Keep the exact column names and order.
2. Copy the JSON from **Importable n8n workflow** below into a `.json` file and import it into n8n. The workflow uses only built-in nodes and is inactive on import.
3. Edit **Configure watchlist**: set your spreadsheet ID, choose 1–25 supported board identifiers, and keep a stable `monitorName`. Replace the illustrative company list with your actual target accounts. The maximum run charge defaults to $0.75.
4. Select your Google Sheets OAuth credential in **Check spreadsheet headers** and the three Sheets nodes. Your Google account needs edit access to the spreadsheet.
5. In all five Apify HTTP nodes, select an HTTP Header Auth credential with header name `Authorization` and value `Bearer YOUR_APIFY_TOKEN`. Enter your own token in n8n's credential screen; never put it in a URL, spreadsheet or shared workflow JSON.
6. Run manually and check `Runs`. `COMPLETE` means the export succeeded. By default, the first inventory is suppressed from `Signals`; set `includeBaseline` to `true` before the first run if you want it. The Actor always stores baseline events for later replay.
7. After verifying your integration, activate the workflow for daily checks at **08:00 America/New_York**. Keep one workflow writer per sheet and avoid concurrent manual runs. Do not also schedule the same monitor separately in Apify.

**Signals headers**

```csv
event_id,run_id,observed_at,signal,company,board,title,locations,source_url,first_seen_at,source_published_at,changed_fields,change_note,notice
```

**Runs headers**

```csv
run_id,status,started_at,finished_at,successful_boards,failed_or_skipped_boards,charged_board_checks,dataset_item_count,matched_signals,export_status,notes,run_url
```

#### Filtering and recovery

Title inclusion/exclusion keywords use case-insensitive word boundaries; a title must also contain engineer, engineering, developer or architect. Optional location keywords match literal text. Empty location keywords mean all locations. Edits that move a role out of scope are retained if the previous title/location matched. Spreadsheet change text is excerpted; full before/after values stay in the saved Apify dataset while retained.

The workflow validates spreadsheet headers before a billable scan, paginates the dataset and checks its final item count. It upserts job events by `event_id` and scan receipts by `run_id`. Zero matching signals still create a Runs receipt. A partial source failure saves available matches, marks `COMPLETE_WITH_SOURCE_ISSUES`, then flags the n8n execution for attention. `PENDING` means spreadsheet export has not completed.

**If export fails, replay the saved run.** Pause the schedule, find the Apify run ID in the n8n execution or Apify console, put it in `replayRunId`, and rerun. This reads the saved dataset without starting another board scan. Clear the ID after recovery. If the run-start request times out, check Apify before retrying: a run might already exist. Monitoring state can advance before Sheets writes finish, so starting a new scan is not a substitute for replaying the missing export.

Keep headers and stable IDs intact. Serial upserts are not an atomic lock against concurrent writers. Recover before your account's run-data retention expires. Filter changes do not automatically reclassify older spreadsheet rows. The workflow caps exports at 100,000 records; the Actor's board and state-size limits still apply. Split a large watchlist if its state reaches the 32 MiB limit.

**Validation status:** 22 local checks passed for filtering, pagination counts, source failures and recovery configuration. Both 25-board source scans succeeded. The complete n8n/Google OAuth/Sheets path has not yet been verified against a customer account; treat this as an integration starter and test a manual execution before scheduling.

References: [n8n workflow import](https://docs.n8n.io/workflows/export-import/), [Google Sheets operations](https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlesheets/sheet-operations/), [HTTP Request pagination](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.httprequest/), [Apify run status](https://docs.apify.com/api/v2/actor-run-get), [dataset export](https://docs.apify.com/api/v2/dataset-items-get).

### Outputs

The default dataset contains observed job events and, when `includeCheckRecords` is enabled, one `board_checked` receipt per successful scan. JSON exports preserve the complete nested before/after values. `SUMMARY` in the run's key-value store gives board counts, errors, pending absences and charged checks. `EVENTS` contains only job events and never includes check receipts.

| Event | Interpretation |
|---|---|
| `baseline` | Present at the first scan. These are not necessarily newly posted jobs. |
| `newly_observed` | A job ID first seen after a baseline exists. |
| `changed` | One or more monitored fields changed; `changes` contains old and new values. |
| `no_longer_listed` | Repeated successful scans confirmed that a listing is absent. The hiring outcome is unknown. |
| `reappeared` | A previously confirmed missing job ID is listed again. |
| `board_checked` | Optional scan receipt with listing and job-event counts. This is not a job change or payment receipt. |

The example form enables check receipts so an unchanged scan still has useful output. Receipts have `job_id: null`, `listed_jobs` and `job_events`; their `source_url` is the scanned public API endpoint. Set `includeCheckRecords` to `false` for a job-events-only dataset, which can be empty when nothing changed. API calls that omit this option default to `false`. Failed or budget-skipped boards do not receive successful-check receipts; inspect `SUMMARY` for their errors. The local CLI always exports job events only.

Illustrative change record — **synthetic values, not a real employer event**:

```json
{
  "event_type": "changed",
  "provider": "ashby",
  "board": "synthetic-example",
  "title": "Senior Software Engineer",
  "changed_fields": ["title", "workplace_type"],
  "changes": {
    "title": {"before": "Software Engineer", "after": "Senior Software Engineer"},
    "workplace_type": {"before": "Hybrid", "after": "Remote"}
  }
}
```

Each real job-event record also includes `event_id`, `job_id`, `observed_at`, `previous_observed_at`, `first_seen_at`, `source_url`, `source_endpoint` and a current job summary.

### Reported salary, not estimated salary

Lever and Ashby structured compensation retains the source's currencies, periods and tiers. Missing compensation stays `null`. Greenhouse does not provide a universal structured salary field through this endpoint, so its salary text is covered by description changes, not extracted salary amounts. Ashby postings or compensation explicitly hidden from public display are excluded.

### History and missing listings

The same monitor name reuses a named state store in your Apify account. Different names create independent monitors. An optional state-store picker lets you reuse an existing JobDelta state store. The Actor uses limited permissions and a queue lock to serialize runs sharing the same monitor.

By default, a listing must be absent from two successful checks spanning at least six hours before `no_longer_listed` is emitted. If more than half of a board with at least ten active listings disappears, the requirements increase to at least three checks spanning 24 hours. A failed fetch or rejected partial response never advances that board's history.

These safeguards reduce false disappearance alerts; they cannot prove that an upstream API returned every available job. A job disappearing does not establish that it was filled, canceled or removed permanently.

### Billing unit

The unit is **one successfully checked company board**, at the price shown on the Store page. The first baseline scan counts. An unchanged successful scan counts. A failed source check does not produce a `board-check` charge. Job and change counts do not add separate event charges. Set the run's maximum charge to cap paid checks; boards beyond it are skipped and reported in `SUMMARY`.

Successful checks in an otherwise partially failed run may be charged and their results remain available. Inspect the run status and `SUMMARY` before automatically retrying a mixed-success batch.

### Limits and interpretation

- Up to 25 boards per run, 3,000 jobs per board by default, configurable up to 5,000. Larger watchlists can use multiple monitors.
- Size limits are 20 MiB per response, 32 MiB received per board, 100,000 characters per description and 32 MiB of monitor state. Exceeding them fails that check instead of silently truncating it.
- Whitespace, HTML presentation, location order and compensation component IDs are normalized. Formatting-only edits are not events.
- Source job IDs define identity. A repost under a new ID is a newly observed listing. No cross-board deduplication or hiring-intent scoring is applied.
- Observation times reflect this monitor's scans. Source publication dates are preserved separately and may represent republication.
- Confirmed missing listings remain in history for 90 days. Reappearance after that window is treated as newly observed.
- A crash can leave partial output or replay events on retry. Deduplicate by `event_id`. Deleting the named state store resets your baseline.
- No historic changes are available from before your first scan. This Actor does not send email, apply for jobs or access private applicant records.

Independent tool using the [Greenhouse](https://docs.greenhouse.io/job-board.html), [Lever](https://github.com/lever/postings-api) and [Ashby](https://developers.ashbyhq.com/docs/public-job-posting-api) public job-board APIs. No affiliation with the providers or monitored employers is implied.

### Importable n8n workflow

Workflow revision: recruiter-starter-1. Copy the entire JSON code block into `jobdelta-recruiter-n8n.json`, import it in n8n, then follow the setup instructions above. Credentials are intentionally not embedded. This is an inactive 27-node workflow, not a running service.

```json
{
  "name": "JobDelta — Backend and Infrastructure Hiring Watchlist",
  "nodes": [
    {
      "parameters": {
        "content": "## JobDelta → recruiter watchlist\n1. Import the blank workbook into Google Sheets. Keep Signals / Runs headers.\n2. Configure the sheet ID and watchlist.\n3. Select Google Sheets credentials in the preflight and three Sheets nodes.\n4. Select an HTTP Header Auth credential (Authorization: Bearer YOUR_APIFY_TOKEN) in every Apify HTTP node. Never paste a token in the URL or code.\n5. Run manually; inspect Runs and Signals. Schedule is inactive until you publish/activate this workflow. Daily at 08:00 America/New_York.\n\n$0.03 per successful board check. Default 25 boards = up to $0.75/run and $22.50/30 daily runs, plus your n8n plan.\n\nInitial inventory is suppressed by default. Set includeBaseline=true BEFORE the first run if wanted. Every board is still baselined.\n\nIf export fails, copy its Apify run ID into replayRunId and rerun. This reads the saved run without starting/billing another scan. Clear replayRunId afterward. Keep one writer per sheet; never run overlapping executions. Read the setup guide for recovery and retention limits.",
        "height": 690,
        "width": 620
      },
      "id": "d4a4b7cb-0433-5e50-8509-7b1f5bd90a00",
      "name": "Read me first",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -740,
        -460
      ]
    },
    {
      "parameters": {},
      "id": "5a3e29fd-1fe2-54bd-8d82-348e9a4b35a7",
      "name": "Run manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "daysInterval": 1,
              "triggerAtHour": 8,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "id": "067d9ae9-17f3-5c4f-ac29-c4c033915f75",
      "name": "Daily at 8am - inactive until enabled",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        180
      ]
    },
    {
      "parameters": {
        "jsCode": "const config = {\n  \"spreadsheetId\": \"PASTE_YOUR_GOOGLE_SPREADSHEET_ID\",\n  \"monitorName\": \"recruiter-backend-v1\",\n  \"boards\": [\n    \"ashby:ramp\",\n    \"ashby:vanta\",\n    \"ashby:linear\",\n    \"ashby:notion\",\n    \"greenhouse:figma\",\n    \"greenhouse:anthropic\",\n    \"greenhouse:brex\",\n    \"greenhouse:mercury\",\n    \"ashby:modal\",\n    \"ashby:supabase\",\n    \"ashby:render\",\n    \"greenhouse:sourcegraph91\",\n    \"ashby:ashby\",\n    \"ashby:coder\",\n    \"ashby:dandy\",\n    \"ashby:middesk\",\n    \"ashby:docker\",\n    \"ashby:clerk\",\n    \"ashby:posthog\",\n    \"ashby:sentry\",\n    \"greenhouse:stripe\",\n    \"ashby:zapier\",\n    \"ashby:benchling\",\n    \"ashby:elevenlabs\",\n    \"ashby:cursor\"\n  ],\n  \"companyNames\": {\n    \"ashby:ramp\": \"Ramp\",\n    \"ashby:vanta\": \"Vanta\",\n    \"ashby:linear\": \"Linear\",\n    \"ashby:notion\": \"Notion\",\n    \"greenhouse:figma\": \"Figma\",\n    \"greenhouse:anthropic\": \"Anthropic\",\n    \"greenhouse:brex\": \"Brex\",\n    \"greenhouse:mercury\": \"Mercury\",\n    \"ashby:modal\": \"Modal\",\n    \"ashby:supabase\": \"Supabase\",\n    \"ashby:render\": \"Render\",\n    \"greenhouse:sourcegraph91\": \"Sourcegraph\",\n    \"ashby:ashby\": \"Ashby\",\n    \"ashby:coder\": \"Coder\",\n    \"ashby:dandy\": \"Dandy\",\n    \"ashby:middesk\": \"Middesk\",\n    \"ashby:docker\": \"Docker\",\n    \"ashby:clerk\": \"Clerk\",\n    \"ashby:posthog\": \"PostHog\",\n    \"ashby:sentry\": \"Sentry\",\n    \"greenhouse:stripe\": \"Stripe\",\n    \"ashby:zapier\": \"Zapier\",\n    \"ashby:benchling\": \"Benchling\",\n    \"ashby:elevenlabs\": \"ElevenLabs\",\n    \"ashby:cursor\": \"Cursor\"\n  },\n  \"titleKeywords\": [\n    \"backend\",\n    \"back-end\",\n    \"platform\",\n    \"infrastructure\",\n    \"site reliability\",\n    \"devops\",\n    \"sre\",\n    \"distributed systems\"\n  ],\n  \"excludeTitleKeywords\": [\n    \"recruit\",\n    \"sales\",\n    \"solutions engineer\",\n    \"solution engineer\",\n    \"customer\",\n    \"technical program\",\n    \"technical writer\",\n    \"marketing\",\n    \"designer\",\n    \"intern\",\n    \"internship\"\n  ],\n  \"locationKeywords\": [],\n  \"includeBaseline\": false,\n  \"maxTotalChargeUsd\": 0.75,\n  \"replayRunId\": \"\"\n};\nif(!/^[A-Za-z0-9_-]{20,}$/.test(config.spreadsheetId) || config.spreadsheetId.startsWith('PASTE_')) throw new Error('Set spreadsheetId before starting a billable scan.');\nif(!/^[a-z0-9][a-z0-9-]{0,39}$/.test(config.monitorName)) throw new Error('Use a stable valid monitorName.');\nif(!Array.isArray(config.boards)||!config.boards.length||config.boards.length>25||new Set(config.boards).size!==config.boards.length) throw new Error('Use 1–25 unique board identifiers.');\nif(!config.boards.every(b=>/^(greenhouse|lever|lever-eu|ashby):[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/.test(b))) throw new Error('Invalid board identifier.');\nif(config.replayRunId && !/^[A-Za-z0-9]{17}$/.test(config.replayRunId)) throw new Error('Invalid replayRunId.');\nif(typeof config.includeBaseline!=='boolean') throw new Error('includeBaseline must be true or false.');\nfor(const key of ['titleKeywords','excludeTitleKeywords','locationKeywords']) if(!Array.isArray(config[key])||config[key].some(x=>typeof x!=='string'||!x.trim()||x.length>100)) throw new Error('Use arrays of nonempty literal keywords.');\nif(!config.titleKeywords.length) throw new Error('Set at least one title keyword.');\nif(!Number.isFinite(config.maxTotalChargeUsd)||config.maxTotalChargeUsd<config.boards.length*0.03-1e-9||config.maxTotalChargeUsd>5) throw new Error('Budget must cover the watchlist and be at most $5/run.');\nconfig.executionStartedAt=Date.now();\nreturn [{json:config}];",
        "mode": "runOnceForAllItems"
      },
      "id": "bfd295e7-953c-5e88-b55a-3a646d89f5ba",
      "name": "Configure watchlist",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://sheets.googleapis.com/v4/spreadsheets/' + $('Configure watchlist').first().json.spreadsheetId + '/values:batchGet?ranges=Signals%211%3A1&ranges=Runs%211%3A1' }}",
        "authentication": "predefinedCredentialType",
        "options": {
          "timeout": 90000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": false,
              "neverError": false
            }
          }
        },
        "nodeCredentialType": "googleSheetsOAuth2Api"
      },
      "id": "ff06a189-2ecc-5072-bddd-d4b1e1908782",
      "name": "Check spreadsheet headers",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        480,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "alwaysOutputData": false
    },
    {
      "parameters": {
        "jsCode": "const ranges=$input.first().json.valueRanges;\nconst expected=[[\"event_id\", \"run_id\", \"observed_at\", \"signal\", \"company\", \"board\", \"title\", \"locations\", \"source_url\", \"first_seen_at\", \"source_published_at\", \"changed_fields\", \"change_note\", \"notice\"], [\"run_id\", \"status\", \"started_at\", \"finished_at\", \"successful_boards\", \"failed_or_skipped_boards\", \"charged_board_checks\", \"dataset_item_count\", \"matched_signals\", \"export_status\", \"notes\", \"run_url\"]];\nif(!Array.isArray(ranges)||ranges.length!==2) throw new Error('Cannot read Signals and Runs tabs. Import the template workbook.');\nfor(let i=0;i<2;i++){ const actual=ranges[i].values?.[0]||[]; if(JSON.stringify(actual)!==JSON.stringify(expected[i])) throw new Error('The template header names/order changed. Restore the template before scanning.'); }\nreturn [{json:$('Configure watchlist').first().json}];",
        "mode": "runOnceForAllItems"
      },
      "id": "244b7451-8431-5bd2-aadf-5af7bad113bb",
      "name": "Validate sheet template",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "8146e8ad-7e09-415f-bd4f-3477ef38ba25",
              "leftValue": "={{ Boolean($json.replayRunId) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "f521ebd0-4285-5d6c-b529-b20da2984009",
      "name": "Replay existing run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        960,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "return [{json:{data:{id:$('Configure watchlist').first().json.replayRunId}}}];",
        "mode": "runOnceForAllItems"
      },
      "id": "c76173d9-a436-5f42-9a18-86b719f908b8",
      "name": "Use saved run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1190,
        -100
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/acts/apterlabs~job-posting-change-monitor/runs",
        "authentication": "genericCredentialType",
        "options": {
          "timeout": 90000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": false,
              "neverError": false
            }
          }
        },
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "memory",
              "value": "512"
            },
            {
              "name": "timeout",
              "value": "600"
            },
            {
              "name": "maxTotalChargeUsd",
              "value": "={{ $('Configure watchlist').first().json.maxTotalChargeUsd }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ boards: $('Configure watchlist').first().json.boards, monitorName: $('Configure watchlist').first().json.monitorName, includeBaseline: true, includeCheckRecords: true, maxJobsPerBoard: 3000, closureChecks: 2, closureMinHours: 6 }) }}"
      },
      "id": "8a2ade49-6bb4-5df6-8677-90128530cc8b",
      "name": "Start JobDelta once",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1190,
        140
      ],
      "retryOnFail": false,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "alwaysOutputData": false
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/actor-runs/' + $json.data.id }}",
        "authentication": "genericCredentialType",
        "options": {
          "timeout": 90000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": false,
              "neverError": false
            }
          }
        },
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "waitForFinish",
              "value": "60"
            }
          ]
        }
      },
      "id": "8fae9c40-56de-5ee2-a954-22d49e6aa7bb",
      "name": "Get run status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1440,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "alwaysOutputData": false
    },
    {
      "parameters": {
        "jsCode": "const r=$input.first().json.data;\nif(!r?.id || r.actId!=='JKg4A48Voq8wYW3Wh') throw new Error('The run does not belong to JobDelta.');\nconst terminal=['SUCCEEDED','FAILED','ABORTED','TIMED-OUT'];\nconst active=['READY','RUNNING','TIMING-OUT','ABORTING'];\nif(!terminal.includes(r.status)&&!active.includes(r.status)) throw new Error('Unknown Actor status: '+r.status);\nif(!terminal.includes(r.status)&&Date.now()-$('Configure watchlist').first().json.executionStartedAt>720000) throw new Error('Polling deadline exceeded. Check Apify run '+r.id+' and replay after it ends. Do not start a duplicate scan.');\nreturn [{json:{data:r,done:terminal.includes(r.status)}}];",
        "mode": "runOnceForAllItems"
      },
      "id": "7318995d-c3d3-559b-bf0a-a871391c79c7",
      "name": "Check run state",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1680,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "19c327f9-ba09-408e-bc54-c573854d3e56",
              "leftValue": "={{ $json.done }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "9b7af65c-645f-50f5-921c-30b61aef8f24",
      "name": "Run has ended?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1920,
        0
      ]
    },
    {
      "parameters": {
        "resume": "timeInterval",
        "amount": 15,
        "unit": "seconds"
      },
      "id": "232fbb18-c02e-5272-8471-780ffd9f7308",
      "name": "Wait before polling",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1920,
        220
      ]
    },
    {
      "parameters": {
        "jsCode": "return [{json:$input.first().json.data}];",
        "mode": "runOnceForAllItems"
      },
      "id": "18fa43cc-b478-5ae8-9cff-885818656780",
      "name": "Capture completed run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2100,
        -100
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/key-value-stores/' + $('Capture completed run').first().json.defaultKeyValueStoreId + '/records/SUMMARY' }}",
        "authentication": "genericCredentialType",
        "options": {
          "timeout": 90000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": true,
              "neverError": true
            }
          }
        },
        "genericAuthType": "httpHeaderAuth"
      },
      "id": "865e73e4-2362-527a-a8de-e2fc7a03ed18",
      "name": "Get run summary",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2160,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "alwaysOutputData": false
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/datasets/' + $('Capture completed run').first().json.defaultDatasetId }}",
        "authentication": "genericCredentialType",
        "options": {
          "timeout": 90000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": false,
              "neverError": false
            }
          }
        },
        "genericAuthType": "httpHeaderAuth"
      },
      "id": "32092a9f-efee-59a8-b709-5e2585f299d4",
      "name": "Get dataset metadata",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2400,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "alwaysOutputData": false
    },
    {
      "parameters": {
        "jsCode": "const d=$input.first().json.data;\nif(!Number.isSafeInteger(d?.itemCount)||d.itemCount<0||d.itemCount>100000) throw new Error('Dataset exceeds this workflow limit or has invalid metadata; export the saved dataset manually. No further scan was started.');\nreturn [{json:d}];",
        "mode": "runOnceForAllItems"
      },
      "id": "83ada245-dff4-5c66-95de-8234a5ad8462",
      "name": "Check export size",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2640,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/datasets/' + $('Capture completed run').first().json.defaultDatasetId + '/items' }}",
        "authentication": "genericCredentialType",
        "options": {
          "timeout": 90000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": false,
              "neverError": false
            }
          },
          "pagination": {
            "pagination": {
              "paginationMode": "updateAParameterInEachRequest",
              "parameters": {
                "parameters": [
                  {
                    "type": "qs",
                    "name": "offset",
                    "value": "={{ $pageCount * 1000 }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ Array.isArray($response.body) && $response.body.length < 1000 }}",
              "limitPagesFetched": true,
              "maxRequests": 100,
              "requestInterval": 100
            }
          }
        },
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "format",
              "value": "json"
            },
            {
              "name": "limit",
              "value": "1000"
            },
            {
              "name": "clean",
              "value": "false"
            },
            {
              "name": "fields",
              "value": "event_id,event_type,observed_at,first_seen_at,provider,board,job_id,title,source_url,changed_fields,changes,job"
            }
          ]
        }
      },
      "id": "8ac7946f-fc21-5109-9898-384ce2604f16",
      "name": "Get all dataset pages",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2880,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "'use strict';\nconst LABELS = {\n  baseline: 'Initial inventory', newly_observed: 'First observed after baseline',\n  changed: 'Listing changed', no_longer_listed: 'No longer listed', reappeared: 'Listed again'\n};\nconst SIGNAL_COLUMNS = ['event_id','run_id','observed_at','signal','company','board','title','locations','source_url','first_seen_at','source_published_at','changed_fields','change_note','notice'];\nconst RUN_COLUMNS = ['run_id','status','started_at','finished_at','successful_boards','failed_or_skipped_boards','charged_board_checks','dataset_item_count','matched_signals','export_status','notes','run_url'];\nfunction text(value) { return value == null ? '' : String(value); }\nfunction safeCell(value) {\n  const s=text(value).replace(/\\u0000/g,'');\n  return /^[\\s]*[=+\\-@]/.test(s) ? \"'\"+s : s;\n}\nfunction validHttps(value) {\n  // No Node imports are required in the n8n Code sandbox.\n  const s=text(value);\n  return /^https:\\/\\/[a-z0-9.-]+(?::[0-9]{1,5})?(?:[/?#][^\\s]*)?$/i.test(s) && !s.includes('\\\\') ? s : '';\n}\nfunction titleMatches(title, cfg) {\n  const t=text(title).toLowerCase();\n  const hasKeyword=k=>{\n    const escaped=k.toLowerCase().replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&');\n    return new RegExp('(?:^|[^a-z0-9])'+escaped+'(?:$|[^a-z0-9])').test(t);\n  };\n  return /\\b(engineer|engineering|developer|architect)\\b/.test(t)\n    && cfg.titleKeywords.some(hasKeyword)\n    && !cfg.excludeTitleKeywords.some(hasKeyword);\n}\nfunction locationsMatch(locations, cfg) {\n  const t=(Array.isArray(locations)?locations:[]).join(' | ').toLowerCase();\n  return !cfg.locationKeywords.length || cfg.locationKeywords.some(k=>t.includes(k.toLowerCase()));\n}\nfunction matches(event,cfg) {\n  const job=event.job||{};\n  if(titleMatches(job.title||event.title,cfg)&&locationsMatch(job.locations,cfg)) return true;\n  // A role that changes out of the selected scope must still be visible to the consumer.\n  if(event.event_type==='changed') {\n    const previousTitle=event.changes?.title?.before ?? event.title;\n    const previousLocations=event.changes?.locations?.before ?? job.locations;\n    return titleMatches(previousTitle,cfg)&&locationsMatch(previousLocations,cfg);\n  }\n  return false;\n}\nfunction brief(value) {\n  const s=typeof value==='string'?value:JSON.stringify(value);\n  return text(s).slice(0,300)+(text(s).length>300?' … [excerpt]':'');\n}\nfunction filterEvents(events,cfg,runId) {\n  if(!Array.isArray(events)) throw new Error('Dataset must be an array.');\n  const seen=new Set(), result=[];\n  for(const e of events) {\n    if(!e || !LABELS[e.event_type] || (e.event_type==='baseline'&&!cfg.includeBaseline)) continue;\n    if(!/^[a-f0-9]{64}$/.test(text(e.event_id))) throw new Error('A job event has no valid event_id.');\n    if(seen.has(e.event_id)) continue;\n    seen.add(e.event_id);\n    if(!matches(e,cfg)) continue;\n    const board=`${e.provider}:${e.board}`;\n    const changed=Array.isArray(e.changed_fields)?e.changed_fields:[];\n    const allChanges=changed.map(k=>`${k}: ${brief(e.changes?.[k]?.before)} → ${brief(e.changes?.[k]?.after)}`).join(' | ');\n    const changeNote=allChanges.slice(0,5000)+(allChanges.length>5000?' … [excerpt]':'');\n    const notice=e.event_type==='baseline' ? 'Present on the first scan; not a newly posted claim.'\n      : e.event_type==='newly_observed' ? 'First seen by this monitor; the actual posting time may be earlier.'\n      : e.event_type==='no_longer_listed' ? 'Absent across confirmed checks; hiring outcome is unknown.'\n      : 'An observed source change; employer hiring intent is not verified.';\n    result.push(Object.fromEntries(Object.entries({\n      event_id:e.event_id,run_id:runId,observed_at:e.observed_at,signal:LABELS[e.event_type],\n      company:cfg.companyNames?.[board]||e.board,board,title:e.title,\n      locations:(e.job?.locations||[]).join(' | '),source_url:validHttps(e.source_url),\n      first_seen_at:e.first_seen_at,source_published_at:e.job?.source_published_at,\n      changed_fields:changed.join(', '),change_note:changeNote,notice\n    }).map(([k,v])=>[k,safeCell(v)])));\n  }\n  return result;\n}\n\nconst cfg=$('Configure watchlist').first().json;\nconst run=$('Capture completed run').first().json;\nconst summaryResponse=$('Get run summary').first().json;\nconst summary=summaryResponse.statusCode===200?summaryResponse.body:null;\nconst expected=$('Check export size').first().json.itemCount;\nconst entries=$input.all().map(i=>i.json).filter(x=>Object.keys(x).length);\nif(entries.length!==expected) throw new Error('Incomplete dataset export for run '+run.id+': expected '+expected+', got '+entries.length+'. Replay this run; do not start another scan.');\nconst rows=filterEvents(entries,cfg,run.id);\nconst issues=!summary || run.status!=='SUCCEEDED' || Number(summary.failed_or_skipped_boards)>0;\nconst notes=[!summary?'SUMMARY unavailable; inspect the Actor log.':'',...(summary?.boards||[]).filter(b=>b.status!=='ok').map(b=>b.board+': '+(b.error||b.status))].filter(Boolean).join(' | ');\nreturn [{json:{rows,hasIssues:issues,run:{\n run_id:run.id,status:run.status,started_at:run.startedAt||'',finished_at:run.finishedAt||'',\n successful_boards:summary?.successful_boards??'',failed_or_skipped_boards:summary?.failed_or_skipped_boards??'',\n charged_board_checks:summary?.charged_board_checks??'',dataset_item_count:expected,matched_signals:rows.length,\n export_status:'PENDING',notes:safeCell(notes),run_url:'https://console.apify.com/actors/JKg4A48Voq8wYW3Wh/runs/'+run.id\n}}}];\n",
        "mode": "runOnceForAllItems"
      },
      "id": "b32cac4e-5823-5bff-9914-e1a0b7298cd7",
      "name": "Prepare recruiter output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "return [{json:$('Prepare recruiter output').first().json.run}];",
        "mode": "runOnceForAllItems"
      },
      "id": "83072307-926f-5388-bd29-dbdf4e92529a",
      "name": "Prepare pending receipt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3360,
        0
      ]
    },
    {
      "parameters": {
        "resource": "sheet",
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Configure watchlist').first().json.spreadsheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Runs",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "run_id": "={{ $json.run_id }}",
            "status": "={{ $json.status }}",
            "started_at": "={{ $json.started_at }}",
            "finished_at": "={{ $json.finished_at }}",
            "successful_boards": "={{ $json.successful_boards }}",
            "failed_or_skipped_boards": "={{ $json.failed_or_skipped_boards }}",
            "charged_board_checks": "={{ $json.charged_board_checks }}",
            "dataset_item_count": "={{ $json.dataset_item_count }}",
            "matched_signals": "={{ $json.matched_signals }}",
            "export_status": "={{ $json.export_status }}",
            "notes": "={{ $json.notes }}",
            "run_url": "={{ $json.run_url }}"
          },
          "matchingColumns": [
            "run_id"
          ],
          "schema": [
            {
              "id": "run_id",
              "displayName": "run_id",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "started_at",
              "displayName": "started_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "finished_at",
              "displayName": "finished_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "successful_boards",
              "displayName": "successful_boards",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "failed_or_skipped_boards",
              "displayName": "failed_or_skipped_boards",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "charged_board_checks",
              "displayName": "charged_board_checks",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "dataset_item_count",
              "displayName": "dataset_item_count",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "matched_signals",
              "displayName": "matched_signals",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "export_status",
              "displayName": "export_status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "notes",
              "displayName": "notes",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "run_url",
              "displayName": "run_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "0422519a-7969-53fa-a2ba-dbca1a30d45a",
      "name": "Record pending export",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.6,
      "position": [
        3600,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "0e75dc5a-6be2-476f-8a45-af342424cdca",
              "leftValue": "={{ $('Prepare recruiter output').first().json.rows.length > 0 }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "d2bb50dd-b383-5128-818f-eed5b5b2196f",
      "name": "Any matching signals?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3840,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "return $('Prepare recruiter output').first().json.rows.map(json=>({json}));",
        "mode": "runOnceForAllItems"
      },
      "id": "14d23b91-ec87-53c9-8d82-b7f4fb506e68",
      "name": "Expand signal rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4080,
        -100
      ]
    },
    {
      "parameters": {
        "resource": "sheet",
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Configure watchlist').first().json.spreadsheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Signals",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "event_id": "={{ $json.event_id }}",
            "run_id": "={{ $json.run_id }}",
            "observed_at": "={{ $json.observed_at }}",
            "signal": "={{ $json.signal }}",
            "company": "={{ $json.company }}",
            "board": "={{ $json.board }}",
            "title": "={{ $json.title }}",
            "locations": "={{ $json.locations }}",
            "source_url": "={{ $json.source_url }}",
            "first_seen_at": "={{ $json.first_seen_at }}",
            "source_published_at": "={{ $json.source_published_at }}",
            "changed_fields": "={{ $json.changed_fields }}",
            "change_note": "={{ $json.change_note }}",
            "notice": "={{ $json.notice }}"
          },
          "matchingColumns": [
            "event_id"
          ],
          "schema": [
            {
              "id": "event_id",
              "displayName": "event_id",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "run_id",
              "displayName": "run_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "observed_at",
              "displayName": "observed_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "signal",
              "displayName": "signal",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "company",
              "displayName": "company",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "board",
              "displayName": "board",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "title",
              "displayName": "title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "locations",
              "displayName": "locations",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "source_url",
              "displayName": "source_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "first_seen_at",
              "displayName": "first_seen_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "source_published_at",
              "displayName": "source_published_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "changed_fields",
              "displayName": "changed_fields",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "change_note",
              "displayName": "change_note",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "notice",
              "displayName": "notice",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "a45ed9b5-50dd-53cf-b7fa-ab8a21c32b3f",
      "name": "Upsert signals by event ID",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.6,
      "position": [
        4320,
        -100
      ]
    },
    {
      "parameters": {
        "jsCode": "const p=$('Prepare recruiter output').first().json; return [{json:{...p.run,export_status:p.hasIssues?'COMPLETE_WITH_SOURCE_ISSUES':'COMPLETE'}}];",
        "mode": "runOnceForAllItems"
      },
      "id": "de8d800e-ecd9-534e-8494-00782e4deda7",
      "name": "Prepare completed receipt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4560,
        0
      ]
    },
    {
      "parameters": {
        "resource": "sheet",
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Configure watchlist').first().json.spreadsheetId }}",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Runs",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "run_id": "={{ $json.run_id }}",
            "status": "={{ $json.status }}",
            "started_at": "={{ $json.started_at }}",
            "finished_at": "={{ $json.finished_at }}",
            "successful_boards": "={{ $json.successful_boards }}",
            "failed_or_skipped_boards": "={{ $json.failed_or_skipped_boards }}",
            "charged_board_checks": "={{ $json.charged_board_checks }}",
            "dataset_item_count": "={{ $json.dataset_item_count }}",
            "matched_signals": "={{ $json.matched_signals }}",
            "export_status": "={{ $json.export_status }}",
            "notes": "={{ $json.notes }}",
            "run_url": "={{ $json.run_url }}"
          },
          "matchingColumns": [
            "run_id"
          ],
          "schema": [
            {
              "id": "run_id",
              "displayName": "run_id",
              "required": false,
              "defaultMatch": true,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "started_at",
              "displayName": "started_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "finished_at",
              "displayName": "finished_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "successful_boards",
              "displayName": "successful_boards",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "failed_or_skipped_boards",
              "displayName": "failed_or_skipped_boards",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "charged_board_checks",
              "displayName": "charged_board_checks",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "dataset_item_count",
              "displayName": "dataset_item_count",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "matched_signals",
              "displayName": "matched_signals",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "export_status",
              "displayName": "export_status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "notes",
              "displayName": "notes",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "run_url",
              "displayName": "run_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {
          "cellFormat": "RAW"
        }
      },
      "id": "80b600b6-147b-5003-8ada-6368fea394b0",
      "name": "Record completed export",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.6,
      "position": [
        4800,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const p=$('Prepare recruiter output').first().json; if(p.hasIssues) throw new Error('Available signals were saved, but source checks need attention. See Runs tab and Apify run '+p.run.run_id+'.'); return [{json:{run_id:p.run.run_id,matched_signals:p.rows.length,export_status:'COMPLETE'}}];",
        "mode": "runOnceForAllItems"
      },
      "id": "9726e4c7-701d-5386-8ac8-ff5727831549",
      "name": "Report source issues",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5040,
        0
      ]
    }
  ],
  "connections": {
    "Run manually": {
      "main": [
        [
          {
            "node": "Configure watchlist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily at 8am - inactive until enabled": {
      "main": [
        [
          {
            "node": "Configure watchlist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configure watchlist": {
      "main": [
        [
          {
            "node": "Check spreadsheet headers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check spreadsheet headers": {
      "main": [
        [
          {
            "node": "Validate sheet template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate sheet template": {
      "main": [
        [
          {
            "node": "Replay existing run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use saved run": {
      "main": [
        [
          {
            "node": "Get run status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start JobDelta once": {
      "main": [
        [
          {
            "node": "Get run status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get run status": {
      "main": [
        [
          {
            "node": "Check run state",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check run state": {
      "main": [
        [
          {
            "node": "Run has ended?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait before polling": {
      "main": [
        [
          {
            "node": "Get run status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Capture completed run": {
      "main": [
        [
          {
            "node": "Get run summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get run summary": {
      "main": [
        [
          {
            "node": "Get dataset metadata",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get dataset metadata": {
      "main": [
        [
          {
            "node": "Check export size",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check export size": {
      "main": [
        [
          {
            "node": "Get all dataset pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get all dataset pages": {
      "main": [
        [
          {
            "node": "Prepare recruiter output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare recruiter output": {
      "main": [
        [
          {
            "node": "Prepare pending receipt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare pending receipt": {
      "main": [
        [
          {
            "node": "Record pending export",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record pending export": {
      "main": [
        [
          {
            "node": "Any matching signals?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Expand signal rows": {
      "main": [
        [
          {
            "node": "Upsert signals by event ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert signals by event ID": {
      "main": [
        [
          {
            "node": "Prepare completed receipt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare completed receipt": {
      "main": [
        [
          {
            "node": "Record completed export",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Record completed export": {
      "main": [
        [
          {
            "node": "Report source issues",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Replay existing run?": {
      "main": [
        [
          {
            "node": "Use saved run",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Start JobDelta once",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run has ended?": {
      "main": [
        [
          {
            "node": "Capture completed run",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait before polling",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any matching signals?": {
      "main": [
        [
          {
            "node": "Expand signal rows",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare completed receipt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "timezone": "America/New_York",
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all",
    "executionTimeout": 1800
  },
  "pinData": {},
  "tags": []
}
```

# Actor input Schema

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

Paste 1–25 public Greenhouse, Lever or Ashby HTTPS board URLs, such as https://jobs.ashbyhq.com/ramp. Identifiers such as greenhouse:stripe and lever-eu:company still work. Job links monitor the whole company board. Custom careers domains are not supported. Reuse the same monitor name to keep history.

## `monitorName` (type: `string`):

Reuse this name to keep monitoring the same history. Use a different name for an independent monitor. State stays in your Apify account.

## `includeBaseline` (type: `boolean`):

Include baseline records on each board's first successful scan. Later scans output changes only. Disabling this still saves the baseline.

## `includeCheckRecords` (type: `boolean`):

Add one board_checked record per successful scan to the Apify dataset, even when no jobs changed. These are scan receipts, not job changes, and carry no extra fee. EVENTS stays job-events-only. Enabled in the example form; API default is false.

## `maxJobsPerBoard` (type: `integer`):

Reject a board above this limit rather than comparing an incomplete export. Failed boards do not advance history or incur a board-check event.

## `closureChecks` (type: `integer`):

A job must be absent from this many successful checks and for the minimum time below. A disappearance does not prove a role was filled.

## `closureMinHours` (type: `integer`):

Time between the first observed absence and confirmation. Large disappearances automatically require at least 3 checks spanning 24 hours.

## `stateStoreId` (type: `string`):

Usually leave blank: a named store is created automatically. Select a specific store only to reuse/move an existing JobDelta monitor.

## Actor input object example

```json
{
  "boards": [
    "https://jobs.ashbyhq.com/ramp"
  ],
  "monitorName": "main",
  "includeBaseline": true,
  "includeCheckRecords": true,
  "maxJobsPerBoard": 3000,
  "closureChecks": 2,
  "closureMinHours": 6
}
```

# Actor output Schema

## `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 = {
    "boards": [
        "https://jobs.ashbyhq.com/ramp"
    ],
    "includeCheckRecords": true
};

// Run the Actor and wait for it to finish
const run = await client.actor("apterlabs/job-posting-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": ["https://jobs.ashbyhq.com/ramp"],
    "includeCheckRecords": True,
}

# Run the Actor and wait for it to finish
run = client.actor("apterlabs/job-posting-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": [
    "https://jobs.ashbyhq.com/ramp"
  ],
  "includeCheckRecords": true
}' |
apify call apterlabs/job-posting-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,apterlabs/job-posting-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/JKg4A48Voq8wYW3Wh/builds/DNrNdpOfZncwZC3wT/openapi.json
