Track new and closed jobs at your target accounts, daily
Hiring signals from the public job boards of Greenhouse, Lever, Ashby, Recruitee and Teamtailor: a free Python script that reports new and closed jobs, then how to turn it into a daily Slack or Sheets digest in n8n.
Requests and scripts tested on 26 October 2026.
When a company opens six data roles in a week, or quietly closes its whole sales hiring, that tells you something before any press release does. Most companies publish their open jobs through an applicant tracking system (ATS), and five of the common ones put those jobs behind public, documented endpoints that need no API key. If you check those endpoints every day and keep yesterday's list, the difference between the two is a hiring signal.
This guide shows the whole method with free code first. The second half shows how we automate it with an Apify Actor and an n8n workflow.
Disclosure: Quarrywise, the publisher of this site, built and sells the Apify Actor described in the second half. The free script in the first half needs nothing from us.
Why job postings are a buying signal
A job posting is a public statement of what a company is about to spend money on. Some patterns worth watching:
- A new leadership role such as "Head of Data" or "VP Security" often means a new budget owner who will choose tools in the next few months.
- A burst of roles in one team (ten new sales roles, five data engineers) shows where a company is growing.
- Closed jobs matter too: a role that closes after two weeks was probably filled, and a department whose roles all close at once may be freezing.
- Recruiters use the same data the other way round: which target employers just opened roles that fit their candidates.
None of this needs scraping. It needs a list of companies, the public job board of each, and a memory of what you saw last time.
Where the data comes from: five public job-board endpoints
Each of these systems documents a read-only endpoint for the jobs an employer already shows on its careers page. None needs a key, and none exposes candidates.
| System | Endpoint (one board) | Job ID field | Paging |
|---|---|---|---|
| Greenhouse | boards-api.greenhouse.io/v1/boards/{token}/jobs |
id |
none, whole board |
| Lever | api.lever.co/v0/postings/{site}?mode=json (EU: api.eu.lever.co) |
id |
skip and limit |
| Ashby | api.ashbyhq.com/posting-api/job-board/{name} |
id |
none, whole board |
| Recruitee | {company}.recruitee.com/api/offers/ |
id |
none, whole board |
| Teamtailor | {company}.teamtailor.com/jobs.rss (RSS, not JSON) |
guid |
per_page and offset |
We cover Greenhouse in depth in Greenhouse Job Board API: list every open job for a company, and Ashby and Lever in Ashby and Lever job posting APIs: pull open roles as JSON, including pay ranges, Lever's EU host and the fields worth keeping. Recruitee documents its endpoint as the Careers Site API, and Teamtailor publishes an RSS feed of each career site.
The board name is usually the last part of the careers URL: job-boards.greenhouse.io/figma,
jobs.lever.co/zoox, jobs.ashbyhq.com/ramp, channable.recruitee.com or
career.teamtailor.com. Companies on Workday, SmartRecruiters, Workable, Personio or a
home-made careers page are not covered by these five endpoints.
A free script: new and closed jobs at five companies
The script below watches one company on each of the five systems. It uses only the Python standard library (we ran it on Python 3.14). On the first run it saves every open job as a baseline. On later runs it prints the jobs that appeared and the jobs that disappeared since the previous run.
import http.client, json, pathlib, time, urllib.request
import xml.etree.ElementTree as ET
from datetime import date
UA = "my-hiring-signals/1.0 (you@example.com)" # say who you are
WATCHLIST = [ # (company, system, board name)
("Figma", "greenhouse", "figma"),
("Zoox", "lever", "zoox"),
("Ramp", "ashby", "ramp"),
("Channable", "recruitee", "channable"),
("Teamtailor", "teamtailor", "career"),
]
KEYWORDS = [] # for example ["engineer", "data"]; empty keeps every job
STATE = pathlib.Path("hiring_state.json")
def get(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read()
def greenhouse(board):
data = json.loads(get(f"https://boards-api.greenhouse.io/v1/boards/{board}/jobs"))
return {str(j["id"]): [j["title"], j["absolute_url"]] for j in data["jobs"]}
def lever(board):
jobs, skip = {}, 0
while True:
url = f"https://api.lever.co/v0/postings/{board}?mode=json&skip={skip}&limit=100"
page = json.loads(get(url))
jobs.update({j["id"]: [j["text"], j["hostedUrl"]] for j in page})
if len(page) < 100:
return jobs
skip += 100
time.sleep(1) # Lever's robots.txt asks for Crawl-delay: 1
def ashby(board):
data = json.loads(get(f"https://api.ashbyhq.com/posting-api/job-board/{board}"))
return {j["id"]: [j["title"], j["jobUrl"]] for j in data["jobs"] if j.get("isListed", True)}
def recruitee(board):
data = json.loads(get(f"https://{board}.recruitee.com/api/offers/"))
return {str(o["id"]): [o["title"], o["careers_url"]] for o in data["offers"]}
def teamtailor(board):
jobs, offset = {}, 0
while True:
url = f"https://{board}.teamtailor.com/jobs.rss?per_page=100&offset={offset}"
items = ET.fromstring(get(url)).findall("./channel/item")
jobs.update({i.findtext("guid"): [i.findtext("title"), i.findtext("link")] for i in items})
if len(items) < 100:
return jobs
offset += 100
time.sleep(1)
FETCH = {"greenhouse": greenhouse, "lever": lever, "ashby": ashby,
"recruitee": recruitee, "teamtailor": teamtailor}
state = json.loads(STATE.read_text()) if STATE.exists() else {}
today = date.today().isoformat()
for company, system, board in WATCHLIST:
key = f"{system}:{board}"
try:
jobs = FETCH[system](board)
except (OSError, http.client.HTTPException, ValueError, ET.ParseError) as err:
print(f"{company}: skipped ({err}); nothing marked closed")
continue
if KEYWORDS:
jobs = {i: j for i, j in jobs.items() if any(k in j[0].lower() for k in KEYWORDS)}
old = state.get(key)
if old is None:
print(f"{company}: baseline of {len(jobs)} open jobs saved")
else:
new_ids, closed_ids = jobs.keys() - old.keys(), old.keys() - jobs.keys()
print(f"{company}: {len(new_ids)} new, {len(closed_ids)} closed, {len(jobs)} open")
for i in sorted(new_ids):
print(f" NEW {jobs[i][0]} {jobs[i][1]}")
for i in sorted(closed_ids):
print(f" CLOSED {old[i][0]} (gone on {today})")
state[key] = jobs
STATE.write_text(json.dumps(state, indent=1)) # save after each board
time.sleep(1) # be polite between boards
Our first run printed:
Figma: baseline of 138 open jobs saved
Zoox: baseline of 236 open jobs saved
Ramp: baseline of 167 open jobs saved
Channable: baseline of 14 open jobs saved
Teamtailor: baseline of 14 open jobs saved
A second run a few minutes later reported 0 new, 0 closed for every board. We then removed
one Figma job from the state file by hand and added a fake one, and the next run printed:
Figma: 1 new, 1 closed, 138 open
NEW Account Executive, Mid-Market (London, United Kingdom) https://boards.greenhouse.io/figma/jobs/5415515004?gh_jid=5415515004
CLOSED Example closed role (gone on 2026-10-26)
A board name that does not exist (we tried an invented Ashby name) printed
skipped (HTTP Error 404: Not Found); nothing marked closed and left that board's saved jobs
untouched. Schedule the script once a day with cron or Windows Task Scheduler and send its output
wherever you read things.
Handling the first run, new companies and closed jobs
A change tracker is only useful if it does not cry wolf. Four rules keep the script honest, and they are worth keeping in any tool you build:
- The first run is a baseline. Without it, every job that already exists shows up as "new". The same applies when you add a company later: its first read is a baseline too, which the script does because the company has no saved state yet.
- A failed request is not "every job closed". The script skips a board on a network, HTTP or parse error (timeouts included) and keeps its saved jobs, so a timeout never produces a wave of fake closures.
- Changing your filter changes the baseline. If you add
KEYWORDSafter a few runs, jobs that no longer match look closed, and jobs that now match look new. Delete the state file (or that board's entry) when you change filters. - Expect some noise. An employer that unpublishes a job and publishes it again later makes it show as closed and then new.
Be a polite client as well: send a User-Agent with a way to contact you, wait a second between boards (Lever's API hosts ask for one second in their robots.txt), and run it once a day, not once a minute. Hiring pages do not change that fast.
Doing it for many companies: our Actor
The script is enough for a handful of boards you already know. At 50 or 300 companies the work shifts to finding each company's board, keeping state somewhere other than a local file, and handling the five formats. That is what our Greenhouse, Lever, Ashby, Recruitee & Teamtailor Jobs API does. It reads the same five public endpoints shown above and nothing else: no logins, no keys, no candidate data.
- You give it 1 to 1,000 company names, domains or board URLs per run. It looks for each company's board on the five systems and reports which board it matched. Companies it cannot find are listed and free.
- In Changes mode with a watchlist ID, it keeps the previous run for you and returns
only rows with
change_typenew,updatedorclosed. The first run of a watchlist, and the first read of a company you add later, is a silent baseline. - Changing the filters records a new baseline quietly instead of reporting fake closures.
- Pay ranges are mapped to one set of fields where the employer publishes them.
This is how we call it from Python. The token is read from an environment variable, and
maxTotalChargeUsd caps what the run may charge:
import json, os, urllib.request
TOKEN = os.environ["APIFY_TOKEN"] # your Apify API token; never paste it into the code
ACTOR = "quarrywise~greenhouse-lever-ashby-jobs-api"
INPUT = {
"companies": ["Figma", "https://jobs.lever.co/zoox"],
"mode": "changes",
"watchlistId": "guide-demo",
"titleKeywords": ["data"],
}
url = (f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items"
"?clean=true&maxTotalChargeUsd=0.50") # cap what this run may charge
req = urllib.request.Request(
url, data=json.dumps(INPUT).encode(), method="POST",
headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=320) as resp: # the sync call waits up to 300 s
rows = json.load(resp)
print(len(rows), "changed jobs")
for r in rows:
print(r["change_type"], "|", r["company"], "|", r["title"], "|", r["job_url"])
The first run with watchlist guide-demo printed 0 changed jobs: that is the baseline,
which charged the two companies and no job rows. The second run also printed 0 changed jobs,
because nothing had changed in between. To see what rows look like, we ran the same input with a
fresh watchlist ID and "includeBaseline": true, which saves the current matching jobs as
new. It returned 25 rows, beginning:
25 changed jobs
new | Figma | Data Engineer Intern (2027) | https://boards.greenhouse.io/figma/jobs/6178851004?gh_jid=6178851004
new | Figma | Data Platform Engineer | https://boards.greenhouse.io/figma/jobs/6201407004?gh_jid=6201407004
new | Zoox | AV Safety Data Reporting Engineer | https://jobs.lever.co/zoox/e36cc53e-5bd5-43b9-9a90-ec9b7c6abda9
Notice Data Engineer Intern in the list: titleKeywords match anywhere in the title,
so add "-intern" to exclude interns. The synchronous call used here waits at most 300 seconds.
For a first run of hundreds of companies, start the run asynchronously and read the dataset when it
finishes, or schedule the Actor in Apify Console and let your workflow react when the run is done.
The n8n workflow, node by node
In n8n, the same thing becomes a daily digest in six nodes:
- Schedule Trigger: every morning.
- Set, "Your settings": your
companies,watchlistIdandtitleKeywords, in one place. - Apify, operation Run an Actor and Get Dataset (labels can differ
slightly between versions; use what your installed node shows). Set the Actor field to
By URL and paste
https://console.apify.com/actors/TkQ2FpRa9SELdahOH/input, or use the ID mode withTkQ2FpRa9SELdahOH. The input is JSON like theINPUTin the script above, built from "Your settings". This operation starts the run and polls it until it ends, so it is not bound by the 300-second limit of the synchronous API call. - IF:
change_typeisneworclosed. Addupdatedif you also want jobs whose title, location or pay range changed. - Code: group the rows per company into one message (below).
- Slack (send a message with the
textfield) or Google Sheets (append rows; for Sheets you can skip the Code node and append the rows as they are).
The Code node, in "Run Once for All Items" mode:
// n8n Code node, mode "Run Once for All Items": one Slack message for all changes
const label = { new: 'New', updated: 'Updated', closed: 'Closed' };
// Slack reserves &, < and >, so every text field goes through esc()
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// A job link is used only if it is a valid http or https URL. The parsed URL encodes spaces
// and line breaks; <, > and | are then percent-encoded, because they would break Slack's
// <url|text> link syntax.
const safeUrl = (value) => {
let url;
try {
url = new URL(String(value ?? ''));
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.href.replace(/</g, '%3C').replace(/>/g, '%3E').replace(/\|/g, '%7C');
};
// Pay range: amounts are used only if they are finite numbers
const pay = (job) => {
const amounts = [job.salary_min, job.salary_max].filter((v) => Number.isFinite(v));
if (!amounts.length) return '';
const range = esc([...new Set(amounts)].join('-'));
const currency = job.salary_currency ? `${esc(job.salary_currency)} ` : '';
const per = job.salary_interval ? ` per ${esc(job.salary_interval)}` : '';
return ` (${currency}${range}${per})`;
};
const line = (job) => {
const url = job.change_type === 'closed' ? null : safeUrl(job.job_url);
const name = esc(job.title || 'Untitled job');
const title = url ? `<${url}|${name}>` : name;
const where = job.location ? `, ${esc(job.location)}` : '';
return `${label[job.change_type] ?? 'Changed'}: ${title}${where}${pay(job)}`;
};
// A Map, so that company names such as "constructor" or "__proto__" are ordinary keys
const byCompany = new Map();
for (const { json: job } of $input.all()) {
const company = String(job.company || job.company_input || 'Unknown company');
if (!byCompany.has(company)) byCompany.set(company, []);
byCompany.get(company).push(line(job));
}
const text = [...byCompany]
.map(([company, lines]) => `*${esc(company)}* (${lines.length})\n${lines.join('\n')}`)
.join('\n\n');
return [{ json: { text } }];
Job titles, company names and locations come from employers' own job boards, so treat them as
untrusted text. The code escapes the &, < and > that Slack reserves
in every text field, including the pay range, uses a job link only if it is a valid
http or https URL, and encodes <, > and | inside the
link so that a title cannot break out of it. Pay amounts appear only when they are real numbers.
We ran this code outside n8n with a stand-in for $input, normal rows and deliberately
hostile ones (a javascript: link, a link with |, <, a space and
a line break, a <!channel> company name, companies named constructor and
__proto__, a missing title, amounts given as text), and checked the output. When the IF
node lets nothing through, n8n does not run the Code and Slack nodes, so quiet
days post nothing.
Setup notes:
- The Apify node is Apify's own n8n node (
@apify/n8n-nodes-apify), verified by n8n. On n8n Cloud, the instance owner first has to allow verified community nodes in the Cloud Admin Panel; then search the nodes panel for Apify and install it. It accepts an Apify API token or, on Cloud, OAuth2. Select the Actor By URL or by ID as above: the From list picker may not show it while the Actor is hidden from Store search. - Set a maximum cost per run on the Apify node. At $0.002 a company plus the expected rows, that is about $0.70 for 300 companies and about $2.20 for 1,000; a lower cap stops the run early.
- What we tested: the Actor calls from Python (above), and this Code node outside n8n. We also built the workflow as an importable file and ran it in a local n8n 2.44.7 against the live Apify API, with the Slack node switched off; the Apify, IF and Code nodes produced one digest message. Posting to Slack and n8n Cloud were not part of that test. We will link the file here once it is published; its Code node also splits long digests into messages of about 3,500 characters.
- The first scheduled run is the baseline and posts nothing. That is expected.
What it costs at 50, 300 and 1,000 companies
The Actor is pay per event, at the same prices on every Apify plan:
- $0.002 per company checked ($2.00 per 1,000)
- $0.0015 per job row saved ($1.50 per 1,000); in Changes mode only new, updated and closed jobs are rows
- free: companies with no board found, duplicates, boards that failed, and unchanged jobs
- plus Apify's standard Actor start event, a fraction of a cent per run
Platform compute is not billed to you on top of these prices. The number of changed jobs a day depends entirely on your companies, so the change counts below are examples, not measurements:
| Watchlist, run daily | Example changes a day | Per day | Per 30 days |
|---|---|---|---|
| 50 companies | 7 | about $0.11 | about $3.30 |
| 300 companies | 40 | about $0.66 | about $20 |
| 1,000 companies | 130 | about $2.20 | about $66 |
Things that change the bill:
- The baseline run charges every company but no job rows: $0.60 for 300 companies.
- Updated jobs are rows too. If your IF node drops
updatedrows, they are still charged. Narrow the Actor's own filters (titleKeywords,locations,departments) rather than filtering in n8n. - Apify's free plan includes $5 of usage a month, and runs stop until the next month once it is used up. The company fee alone passes $5 a month at about 80 companies checked daily, so the 50-company watchlist fits and the 300-company one lasts about a week. Check the current amount on Apify's pricing page.
- Weekly instead of daily cuts the company fee by seven. Closed jobs still show up, just a few days later.
- Set a maximum charge per run (the
maxTotalChargeUsdparameter, or the run options in Apify Console): the Actor stops cleanly at that limit.
Limits
- Five systems only. No LinkedIn. Companies on Workday, SmartRecruiters, Workable, Personio and others are reported as not found, not supported or not available, free.
- Name matching is a guess. A name can match another company that uses the same board
name, or miss a board with an unusual name. Check the per-company report (the
COMPANIESrecord) once, and paste the board URL for any company it matched wrongly. Board URLs always match exactly. - Closed jobs need the full board. Jobs are reported closed only when a company's board was read in full in that run; a board that failed or hit the per-company job cap reports no closures that day.
- Pay ranges appear only where the employer publishes them, and Teamtailor's feed has none.
- The endpoints belong to the vendors. They are public and documented today, and any vendor can change or close one. That applies to the free script as much as to the Actor.
FAQ
Do I need an API key for these job boards?
No. All five endpoints are public and read-only. Greenhouse's Harvest API, Lever's Data API and Ashby's main API do need keys, but they are private admin APIs for a company's own account, not for watching other companies.
Can I do this without paying anything?
Yes. The script above is free and is all you need for a few boards you know. The Actor is for when matching names to boards, storing state and covering five formats becomes the work.
How often should I check?
Once a day is plenty for sales signals, and once a week works for slower trends. More often adds load on the job boards and, with the Actor, cost, without telling you much more.
What about personal data?
Job descriptions sometimes name a recruiter or a hiring manager. The script keeps only IDs, titles and links. The Actor leaves descriptions out by default and screens its text fields for email addresses and phone numbers.
Quarrywise is independent and isn't affiliated with or endorsed by Greenhouse, Lever, Ashby, Recruitee, Teamtailor, Apify or n8n.