Whether you're job hunting, selling to companies that are hiring, or keeping an eye on competitors, "tell me when Stripe posts a new role" is a surprisingly annoying problem. Career pages don't have RSS. Job board alerts miss postings, duplicate them, and arrive days late.
Most companies host their career page on an applicant tracking system (ATS) like Greenhouse, Lever or Ashby, and those have public job feeds. The tricky part isn't fetching jobs. It's remembering which ones you've already seen, so the alert only contains new ones.
This post shows two ways to do it: an n8n workflow and a short Python script. Both use the ATS Job Feed Actor on Apify, which keeps the "already seen" state for you.
How the "only new" logic works
Three inputs do the work:
-
onlyNewSinceLastRun: true: return only jobs not returned before. -
stateStoreName: a named key-value store in your Apify account that remembers seen job IDs, one key per board. Use one name per watchlist. -
firstRunEmitsAll: false: the first run records every current job silently and returns nothing. You aren't charged for that baseline.
Removed jobs can be reported too (emitRemovedJobs), and those records are free.
Option 1: n8n
- Install the verified community node Apify (
@apify/n8n-nodes-apify) and add an Apify credential with your API token. - Build five nodes: Schedule Trigger (daily, 8:00) → Apify: Run an Actor and get dataset (Actor
steady-scrape/ats-job-feed, input JSON below) → Filter (changeTypeis notremoved; the workflow stops here when nothing is new) → Code (build one message, below) → Slack: Send message. - Run once to save the baseline, then activate the workflow.
Apify node input:
{
"companies": [
"https://job-boards.greenhouse.io/stripe",
"https://jobs.lever.co/spotify",
"https://jobs.ashbyhq.com/ramp"
],
"onlyNewSinceLastRun": true,
"firstRunEmitsAll": false,
"stateStoreName": "my-target-companies",
"includeDescription": false,
"maxJobsPerCompany": 200
}
Code node (JavaScript, "Run once for all items"):
const jobs = $input.all().map(i => i.json);
const byCompany = {};
for (const j of jobs) (byCompany[j.company] ??= []).push(j);
const text = Object.entries(byCompany).map(([company, list]) =>
`*${company}* (${list.length})\n` +
list.map(j => `• <${j.jobUrl}|${j.title}> (${j.locationText || 'n/a'})`).join('\n')
).join('\n\n');
return [{ json: { text: `${jobs.length} new jobs\n\n${text}` } }];
In the Slack node, set the message text to {{ $json.text }}. To keep a log as well, add a Google Sheets: Append row node after the Filter and match on jobId.
Option 2: Python
import os, requests
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL") # optional
run = client.actor("steady-scrape/ats-job-feed").call(logger=None, run_input={
"companies": [
"https://job-boards.greenhouse.io/stripe",
"https://jobs.lever.co/spotify",
"https://jobs.ashbyhq.com/ramp",
],
"onlyNewSinceLastRun": True,
"firstRunEmitsAll": False, # first run = silent, free baseline
"stateStoreName": "my-target-companies",
"includeDescription": False,
"maxJobsPerCompany": 200,
})
new_jobs = [j for j in client.dataset(run.default_dataset_id).iterate_items()
if j.get("changeType") != "removed"]
lines = [f'• *{j["company"]}*: <{j["jobUrl"]}|{j["title"]}> ({j.get("locationText") or "n/a"})'
for j in new_jobs]
print(f"{len(new_jobs)} new jobs")
print("\n".join(lines))
if SLACK_WEBHOOK and lines:
requests.post(SLACK_WEBHOOK, json={"text": f"{len(lines)} new jobs\n" + "\n".join(lines)}, timeout=30)
Set SLACK_WEBHOOK_URL to a Slack incoming webhook if you want the message posted, then schedule the script with cron or GitHub Actions. Keep stateStoreName the same between runs. That's what makes the second run return only new jobs.
What happened when I ran it
All times Costa Rica (UTC−6), Nov 1, 2026:
- 6:14 PM, baseline run. Stripe (Greenhouse), Spotify (Lever) and Ramp (Ashby). It recorded 1,006 current jobs (Stripe 746, Ramp 172, Spotify 88) in 4.5 seconds and returned 0. Cost: the $0.00005 Actor start.
- 6:25 PM, the same script again. 0 new jobs, nothing posted to Slack. Cost: $0.00005 again. (It was a Sunday evening. On a weekday these three companies post several roles a day.)
To see what a non-empty alert looks like without waiting, I ran the same companies with postedWithinDays: 3 and maxJobsPerCompany: 4 instead of the state options. That returned 8 jobs (4 from Ramp, 4 from Stripe, none from Spotify), for example:
8 new jobs
• Ramp: Manager, Enterprise Technical Consulting (New York, NY (HQ))
• Stripe: Blockchain Security Engineer, Bridge (Luxembourg)
...
Cost at the free-plan list price: 8 × $0.0015 + $0.00005 = $0.012. In general you pay $0.0015 per new job and nothing for jobs already delivered, so a watchlist that gets ~10 new roles a day costs roughly $0.45 a month (estimate: 300 jobs × $0.0015, plus ~$0.0015 in Actor starts).
Tips
- Paste whatever career URL you have. The Actor detects the ATS, and also reads Workday, SmartRecruiters, Workable and others.
- Filter early with
keywords,excludeKeywords,locations,departmentsorremoteOnly. Filtered-out jobs aren't billed. - Leave
includeDescriptionoff for alerts. It makes runs faster, and the job URL is enough to click through. - Each job has a stable
jobId, so dedupe on it if you also append jobs to a sheet or CRM.
Disclosure: SteadyScrape publishes the Actor used in this post. Numbers are from real runs on Nov 1, 2026.
Top comments (0)