When a company opens three sales roles and a RevOps lead, it has budget and a plan. Hiring is one of the most honest buying signals there is. The trouble is that it lives on a few hundred careers pages, and nobody checks those by hand.
Most careers pages run on an applicant tracking system (ATS), and the big three, Greenhouse, Lever and Ashby, publish every open job through a public JSON API. No login and no key. This post wires those APIs into a Google Sheet that refreshes itself every morning and logs which jobs are new and which ones closed.
1. Find the board name
Open a company's careers page and click any job. The link tells you the ATS and the board name:
| Link looks like | Board entry |
|---|---|
job-boards.greenhouse.io/stripe/... | greenhouse:stripe |
jobs.lever.co/spotify/... | lever:spotify |
jobs.ashbyhq.com/ramp/... | ashby:ramp |
Make a tab called Companies and put one board per row in column A, starting at A2.
2. The three APIs
These are the endpoints the script calls. You can open any of them in a browser to see the raw data:
- Greenhouse:
https://boards-api.greenhouse.io/v1/boards/stripe/jobs - Lever:
https://api.lever.co/v0/postings/spotify?mode=json - Ashby:
https://api.ashbyhq.com/posting-api/job-board/ramp
Each one returns every open job with an ID, a title, a location and a link. The ID is what makes change tracking possible: if an ID shows up that we haven't seen, the job is new, and if one we've seen disappears, it closed.
3. The script
In the sheet, open Extensions → Apps Script, paste this in, and save.
const HEADERS = ['Company', 'Job ID', 'Title', 'Location', 'Link']; function refreshJobs() { const ss = SpreadsheetApp.getActive(); const boards = ss.getSheetByName('Companies').getRange('A2:A').getValues() .map(function (r) { return String(r[0]).trim(); }) .filter(String); const jobs = []; boards.forEach(function (entry) { const parts = entry.split(':'); try { fetchBoard(parts[0].trim().toLowerCase(), parts[1].trim()).forEach(function (row) { jobs.push(row); }); } catch (e) { console.warn(entry + ': ' + e.message); } }); writeSheet(ss, 'Jobs', HEADERS, jobs); logChanges(ss, jobs); } function fetchBoard(ats, slug) { const urls = { greenhouse: 'https://boards-api.greenhouse.io/v1/boards/' + slug + '/jobs', lever: 'https://api.lever.co/v0/postings/' + slug + '?mode=json', ashby: 'https://api.ashbyhq.com/posting-api/job-board/' + slug, }; if (!urls[ats]) throw new Error('unknown job board "' + ats + '"'); if (ats === 'lever') Utilities.sleep(1000); // Lever's robots.txt asks for 1 request a second const res = UrlFetchApp.fetch(urls[ats], { muteHttpExceptions: true }); if (res.getResponseCode() !== 200) throw new Error('HTTP ' + res.getResponseCode()); const data = JSON.parse(res.getContentText()); if (ats === 'greenhouse') { return data.jobs.map(function (j) { return [slug, String(j.id), j.title, (j.location && j.location.name) || '', j.absolute_url]; }); } if (ats === 'lever') { return data.map(function (j) { return [slug, j.id, j.text, (j.categories && j.categories.location) || '', j.hostedUrl]; }); } return data.jobs.filter(function (j) { return j.isListed !== false; }).map(function (j) { return [slug, j.id, j.title, j.location || '', j.jobUrl]; }); } function logChanges(ss, jobs) { const seenSheet = ss.getSheetByName('Seen') || ss.insertSheet('Seen'); const before = {}; seenSheet.getDataRange().getValues().slice(1).forEach(function (r) { if (r[1]) before[r[0] + '|' + r[1]] = r; }); const now = {}; jobs.forEach(function (r) { now[r[0] + '|' + r[1]] = r; }); const today = new Date(); const changes = []; Object.keys(now).forEach(function (k) { if (!before[k]) changes.push([today, 'new'].concat(now[k])); }); Object.keys(before).forEach(function (k) { if (!now[k]) changes.push([today, 'closed'].concat(before[k])); }); const firstRun = Object.keys(before).length === 0; if (!firstRun && changes.length) { const log = ss.getSheetByName('Changes') || ss.insertSheet('Changes'); if (log.getLastRow() === 0) log.appendRow(['Date', 'Change'].concat(HEADERS)); log.getRange(log.getLastRow() + 1, 1, changes.length, changes[0].length).setValues(changes); } writeSheet(ss, 'Seen', HEADERS, jobs); } function writeSheet(ss, name, headers, rows) { const sheet = ss.getSheetByName(name) || ss.insertSheet(name); sheet.clearContents(); sheet.getRange(1, 1, 1, headers.length).setValues([headers]); if (rows.length) sheet.getRange(2, 1, rows.length, headers.length).setValues(rows); } Pick refreshJobs in the function menu and click Run. Google asks you to approve access to the sheet and to outside URLs the first time. You get three tabs:
- Jobs: every open job right now.
- Seen: the script's memory of the last run.
- Changes: one row per job that opened or closed, with the date. It stays empty after the first run, because the first run is the baseline.
4. Run it every morning
In Apps Script, open Triggers (the clock icon), add a trigger for refreshJobs, and choose Time-driven → Day timer. From then on the Changes tab fills itself. Sort it by date and you have a daily list of accounts that just started hiring.
A few honest limits:
- It only knows the three job boards above. Workable, Recruitee, Workday and companies with home-grown careers pages need their own code.
- You have to look up each board name by hand.
- Apps Script has daily quotas on outside requests. A few dozen companies a day is fine, but a few hundred will hit them.
- It keeps the title and location only. Department, remote or hybrid, and pay ranges are in the APIs if you want to add columns.
5. When the list gets long
Those limits are why I packaged the same idea as a tool on Apify. You give it domains instead of board names (it finds the board for you), it covers Workable and Recruitee too, and it tags each job by function (sales, engineering and so on) and seniority, with pay ranges when they're published. It can return only the new and closed jobs since the last run, and it costs $1 per 1,000 jobs.
You can still finish in Sheets. One formula pulls the latest run's results into a tab:
=IMPORTDATA("https://api.apify.com/v2/acts/hiringsignals~ats-hiring-signal-monitor/runs/last/dataset/items?format=csv&status=SUCCEEDED&fields=company,title,changeType,function,seniority,department,postedAt,url&token=YOUR_TOKEN") Keep that sheet private, because the token is visible to anyone who can open it.
- Try it on a sales account list: https://apify.com/hiringsignals/ats-hiring-signal-monitor/examples/sales-hiring-signals?utm_source=devto&utm_medium=article&utm_campaign=sheets-howto-2026-11
- The tool itself: https://apify.com/hiringsignals/ats-hiring-signal-monitor?utm_source=devto&utm_medium=article&utm_campaign=sheets-howto-2026-11
Disclosure: I built the Apify tool. The script above is free and yours to keep either way.