Skip to main content

Build a Daily Job Alerts Feed in Python (New Jobs Only, Straight from Company Career Sites)

About 50 lines of Python and one API call a day: only the jobs posted since your last run, with salary filters.

Updated 6 min readMarkdown

Disclosure: I built the Job Search API used in this tutorial. It has a free plan, and the script works with it.

Job boards send alerts, but they're noisy: reposts, staffing agencies, the same job in ten cities, and listings that closed weeks ago. I wanted something simpler: every morning, show me only the jobs posted since yesterday, straight from the companies' own career sites, above a salary I choose.

This tutorial builds that in about 50 lines of Python. One API call (or a few, for busy searches) per run, a tiny state file, and a cron entry.

What you'll build

$ python job_alerts.py
Senior Analytics Engineer @ Betterup (Austin, TX) | 158,000-220,000 USD
  https://jobs.ashbyhq.com/betterup/72ad3a02-6d5c-47b5-9eae-1a39040f0bf9
Analytics Engineer @ Lovable (Boston) | 141,500-192,000 USD
  https://jobs.ashbyhq.com/lovable/d92292d5-a9f5-4637-9d86-77e95bf66970
Senior Data Engineer @ Headway (New York City) | 180,000-225,000 USD
  https://jobs.ashbyhq.com/headway/75017946-1062-40fb-a78f-9e1edf5424a7
...

15 new jobs since 2026-10-16

The next run prints only what's been posted since then. (Real output from the script below, trimmed.)

Where the jobs come from

Most companies don't publish jobs on their own website from scratch. They use an applicant tracking system (Greenhouse, Lever, Ashby, Workday and others), and those systems expose public job-board feeds. The Job Search API indexes those feeds for 16,000+ companies every day (about 700,000 open jobs), so you can search all of them at once:

  • Employer-direct: no reposts or agency copies; each job links to the company's own apply page.
  • Duplicates folded: the same job posted in five cities shows up once, with a similarPostings count.
  • Salary filter: min_salary works on structured salaries and on ranges written in the description, in any currency.
  • posted_after: returns only jobs posted after a timestamp. This one parameter is what makes alerts easy.

Step 1: get a key

  1. Open the API on RapidAPI and subscribe to a plan (the free plan is enough to try this; it returns up to 10 jobs per call).
  2. Copy your X-RapidAPI-Key and put it in an environment variable:
pip install requests
export RAPIDAPI_KEY="your-key-here"

Decide what you want to be alerted about. Every parameter is optional:

SEARCH = {
    "keywords": "data engineer,analytics engineer",  # any of these, whole words in the job title
    "locations": "United States",
    "remote": "true",
    "min_salary": 120000,
    "salary_currency": "USD",                          # hourly/monthly pay is converted to yearly
    "limit": 50,                                       # jobs per page (1-50)
}

Other useful filters: exclude (e.g. intern,principal), seniority (entry, mid, senior, lead...), has_salary=true to skip jobs without pay info, and ats to limit the search to certain systems.

Step 3: fetch only new jobs

posted_after does the heavy lifting. Responses are newest first and paginated with a cursor, so we follow meta.nextCursor until it runs out:

def new_jobs(since: str) -> list[dict]:
    params, jobs = {**SEARCH, "posted_after": since}, []
    while True:
        page = requests.get(URL, headers=HEADERS, params=params, timeout=90)
        page.raise_for_status()
        data = page.json()
        jobs += data["jobs"]
        if not data["meta"].get("nextCursor"):
            return jobs
        params["cursor"] = data["meta"]["nextCursor"]

On paid plans each result is also re-checked against the employer's board at request time, so closed jobs are dropped. That's why a page can take a few seconds instead of one.

Step 4: remember the last run

A one-line JSON file stores when we last checked. The first run looks back a week; after that, each run asks only for what's new:

def main() -> None:
    now = datetime.now(timezone.utc)
    first_run = (now - timedelta(days=7)).strftime("%Y-%m-%d")  # first run: the last week
    since = json.loads(STATE.read_text())["since"] if STATE.exists() else first_run
    jobs = new_jobs(since)
    for j in jobs:
        low, high = j.get("salaryMin"), j.get("salaryMax") or j.get("salaryMin")
        pay = f"{low:,.0f}-{high:,.0f} {j['salaryCurrency']}" if low else "salary n/a"
        print(f"{j['title']} @ {j['company']} ({j['location']}) | {pay}\n  {j['url']}")
    print(f"\n{len(jobs)} new jobs since {since}")
    STATE.write_text(json.dumps({"since": now.strftime("%Y-%m-%dT%H:%M:%SZ")}))

The whole script

"""Daily job alerts: print the jobs posted since the last run that match your search."""

import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path

import requests

HOST = "job-search-api-career-site-jobs.p.rapidapi.com"
URL = f"https://{HOST}/search"
HEADERS = {"X-RapidAPI-Key": os.environ.get("RAPIDAPI_KEY", ""), "X-RapidAPI-Host": HOST}
STATE = Path("last_run.json")

SEARCH = {
    "keywords": "data engineer,analytics engineer",
    "locations": "United States",
    "remote": "true",
    "min_salary": 120000,
    "salary_currency": "USD",
    "limit": 50,
}


def new_jobs(since: str) -> list[dict]:
    params, jobs = {**SEARCH, "posted_after": since}, []
    while True:
        page = requests.get(URL, headers=HEADERS, params=params, timeout=90)
        page.raise_for_status()
        data = page.json()
        jobs += data["jobs"]
        if not data["meta"].get("nextCursor"):
            return jobs
        params["cursor"] = data["meta"]["nextCursor"]


def main() -> None:
    now = datetime.now(timezone.utc)
    first_run = (now - timedelta(days=7)).strftime("%Y-%m-%d")  # first run: the last week
    since = json.loads(STATE.read_text())["since"] if STATE.exists() else first_run
    jobs = new_jobs(since)
    for j in jobs:
        low, high = j.get("salaryMin"), j.get("salaryMax") or j.get("salaryMin")
        pay = f"{low:,.0f}-{high:,.0f} {j['salaryCurrency']}" if low else "salary n/a"
        print(f"{j['title']} @ {j['company']} ({j['location']}) | {pay}\n  {j['url']}")
    print(f"\n{len(jobs)} new jobs since {since}")
    STATE.write_text(json.dumps({"since": now.strftime("%Y-%m-%dT%H:%M:%SZ")}))


if __name__ == "__main__":
    main()

Step 5: run it every morning

With cron (Linux/macOS), every day at 8:00:

0 8 * * * cd /path/to/alerts && RAPIDAPI_KEY=your-key /usr/bin/python3 job_alerts.py >> alerts.log 2>&1

On Windows, Task Scheduler does the same. Each page is one request: a focused search needs one or two per run (a few more on the free plan, which returns 10 jobs per page), so a daily run fits comfortably in the free plan's 250 requests a month.

Ideas to take it further

  • Send it somewhere: post the lines to a Slack or Discord webhook, or email them, instead of printing.
  • Several searches: keep a list of SEARCH dicts (one per role or city) with a state file each.
  • Watch specific companies: the /company endpoint lists every open job at one company from its name, domain or careers URL (company=stripe.com), including Workday and Oracle career sites.
  • Hiring signals: count new jobs per company per week to see who's ramping up.

Wrap-up

That's the whole thing: one parameter (posted_after), a cursor loop and a timestamp file. Because the data comes from the employers' own career sites, the alerts skip the reposts and agency noise you get from job boards.

The API is also listed on Zyla API Hub if you'd rather subscribe there. Questions or feature requests are welcome in the comments.

No comments yet. Be the first to comment.