# Job Lifecycle Monitor - Ghost Jobs & Reposts (`gganbukim/job-lifecycle-monitor`) Actor

Watch company career pages on a schedule and get only what changed: roles opened, closed, quietly republished under a new ID, or silently edited. Detects ghost jobs by scoring roles against board median age and republish history. Greenhouse, Ashby, Lever and SmartRecruiters. Remembers between runs.

- **URL**: https://apify.com/gganbukim/job-lifecycle-monitor.md
- **Developed by:** [DONGMIN KIM](https://apify.com/gganbukim) (community)
- **Categories:** Jobs, Lead generation, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 change event reporteds

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/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Job Lifecycle Monitor — Track Openings, Closures, Reposts & Ghost Jobs

Paste **company domains** and watch them on a schedule. You get **only what changed**:
roles opened, roles closed, roles quietly republished under a new ID, and silent edits to
titles, locations or salary ranges.

No need to know which ATS each company uses — the board is discovered and verified for
you across Greenhouse, Ashby, Lever and SmartRecruiters.

### Why this data does not exist anywhere else

Every ATS publishes when a role *opened*. **None of them publish when it closed**, that
it was reposted last month under a different ID, or that the salary band changed on
Tuesday.

Those facts only exist for someone who was watching. They cannot be back-filled, bought,
or reconstructed after the fact — not by you, and not by anyone who starts later.

That has a practical consequence worth understanding before you start: **the first run
is a baseline and reports no change events.** From the second run onward you get the
half of the job market nobody else has.

### Events

| Event | What it means |
|---|---|
| `opened` | A role that was not there last run |
| `closed` | A role that disappeared, with `daysOpen` |
| `reposted` | **The same role under a new ID** — matched on a normalised title and city, so seniority tweaks and "Seoul" vs "Seoul, KR" still match. The strongest ghost-job signal there is. |
| `reopened` | The same ID came back after closing |
| `edited` | Title, location or salary changed silently, with before and after |

Plus `ghost-suspect` rows scoring open roles against their own board's median age and
their republish history.

### Input

```jsonc
{
  "boardUrls": ["stripe.com", "notion.so", "https://jobs.ashbyhq.com/ramp"],
  "eventTypes": ["opened", "closed", "reposted", "edited"],
  "emitGhosts": true,
  "minGhostScore": 40,
  "titleContains": ["engineer"],
  "memoryStoreName": "job-lifecycle-state"
}
```

#### Every option

The same wording you see in the Apify console, with the JSON key for API and MCP callers.

| Option | What it does | Default |
|---|---|---|
| **Companies to watch** — `boardUrls` *(required)* | Paste plain company domains — stripe.com, notion.so — and the ATS board is found and verified automatically. Careers URLs work too: boards.greenhouse.io/\<company>, jobs.ashbyhq.com/\<company>, jobs.lever.co/\<company>, jobs.smartrecruiters.com/\<Company>. | — |
| **Events to report** — `eventTypes` | opened and closed are the basics. reposted catches the same role republished under a new ID — the strongest ghost-job signal. edited catches silent title, location or salary changes. | `["opened","closed","reposted","reopened","edited"]` |
| **Report ghost-job suspects** — `emitGhosts` | Score every open role against its own board's median age and report the outliers. | `true` |
| **Minimum ghost score** — `minGhostScore` | 0-100. Below 40 gets noisy on boards that legitimately keep evergreen roles open. Note the ceiling: republish history is worth up to 40 points and only exists once this actor has watched a board for a while, so early runs top out at 65. Each row carries ghostScoreMax so you can see which ceiling applied. | `40` |
| **Only roles whose title contains** — `titleContains` | Case-insensitive. Narrow to the functions you care about. Filtered events are not billed. | — |
| **Memory store name** — `memoryStoreName` | Named key-value store holding the tracking history. Use different names to run independent watchlists. | `"job-lifecycle-state"` |
| **Reset memory** — `resetMemory` | Discard history and treat this run as a new baseline. Only for starting over. | `false` |
| **Concurrency** — `concurrency` | Boards fetched in parallel. Each board keeps its own tracking history regardless of ordering. | `5` |
| **Proxy** — `proxyConfiguration` | Leave the default. | `{"useApifyProxy":true}` |

### Output

```json
{
  "board": "greenhouse:stripe",
  "event": "reposted",
  "jobId": "8412991",
  "previousJobId": "8077887",
  "title": "Backend Engineer II, Payments",
  "location": "Dublin, Ireland",
  "daysClosedBefore": 12,
  "republishCount": 2,
  "detectedAt": "2026-08-13T09:22:14.001Z"
}
```

Ghost suspects are a different row. They are an assessment of a role as it stands rather
than a transition, so they carry the score and what the score was able to see:

```json
{
  "board": "greenhouse:stripe",
  "event": "ghost-suspect",
  "jobId": "4921361",
  "title": "Backend Engineer/API, Payments and Risk",
  "department": "8556 LPM Core - Eng",
  "ageDays": 1243,
  "ghostScore": 65,
  "ghostScoreMax": 100,
  "ghostScoreBasis": "age-and-republish-history",
  "ghostReasons": ["open 1243 days (board median 58)", "older than a year"],
  "boardMedianAgeDays": 58,
  "republishCount": 0,
  "detectedAt": "2026-08-15T23:34:02.855Z"
}
```

**Read `ghostScore` against `ghostScoreMax`, not against 100.** Republish history is worth
up to 40 points and only exists once this actor has watched a board for a while, so early
runs top out at 65 and `ghostScoreBasis` says `age-only`. A `minGhostScore` of 70 returns
nothing at all until the history has accumulated.

The row above shows why the two are separate numbers. The ceiling is 100 because this
board has been watched long enough to have a history; this particular role scores 65
because it has not been closed and reposted in that time. A high ceiling with a low score
means "we looked and found no cycles", which is a different statement from "we could not
look" — and `republishCount` tells you which.

#### Every field

You are billed per row delivered — change events and ghost suspects alike — so here is
everything a row can contain. Both row types carry the role's own fields from the board,
so a row is readable on its own without joining back to a scrape.

**On every row**

| Field | What it is |
|---|---|
| `board` | `platform:slug`, e.g. `greenhouse:stripe`. |
| `ats` | The platform alone: `greenhouse`, `ashby`, `lever` or `smartrecruiters`. |
| `boardSlug` | The company's slug alone. |
| `event` | `opened`, `closed`, `reposted`, `reopened`, `edited` or `ghost-suspect`. |
| `detectedAt` | When this run saw it. The event timestamp, not the board's. |
| `jobId` | The platform's id for the role. |
| `title` | Role title. |

**Carried from the board, where the platform publishes it**

| Field | What it is |
|---|---|
| `url` | Public application URL. |
| `location` | Location string as published. |
| `department` / `team` | Department, and a finer team where the platform separates them. |
| `employmentType` / `workplaceType` / `isRemote` | Contract shape and remote status, where published. |
| `isListed` | `false` for delisted-but-not-deleted. Ashby only. |
| `salaryRange` | `min`, `max`, `currency`, `interval`. Changes to it are what an `edited` event reports. |
| `requisitionId` | The company's internal requisition number, where exposed. |
| `companyName` | Company name as the board publishes it. |
| `firstPublishedAt` | When the role was first posted. |
| `updatedAt` | Last edit to the posting, where published. |
| `ageDays` | Days open, from `firstPublishedAt`. |

**On change events**

| Field | Events | What it is |
|---|---|---|
| `fingerprint` | all | Hash of title + location, which is how a repost under a new id is matched to the role that closed. |
| `previousJobId` | `reposted`, `reopened` | The id this posting replaced. This is the whole point of the actor: the same role, renumbered. |
| `daysClosedBefore` | `reposted`, `reopened` | How long the gap was. A short gap is the classic evergreen-repost pattern. |
| `changes` | `edited` | One entry per changed field: `field`, `from`, `to`. Covers title, location and salary. |
| `firstSeenAt` | `closed` | When this actor first saw the role — earlier than any board field when the role predates your first run. |
| `lastSeenAt` | `closed` | The last run that still saw it open, so the closure is bracketed. |
| `daysOpen` | `closed` | Total days the role stayed up. |
| `cycles` | `closed` | How many times it has closed and come back since tracking began. |

**On ghost suspects**

| Field | What it is |
|---|---|
| `ghostScore` | 0–`ghostScoreMax`. |
| `ghostScoreMax` | The highest score reachable given what this board's history supports — `65` before history exists, `100` after. |
| `ghostScoreBasis` | `age-only` or `age-and-republish-history`. |
| `ghostReasons` | Plain-language reasons behind the score. |
| `boardMedianAgeDays` | The board's median role age, which the score is relative to. |
| `republishCount` | Close-and-return cycles seen for this role. `0` with a `ghostScoreMax` of 100 means we looked and found none. |

### The memory, and why it is a named store

Tracking history lives in a **named** key-value store, not the default one. Apify's
default store is scoped to a single run — history kept there would be silently discarded
every time, and the monitor would report the entire board as newly opened on each run.
That would make it a slower, more expensive version of the plain scraper. A test covers
this.

Use different `memoryStoreName` values for independent watchlists. Set `resetMemory` to
start a fresh baseline.

### Who this is for

- **Job boards and hiring products** — prove a role keeps being republished rather than filled, and say so to your users.
- **Recruiting analysts** — real time-to-fill data by function and company, which nobody publishes.
- **Competitive watchers** — know within a day when a rival opens a role, and catch salary bands changing quietly.
- **Investors** — closure rates as a hiring-health signal, accumulated over time.

### Common uses

- **Ghost-job detection** — prove a role keeps being republished rather than filled.
- **Time-to-fill benchmarking** — real closure data by function and company.
- **Competitor hiring alerts** — know within a day when a rival opens a role.
- **Compensation drift** — catch salary bands changing quietly on Lever boards.
- **Market research** — closure rates as a hiring-health signal, accumulated over time.

### Pricing

Pay per event reported and per ghost suspect. **Watching costs almost nothing** — reading a
board is billed at $0.00002 per run — the platform's $0.00001 minimum, charged once per GB
of memory, on 2 GB — and everything beyond that is charged only for the changes it
surfaces. On a steady schedule most runs report a handful of events, or none, and cost
well under a hundredth of a cent.

### Other Actors in this family

Same engines, same billing, no account or API key on any of them.

**YouTube & video**

- [Download YouTube Subtitles in Bulk — SRT, VTT & Text](https://apify.com/gganbukim/youtube-transcript-scraper) — Bulk subtitles from videos, channels or playlists — text, SRT, VTT or RAG chunks.
- [Export YouTube Comments to CSV — Replies and Likes](https://apify.com/gganbukim/youtube-comments-scraper) — Every comment and reply thread, with likes, authors and creator flags.
- [List Every Video on a YouTube Channel — Export to CSV](https://apify.com/gganbukim/youtube-channel-scraper) — A channel's whole back catalogue plus a subscriber and RSS summary row.
- [Find YouTube Sponsors — Brand Deals, Codes & Links](https://apify.com/gganbukim/youtube-sponsorship-finder) — Which brands pay which creators, with the campaign link, the code and the timestamp.
- [YouTube Search API — Bulk Results, No Quota](https://apify.com/gganbukim/youtube-search-scraper) — Many search terms at once, every result as a row, filtered before you are billed.
- [Track Deleted YouTube Videos & Title Changes](https://apify.com/gganbukim/youtube-channel-monitor) — What a channel quietly changed: deleted videos, rewritten titles, view velocity.
- [YouTube Creator Email Finder & Sponsor Lookup](https://apify.com/gganbukim/youtube-creator-leads) — A channel list into leads: the published email, audience bands, and who already sponsors them.
- [Export a YouTube Playlist to CSV — Every Video](https://apify.com/gganbukim/youtube-playlist-scraper) — Any playlist as a table, with each video position in it.

**Search demand**

- [AnswerThePublic Alternative — Autocomplete Keyword API](https://apify.com/gganbukim/long-tail-keyword-scraper) — One seed into hundreds of real keywords from Google, YouTube and Amazon autocomplete.
- [Google Trends API — Today's Trending Searches, No Key](https://apify.com/gganbukim/google-trends-scraper) — Today's trending searches by country, with traffic bands and the news behind them.

**E-commerce**

- [Export Any Shopify Store's Products to CSV or JSON](https://apify.com/gganbukim/shopify-product-scraper) — Any Shopify catalogue: variants, SKUs, live prices, stock, images, collections.
- [New Shopify Product Alerts — Competitor Drop Tracker](https://apify.com/gganbukim/shopify-new-arrivals-monitor) — Only what a store launched since the last run. Scanning is free.

**Hiring**

- [Greenhouse, Lever & Ashby Job Scraper — No API Key](https://apify.com/gganbukim/ats-job-scraper) — Paste a company domain, get its open roles from Greenhouse, Ashby, Lever or SmartRecruiters.

### FAQ

**Why did the first run report nothing?** By design. It records a baseline instead of
claiming every existing role just opened.

**How often should I run it?** Daily is the sweet spot. Hourly rarely surfaces more,
because ATS boards do not change that fast.

**What if a run is interrupted?** State is written after all boards complete, so an
interrupted run re-reports next time rather than losing history.

**Can I watch many companies?** Yes, in one run. Each board's history is tracked
separately.

**Is it legal to monitor careers pages?** Greenhouse, Ashby, Lever and SmartRecruiters
publish documented, public, no-authentication job board APIs intended for syndication.
There is no login and nothing bypassed. What this adds is your own record of what changed
between two public snapshots, which is data you created by watching. Each company's own
terms are a separate contract question. Not legal advice.

**Will I get blocked or rate-limited?** These are official JSON endpoints built for
syndication, so a daily schedule across many boards is well within normal use.

**How much does 1,000 changes cost?** $4.00 per 1,000 change events and $2.00 per 1,000
ghost suspects, plus $0.00002 for the run. A quiet day reports nothing and costs the run
fee alone.

**Can I export the results to Excel or Google Sheets?** Yes. Every run's dataset
downloads as CSV, Excel, JSON, XML or RSS from the Storage tab, or straight from the API
if you want a live link a spreadsheet can pull.

**Can I connect it to Zapier, Make or n8n?** Yes — Apify publishes integrations for all
three, plus webhooks that fire when a run finishes. A common setup is a schedule here and
a webhook into your own database or Slack.

**Do I need to write code?** No. Fill the form in the console and press Start. If you do
want code, the Apify client libraries for Python and JavaScript call this the same way,
and it is available over MCP so an AI agent can call it directly.

# Actor input Schema

## `boardUrls` (type: `array`):

Paste plain company domains — stripe.com, notion.so — and the ATS board is found and verified automatically. Careers URLs work too: boards.greenhouse.io/<company>, jobs.ashbyhq.com/<company>, jobs.lever.co/<company>, jobs.smartrecruiters.com/<Company>.

## `eventTypes` (type: `array`):

opened and closed are the basics. reposted catches the same role republished under a new ID — the strongest ghost-job signal. edited catches silent title, location or salary changes.

## `emitGhosts` (type: `boolean`):

Score every open role against its own board's median age and report the outliers.

## `minGhostScore` (type: `integer`):

0-100. Below 40 gets noisy on boards that legitimately keep evergreen roles open. Note the ceiling: republish history is worth up to 40 points and only exists once this actor has watched a board for a while, so early runs top out at 65. Each row carries ghostScoreMax so you can see which ceiling applied.

## `titleContains` (type: `array`):

Case-insensitive. Narrow to the functions you care about. Filtered events are not billed.

## `memoryStoreName` (type: `string`):

Named key-value store holding the tracking history. Use different names to run independent watchlists.

## `resetMemory` (type: `boolean`):

Discard history and treat this run as a new baseline. Only for starting over.

## `concurrency` (type: `integer`):

Boards fetched in parallel. Each board keeps its own tracking history regardless of ordering.

## `proxyConfiguration` (type: `object`):

Leave the default.

## Actor input object example

```json
{
  "boardUrls": [
    "stripe.com",
    "https://jobs.ashbyhq.com/ramp"
  ],
  "eventTypes": [
    "opened",
    "closed",
    "reposted",
    "reopened",
    "edited"
  ],
  "emitGhosts": true,
  "minGhostScore": 40,
  "memoryStoreName": "job-lifecycle-state",
  "resetMemory": false,
  "concurrency": 5,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `results` (type: `string`):

One row per change since the last run — opened, closed, reposted, edited — plus ghost-job suspects when that option is on.

## `runSummary` (type: `string`):

Counts for this run: rows delivered, rows filtered, items that failed, the proxy tier used, and whether the run stopped at its charge limit.

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

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

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

```

## MCP server setup

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