DEV Community

SteadyScrape
SteadyScrape

Posted on

How to get a Slack alert when a company posts a new job (Greenhouse, Lever, Ashby) with n8n or Python

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

  1. Install the verified community node Apify (@apify/n8n-nodes-apify) and add an Apify credential with your API token.
  2. 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 (changeType is not removed; the workflow stops here when nothing is new) → Code (build one message, below) → Slack: Send message.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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}` } }];
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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)
...
Enter fullscreen mode Exit fullscreen mode

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, departments or remoteOnly. Filtered-out jobs aren't billed.
  • Leave includeDescription off 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)