DEV Community

Loukas Tzekos
Loukas Tzekos

Posted on

Track which companies are hiring (and closing roles) with a jobs API

Disclosure: I build JOA, the jobs API used below. The code runs on its free plan and I tested it against the live API in early October 2026.

Counting live job postings is easy. Knowing which companies are hiring more and which roles just disappeared is more interesting, and most job feeds can't tell you the second part because a closed role simply vanishes.

The Job Opportunities API keeps postings after they leave their source, with a closure date and a reason. This post builds a small hiring tracker on top of that:

  • a daily snapshot of each company's open-role count (so you can see movement over time),
  • the roles posted in the last week,
  • the roles that closed in the last week, with the closure reason,
  • a rough "days listed" figure that is honest about where it came from.

If you have not used the API before, the first post in this pair covers getting a key, the client basics and pagination. This one builds on it. It uses three endpoints that work on the free Explore plan: /v1/companies/{slug}, /v1/jobs and /v1/jobs/closed. (Get a free key at /login, no card; the key goes in JOA_API_KEY.)

What "closed" means here

Each closed row has closed_at and closed_reason. The documentation is explicit about the reasons:

  • expired_upstream: the ledger proved the vacancy dead (re-checked against the board listing or the job URL itself).
  • not_seen: the role stopped appearing at its source for the grace period. Usually a closure, occasionally a careers site that broke.
  • employer_closed / deadline_passed: stated by the employer itself, for roles listed through Erioun for Teams.

A role an employer merely paused or unlisted is not a closure. Keep that in mind: a closure means "gone from the source", not "filled".

The tracker

Save as hiring_tracker.py (only requests and the standard library):

"""Hiring tracker: which companies are hiring, and which roles are closing.

Runs on the free JOA Explore plan. Needs: Python 3.9+, `pip install requests`, key in JOA_API_KEY.
Each run spends: 1 record per company (the snapshot) + the rows you read from the two lists.
"""
import os
import random
import sqlite3
import statistics
import sys
import time
from datetime import date, datetime, timedelta, timezone

import requests

API = "https://api.jobopportunitiesapi.org"
WATCHLIST = ["spotify", "adyen", "zalando", "n26"]  # company slugs, see /v1/companies?q=
WINDOW_DAYS = 7
SAMPLE = int(os.environ.get("SAMPLE", "10"))  # rows read per list per company = records spent

session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['JOA_API_KEY']}"


def get(path, **params):
    for attempt in range(4):
        resp = session.get(API + path, params=params, timeout=30)
        if resp.status_code in (429, 503):
            time.sleep(int(resp.headers.get("Retry-After", 2 ** attempt)) + random.random())
            continue
        if resp.status_code == 402:
            sys.exit("This month's free records are spent. Wait for the 1st or upgrade.")
        if not resp.ok:
            sys.exit(f"{resp.status_code} {resp.json().get('message', resp.text)}")
        return resp
    sys.exit("Still rate limited after 4 tries.")


def ts(value):
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


db = sqlite3.connect("hiring.db")
db.execute(
    "CREATE TABLE IF NOT EXISTS snapshot ("
    " day TEXT, slug TEXT, open_roles INTEGER, PRIMARY KEY (day, slug))"
)


def snapshot(slug):
    """Today's open-role count for one company (1 record), stored so tomorrow can diff against it."""
    company = get(f"/v1/companies/{slug}").json()["data"]
    today = date.today().isoformat()
    previous = db.execute(
        "SELECT day, open_roles FROM snapshot WHERE slug=? AND day<? ORDER BY day DESC LIMIT 1",
        (slug, today),
    ).fetchone()
    db.execute("INSERT OR REPLACE INTO snapshot VALUES (?,?,?)", (today, slug, company["open_roles"]))
    db.commit()
    return company, previous


def days_listed(job):
    """Whole days between the posting and its closure.

    Use the employer's own posted_at when the field is 'published'; otherwise fall back to
    first_seen_at (when the ledger first saw it), which can only under-count.
    """
    start = job["posted_at"] if job["field_sources"].get("posted_at") == "published" else job["first_seen_at"]
    return max((ts(job["closed_at"]) - ts(start)).days, 0)


def report(slug):
    since = (datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS)).strftime("%Y-%m-%dT%H:%M:%SZ")
    company, previous = snapshot(slug)
    now = company["open_roles"]
    delta = f"{now - previous[1]:+d} since {previous[0]}" if previous else "first snapshot, no comparison yet"
    print(f"\n{company['name']}: {now} open roles ({delta})")

    fresh = get("/v1/jobs", company=slug, posted_after=since, limit=SAMPLE).json()
    more = "+" if fresh["has_more"] else ""
    print(f"  posted in the last {WINDOW_DAYS} days: {len(fresh['data'])}{more}")
    for job in fresh["data"][:3]:
        print(f"    + {job['title'][:60]}")

    closed = get("/v1/jobs/closed", company=slug, closed_after=since, limit=SAMPLE).json()
    more = "+" if closed["has_more"] else ""
    print(f"  closed in the last {WINDOW_DAYS} days: {len(closed['data'])}{more}")
    for job in closed["data"][:3]:
        print(f"    - {job['title'][:52]}  [{job['closed_reason']}, listed ~{days_listed(job)}d]")
    if closed["data"]:
        median = statistics.median(days_listed(j) for j in closed["data"])
        print(f"  median days listed (sample of {len(closed['data'])}): {median}")


if __name__ == "__main__":
    for slug in WATCHLIST:
        report(slug)
    spent = get("/v1/me").json()
    print(f"\nplan={spent['plan']}")
Enter fullscreen mode Exit fullscreen mode

Find slugs with /v1/companies?q=<name>. The ones above are real company slugs in the ledger.

Real output

spotify: 84 open roles (first snapshot, no comparison yet)
  posted in the last 7 days: 9
    + Partner Manager - Lifestyle & Wellness
    + Program Manager - Podcast Sales Strategy & Solutions
    + Senior Software Engineer
  closed in the last 7 days: 10+
    - Security Engineer - Detection and Response  [not_seen, listed ~7d]
    - C++ Engineer - Experience  [not_seen, listed ~27d]
    - Marketing Technology Manager  [not_seen, listed ~75d]
  median days listed (sample of 10): 28.0

Adyen: 68 open roles (first snapshot, no comparison yet)
  posted in the last 7 days: 10+
    + Team Lead - Technical Support
    + Finance Support Specialist
    + Implementation Engineer
  closed in the last 7 days: 10+
    - Senior System Administrator [Storage Engineer]  [not_seen, listed ~127d]
    - Account Manager  [not_seen, listed ~318d]
    - Senior System Administrator [Ceph Engineer]  [not_seen, listed ~127d]
  median days listed (sample of 10): 78.5

Zalando: 93 open roles (first snapshot, no comparison yet)
  posted in the last 7 days: 2
    + Senior Applied Scientist (all genders)
    + Prozessmanager (all genders) Warehouse Operations Lahr
  closed in the last 7 days: 10+
    - Senior ML Software Engineer - Growth & Lifecycle / L  [not_seen, listed ~65d]
    - Creator Manager - Sports (all genders)  [not_seen, listed ~45d]
    - Senior Principal Product Manager – Client Foundation  [not_seen, listed ~89d]
  median days listed (sample of 10): 59.5

N26: 45 open roles (first snapshot, no comparison yet)
  posted in the last 7 days: 9
    + Head of Business Internal Audit
    + Senior Associate Audit & Operations
    + Steuerberater (Tax Manager)
  closed in the last 7 days: 10+
    - Backend Engineer - Investments & Savings  [not_seen, listed ~98d]
    - Senior Cloud Security Engineer  [not_seen, listed ~67d]
    - iOS Engineer - Digital Identity  [not_seen, listed ~56d]
  median days listed (sample of 10): 42.0

plan=explore
Enter fullscreen mode Exit fullscreen mode

(A 10+ means the sample cap was hit. Raise SAMPLE if you want more rows and have the records to spend.)

Details that make it more honest

Days listed uses provenance. Every row carries field_sources. When posted_at is published, the employer's own date is used. Otherwise the code falls back to first_seen_at (when the ledger first saw the role), which can only under-count. The fallback is stated in the docstring so nobody mistakes the median for a measured time-to-fill. It is a rough indicator over a sample, nothing more.

The delta column needs a second day. The first run has nothing to compare against, so it says so. To check the diff logic, I edited a snapshot row for yesterday by hand and re-ran: the output read 84 open roles (+4 since <yesterday>). From the second real day on, you get the movement for free.

Staying inside the free allowance

On Explore, one row returned is one record, and the allowance is 1,000 a month. With four companies and SAMPLE=10, one run costs at most 4 x (1 + 10 + 10) = 84 records, so a weekly run uses roughly a third of the month. The key lever is SAMPLE: reading three rows instead of ten per list makes a run a handful of records. /v1/me shows your usage and costs nothing.

Schedule it with cron:

0 7 * * 1  cd /home/me/tracker && JOA_API_KEY=... python3 hiring_tracker.py >> weekly.txt
Enter fullscreen mode Exit fullscreen mode

Limits to be upfront about

  • Closure is evidence, not proof of a filled role. Companies close and repost roles all the time.
  • not_seen can occasionally mean a company's careers page was down. Look at several closures at one company before reading anything into it.
  • Coverage differs by country and by how an employer publishes jobs. Check the facts page for the markets you care about.

If you outgrow it

Re-querying works fine for a small watchlist. If you track many companies, the delta feed (/v1/changes) returns only what changed since your last call (created, updated, withdrawn, delisted), so you stop paying records to re-read unchanged rows. It is on the Growth plan and up, see pricing. There is also a keyless, beta /public/companies/{slug}/open-roles-history endpoint with a daily open-roles series per company. The history only started recently, so treat it as thin for now.

If you build something with this, or find a number that looks wrong, tell me in the comments.

Top comments (0)