Practical guide
How to Get Alerts for New Job Postings at Specific Companies
Last reviewed: October 22, 2026
Save a successful job-board snapshot, fetch the next one and compare IDs. Send new-ID records to your chosen notification workflow. This works for a job search shortlist, recruiting research or a company hiring watchlist.
Define a useful hiring signal
Choose target companies, confirm their hosted boards and decide which titles and locations matter. A new engineering role may be relevant to your work, but a posting alone does not prove a hiring budget, company growth or purchase intent. Use it as a reason to investigate.
Use the provider's native posting ID with the platform and board as the key. Do not compare titles alone: two openings can share a title, and titles can change. Keep first-seen time separate from any provider publication date. A collection timestamp is not the date the employer created the role.
The public API export guide covers all five supported sources. The script below uses one complete Greenhouse list response to keep the example small; Lever needs pagination before comparison. Source contracts: Greenhouse: Job Board API and Lever: official Postings API documentation.
Diff job IDs with a small Python script
Save this as monitor_jobs.py. Run python3 monitor_jobs.py stripe stripe-state.json. First run: a silent baseline. Later runs: one JSON line for each newly observed ID. The state file includes the source URL to prevent accidentally sharing a baseline across boards.
# Save as monitor_jobs.py. Python 3 standard library.
import json, sys
from pathlib import Path
from urllib.parse import quote
from urllib.request import Request, urlopen
from datetime import datetime, timezone
slug = sys.argv[1]
state = Path(sys.argv[2]) # use a different state file for each board
url = "https://boards-api.greenhouse.io/v1/boards/" + quote(slug, safe="") + "/jobs"
req = Request(url, headers={"User-Agent": "JobBoardMonitor/1.0"})
with urlopen(req, timeout=30) as response:
if response.status != 200:
raise RuntimeError("Unexpected status")
raw = response.read(10_000_001)
if len(raw) > 10_000_000:
raise RuntimeError("Response too large; baseline unchanged")
data = json.loads(raw)
jobs = data.get("jobs")
if not isinstance(jobs, list):
raise RuntimeError("Missing jobs array; baseline unchanged")
current = {}
for job in jobs:
ident = str(job["id"])
if ident in current:
raise RuntimeError("Duplicate ID; inspect snapshot")
current[ident] = job
if isinstance(data.get("meta"), dict) and "total" in data["meta"]:
if data["meta"]["total"] != len(jobs):
raise RuntimeError("Count mismatch; baseline unchanged")
old = json.loads(state.read_text()) if state.exists() else None
if old is not None and old["source"] != url:
raise RuntimeError("State belongs to a different board")
seen = set(old["ids"]) if old is not None else set(current)
now = datetime.now(timezone.utc).isoformat()
for ident in sorted(set(current) - seen):
job = current[ident]
print(json.dumps({"id": ident, "title": job["title"],
"url": job["absolute_url"], "firstSeenAt": now}), flush=True)
snapshot = {"source": url, "observedAt": now, "ids": sorted(current)}
temporary = state.with_suffix(state.suffix + ".tmp")
temporary.write_text(json.dumps(snapshot), encoding="utf-8")
temporary.replace(state)
The code validates the response and changes state only after parsing the full list. A missing array, HTTP error or malformed record raises an error and preserves the old baseline. A valid empty board is a successful snapshot. Review an unexpected drop to zero rather than automatically assuming every role closed.
The first run intentionally treats all current IDs as already seen. To include the current inventory, export it separately. If an ID disappears from a successful snapshot and later returns, this script reports it again. A title or description edit under the same ID does not trigger a new-posting event.
Schedule checks and route alerts
Start manually and inspect output before scheduling. Choose an interval such as once daily based on how quickly you need to act. Every 15 minutes is unnecessary if a daily digest meets the need. Check terms and robots policies and avoid overlapping runs that write the same state file.
# Example crontab entry. Replace paths with your actual absolute paths.
# This runs at 08:15 in the scheduler's configured time zone.
15 8 * * * /usr/bin/python3 /srv/job-watch/monitor_jobs.py stripe /srv/job-watch/stripe-state.json >> /srv/job-watch/new-jobs.jsonl 2>> /srv/job-watch/errors.log
Cron syntax and time-zone behavior depend on the installed scheduler; consult its manual. Upstream crontab documentation. Use an external lock or a scheduler that prevents overlap. Keep persistent state outside temporary job directories.
The script writes JSON lines to stdout; it does not send email or chat messages. Have your existing notification workflow read the output file and keep its own delivery cursor. Test a deliberate new ID and a failed HTTP response before relying on notifications.
This example does not provide transactional or exactly-once delivery: a process failure or routing failure around the state update can lose or repeat a notification. For important alerts, write an event outbox durably before advancing the snapshot, then deliver and acknowledge each event separately. Preserve error logs and alert on repeated collection failures.
Filters, retries and false alerts
- Collect the full board before deciding what disappeared. Never replace a baseline with a truncated first page.
- Store all IDs, then filter new records for titles or locations. Otherwise changing a filter can make an old job look new.
- On 429, temporary server errors or timeouts, retain the previous baseline and retry with bounded backoff. Do not turn a failure into an empty array.
- Keep boards isolated: one failed company should not overwrite another company's state.
- Board migrations change the ID namespace. Establish a fresh baseline and tell reviewers why alerts paused.
A role can appear and disappear between checks. Polling cannot guarantee observation of every short-lived posting. A source may also republish a job with a new ID, producing a new signal even when the title is unchanged. Confirm the live posting before acting.
Use ATS Jobs Scraper monitor mode
For multiple companies, use onlyNewJobs and a stable monitorKey. That option name differs from the other Actors' monitor inputs. This example watches engineering roles:
{
"companies": ["greenhouse:stripe", "lever:spotify", "ashby:ramp"],
"keywords": ["engineer"],
"onlyNewJobs": true,
"monitorKey": "target-engineering",
"maxJobsPerCompany": 0,
"maxResults": 1000,
"requestDelayMs": 1000
}
Unlike the demonstration script, the Actor's first monitoring run returns current matching jobs, subject to limits. It still saves every fetched ID, including jobs hidden by filters or output caps. Later filter changes do not make old jobs new. Jobs hidden by an initial output cap are therefore not queued for later delivery.
State persists in the named ats-jobs-scraper-state key-value store. Complete successful fetches replace a board baseline; failed or incomplete fetches do not. A removed ID that later reappears is new again. Same-ID edits are not new jobs. Use a different monitor key to create an independent stream.
In Apify Console, save a task with this input, create a schedule for it, and choose its time zone. Configure a run-completion webhook or existing integration to read the dataset and route notifications. Apify: schedules and Apify: webhooks describe those platform features. The Actor produces records; a notification destination needs separate setup.
Keep runs serial per monitor key and check RUN_SUMMARY. An empty dataset can mean no matching new jobs, while a failure needs investigation. State is a comparison baseline, not a complete archive or a delivery guarantee.
Frequently asked questions
Will a changed job title trigger an alert?
Not in this ID-based workflow. Compare content fingerprints separately if you need update alerts.
What does the first run return?
The sample script saves a silent baseline. The Actor returns existing matching jobs on its first monitoring run, subject to output limits.
Does the Actor send email by itself?
It produces dataset records. Configure a webhook or an existing notification integration to route those records.
Can daily checks miss a job?
Yes. A role posted and removed between checks may never be observed. Choose a suitable interval without promising complete coverage.
Sources
Primary documentation and official authorities reviewed for this guide. Live responses are observations on the review date; source policies and laws can change.
- Greenhouse: Job Board API
- Lever: official Postings API documentation
- Python: standard library reference
- Cron: upstream crontab manual
- Apify: schedules
- Apify: webhooks
- RFC 9309
Tool inputs, outputs and limitations: ATS Jobs Scraper documentation.