<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Job Opportunities API]]></title><description><![CDATA[Hands-on guides to job-postings data: pull live jobs from employers' own career sites, track hiring and closed roles, and build job boards and AI agents.

]]></description><link>https://jobapi.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ac036e227e62109aba9a706/a655c842-0d77-4b19-9bec-beeb0808992d.png</url><title>Job Opportunities API</title><link>https://jobapi.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 06:20:59 GMT</lastBuildDate><atom:link href="https://jobapi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What 1.39 million employer job postings say about pay in Austria and Germany]]></title><description><![CDATA[Austrian job postings are about four times as likely as German ones to state pay. Open dataset and report from Job Opportunities API (JOA).]]></description><link>https://jobapi.hashnode.dev/what-1-39-million-employer-job-postings-say-about-pay-in-austria-and-germany</link><guid isPermaLink="true">https://jobapi.hashnode.dev/what-1-39-million-employer-job-postings-say-about-pay-in-austria-and-germany</guid><category><![CDATA[Data Science]]></category><category><![CDATA[data analysis]]></category><category><![CDATA[Career]]></category><dc:creator><![CDATA[Loukas Tzekos]]></dc:creator><pubDate>Mon, 12 Oct 2026 08:00:22 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: written by Job Opportunities API (JOA, jobopportunitiesapi.org), an independent data business that sells job-posting data through an API. AI helped write and check the analysis and this text; Loukas (Luca) Tzekos is editorially responsible. Contact: <a href="mailto:hello@jobopportunitiesapi.org">hello@jobopportunitiesapi.org</a>.</em></p>
<p>We have published the aggregate tables behind the Job Opportunities API (JOA) Job Market Report, September 2026 as an open dataset, on <a href="https://huggingface.co/datasets/JobOpportunitiesAPI/joa-job-market-2026-09">Hugging Face</a> and as a <a href="https://doi.org/10.5281/zenodo.23143502">citable Zenodo record</a>. It is one CSV of 803 rows with a data dictionary, licensed CC BY 4.0. Every row carries its sample size n. The report itself, with the full text, charts, a Markdown version and an audio edition, is at <a href="https://jobopportunitiesapi.org/reports/job-market-september-2026">jobopportunitiesapi.org/reports/job-market-september-2026</a>.</p>
<h2>What is in it</h2>
<p>On 4 October 2026 JOA held a snapshot of 2,111,453 open employer-direct postings from 144,098 employers. The report analyses the 1,385,899 located in North America (986,641) and Europe (399,258). The tables cover regions and countries, pay in postings, how old stated posting dates are, employer concentration, work model, role families, a weekly panel and a comparison with official vacancy statistics. There are no individual postings, employer names or personal data in the files.</p>
<h2>One finding worth a look</h2>
<p>Austria has required job adverts to state a minimum pay since 2011; Germany has no such rule. Austrian postings are about four times as likely as German ones to state a pay figure: 4.4 to 1 on a blind, AI-assisted sampled estimate (interval 2.9 to 6.2), and 6.9 to 1 on automated detection (33.7% against 4.9%). The gap holds within every recruiting system with at least 150 Austrian postings.</p>
<p>In the United States, 24.4% of own-site postings in the 10 jurisdictions with a pay-range posting law have pay detected in a structured field, against 18.7% in 28 states without one (both are lower bounds: detection misses some stated pay). That is an association, and states differ in other ways.</p>
<h2>A note on method</h2>
<p>The pay measure was tested before publication: two AI readers with web access labelled stratified random samples blind to our flags, a script verified each quoted passage against the text, and a rule-based second detector agreed with the readers on 97.6% of Austrian and 91.0% of German postings. The Methodology section of the report also explains how employer-direct data differs from job-board and aggregator data and from official statistics, which is why the two should be read side by side. Across the 51 US jurisdictions, JOA's postings follow the official JOLTS distribution by state (rank correlation 0.96).</p>
<h2>Using the data</h2>
<p>Load it with the Hugging Face datasets library or read the CSV directly; the dictionary describes every column. Please cite the report and, for the tables, the DOI. If you find an error, write to <a href="mailto:hello@jobopportunitiesapi.org">hello@jobopportunitiesapi.org</a> with the subject "Market report correction".</p>
]]></content:encoded></item><item><title><![CDATA[Comparing job APIs with real requests]]></title><description><![CDATA[One request to seven job data sources, five measurements, and a script you can run yourself]]></description><link>https://jobapi.hashnode.dev/comparing-job-apis-with-real-requests</link><guid isPermaLink="true">https://jobapi.hashnode.dev/comparing-job-apis-with-real-requests</guid><dc:creator><![CDATA[Loukas Tzekos]]></dc:creator><pubDate>Sat, 03 Oct 2026 22:56:27 GMT</pubDate><content:encoded><![CDATA[<p><em>One request to seven job data sources, five measurements and a script you can run yourself. Run on 3 October 2026.</em></p>
<p>Every "best job API" list claims something about sourcing, freshness and data quality. Claims are cheap. Requests are not: send the same kind of request to several APIs and you can see what actually comes back.</p>
<p>This tutorial does that. We send <strong>one real request</strong> to each of seven job data sources, measure five things in the responses, and print a comparison table. The script is plain Python 3, standard library only, and it needs <strong>no sign-up for six of the seven sources</strong>.</p>
<p><strong>Disclosure.</strong> I build Job Opportunities API (JOA), which is one of the seven, and I recommend it below for some uses. JOA, Erioun and Kaeros are all made by the same small studio, so read my conclusions with that in mind. I included JOA because I can test it with a free key; the other six are queried exactly as their public documentation describes, with no key. I report what came back, including the places JOA looks worse than the others.</p>
<h2>What we measure</h2>
<p>These five checks come from questions I ask before trusting any job feed:</p>
<ol>
<li><p><strong>Where does the apply link go?</strong> To the employer's application page, or to a page owned by the API vendor?</p>
</li>
<li><p><strong>Is there text?</strong> Does each row carry the job description, not just a title and a link?</p>
</li>
<li><p><strong>Is there a salary?</strong> Only counted when the response to this exact request contains one.</p>
</li>
<li><p><strong>Are there duplicates?</strong> Rows with the same title, company and location inside one response.</p>
</li>
<li><p><strong>Can the API say a role has closed?</strong> And can it say which fields were stated by the employer and which were guessed? Here we look at what the response format can express, then check it with a real call where it exists.</p>
</li>
</ol>
<h2>Setup</h2>
<p>You need Python 3.9 or newer. For the Job Opportunities API row, create a free Explore key (no card) and export it. The other sources need nothing.</p>
<pre><code class="language-bash">read -rs JOA_API_KEY &amp;&amp; export JOA_API_KEY   # paste your free key, press Enter (optional: without it the Job Opportunities API row is skipped)
python3 compare_job_apis.py
</code></pre>
<p>Two courtesy notes. Remotive asks API users to make at most about four requests a day, so do not put this in a loop. And the single-employer sources (Greenhouse, Lever, Ashby) are queried for one company each, to show the shape of their public job board APIs; they are not market-wide.</p>
<h2>The script</h2>
<p>It sends one request per source, normalises each row into the same small structure, and summarises. The apply-link classifier is deliberately simple: a link on the vendor's own domain counts as "the vendor's own page", a known applicant-tracking-system host counts as an application page, and anything else is "another domain".</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Send ONE real request to each job API and measure what comes back.

Python 3.9+, standard library only. Optional: export JOA_API_KEY=... (free Explore key)
to include Job Opportunities API. No other key is needed.
"""
import json, os, re, sys, urllib.request
from collections import Counter
from urllib.parse import urlparse

UA = "job-api-comparison-script/1.0"
ATS_HOSTS = ("greenhouse.io", "lever.co", "ashbyhq.com", "myworkdayjobs.com", "smartrecruiters.com",
             "join.com", "teamtailor.com", "recruitee.com", "personio.de", "personio.com", "workable.com",
             "comeet.com", "bamboohr.com", "icims.com", "jobvite.com")

def get(url, key=None):
    headers = {"User-Agent": UA}
    if key:
        headers["Authorization"] = "Bearer " + key
    with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=40) as r:
        return json.load(r)

def row(title, company, location, apply_url, has_desc, salary, date, source_host):
    return dict(title=title or "", company=company or "", location=location or "",
                apply_url=apply_url or "", has_desc=bool(has_desc), salary=bool(salary),
                date=date, source_host=source_host)

# One function per source: returns (rows, schema_flags). schema_flags says what the API's own
# fields can express: does a row carry a closed/last-checked field, and per-field provenance?
def arbeitnow():
    d = get("https://www.arbeitnow.com/api/job-board-api")["data"][:50]
    return [row(j["title"], j["company_name"], j["location"], j["url"], j["description"], False,
                j["created_at"], "arbeitnow.com") for j in d], dict(closed=False, verified=False, provenance=False)

def muse():
    d = get("https://www.themuse.com/api/public/jobs?page=1")["results"]
    return [row(j["name"], j["company"]["name"], (j["locations"] or [{}])[0].get("name"),
                j["refs"]["landing_page"], j["contents"], False, j["publication_date"], "themuse.com")
            for j in d], dict(closed=False, verified=False, provenance=False)

def remotive():
    d = get("https://remotive.com/api/remote-jobs")["jobs"][:50]
    return [row(j["title"], j["company_name"], j["candidate_required_location"], j["url"], j["description"],
                j["salary"], j["publication_date"], "remotive.com") for j in d], dict(closed=False, verified=False, provenance=False)

def greenhouse(board="stripe"):  # ONE employer's public board
    d = get("https://boards-api.greenhouse.io/v1/boards/%s/jobs?content=true" % board)["jobs"][:50]
    return [row(j["title"], j.get("company_name") or board, (j.get("location") or {}).get("name"),
                j["absolute_url"], j.get("content"), False, j.get("first_published"), "greenhouse.io")
            for j in d], dict(closed=False, verified=False, provenance=False)

def lever(site="palantir"):  # ONE employer's public postings
    d = get("https://api.lever.co/v0/postings/%s?mode=json&amp;limit=50" % site)
    return [row(j["text"], site, (j.get("categories") or {}).get("location"), j.get("applyUrl") or j["hostedUrl"],
                j.get("descriptionPlain"), j.get("salaryRange"), j.get("createdAt"), "lever.co")
            for j in d], dict(closed=False, verified=False, provenance=False)

def ashby(board="ashby"):  # ONE employer's public postings
    d = get("https://api.ashbyhq.com/posting-api/job-board/%s?includeCompensation=true" % board)["jobs"][:50]
    return [row(j["title"], board, j.get("location"), j.get("applyUrl") or j["jobUrl"],
                j.get("descriptionPlain"), (j.get("compensation") or {}).get("compensationTierSummary"), j.get("publishedAt"), "ashbyhq.com")
            for j in d], dict(closed=False, verified=False, provenance=False)

def joa():
    key = os.environ.get("JOA_API_KEY")
    if not key:
        return None, None
    d = get("https://api.jobopportunitiesapi.org/v1/jobs?country=DE&amp;limit=50", key)["data"]
    return [row(j["title"], j["company"], j.get("city"), j.get("apply_url"), j.get("has_description"),
                (j.get("field_sources") or {}).get("salary") == "published", j.get("posted_at"), "jobopportunitiesapi.org")
            for j in d], dict(closed=True, verified=True, provenance=True)

def classify(r):
    host = urlparse(r["apply_url"]).netloc.lower()
    if r["source_host"] in host and not any(h in r["source_host"] for h in ATS_HOSTS):
        return "the vendor's own page"
    if any(h in host for h in ATS_HOSTS):
        return "an ATS application page"
    return "another domain" if host else "no link"

def summarise(name, rows, flags):
    n = len(rows)
    keys = [(r["title"].lower(), r["company"].lower(), r["location"].lower()) for r in rows]
    dupes = n - len(set(keys))
    kinds = Counter(classify(r) for r in rows)
    pct = lambda x: "%d%%" % round(100 * x / n) if n else "-"
    return [name, n, pct(sum(r["has_desc"] for r in rows)), pct(sum(r["salary"] for r in rows)),
            pct(sum(bool(r["date"]) for r in rows)), dupes,
            ", ".join("%s %s" % (pct(v), k) for k, v in kinds.most_common(2)),
            "yes" if flags["closed"] else "no", "yes" if flags["provenance"] else "no"]

def joa_closed_check():
    key = os.environ.get("JOA_API_KEY")
    if not key:
        return None
    d = get("https://api.jobopportunitiesapi.org/v1/jobs/closed?country=DE&amp;limit=5", key)["data"]
    ok = sum(1 for j in d if j.get("closed_at") and j.get("closed_reason"))
    return "Job Opportunities API /v1/jobs/closed: %d of %d rows carry closed_at and closed_reason" % (ok, len(d))

HEAD = ["source", "rows", "has text", "has salary", "has date", "dupes", "apply link goes to", "closed roles?", "field provenance?"]

if __name__ == "__main__":
    out = [HEAD]
    for name, fn in [("Arbeitnow", arbeitnow), ("The Muse", muse), ("Remotive", remotive),
                     ("Greenhouse (1 employer)", greenhouse), ("Lever (1 employer)", lever),
                     ("Ashby (1 employer)", ashby), ("Job Opportunities API (DE)", joa)]:
        try:
            rows, flags = fn()
            if rows is None:
                print("skipping %s (set JOA_API_KEY)" % name, file=sys.stderr)
                continue
            out.append(summarise(name, rows, flags))
        except Exception as e:
            print("%s failed: %s" % (name, e), file=sys.stderr)
    for r in out:
        print(" | ".join(str(c) for c in r))
    try:
        line = joa_closed_check()
        if line:
            print()
            print(line)
    except Exception as e:
        print("closed check failed: %s" % e, file=sys.stderr)
</code></pre>
<h2>What came back (3 October 2026, 14:38 UTC)</h2>
<pre><code class="language-text">source | rows | has text | has salary | has date | dupes | apply link goes to | closed roles? | field provenance?
Arbeitnow | 50 | 100% | 0% | 100% | 0 | 100% the vendor's own page | no | no
The Muse | 20 | 100% | 0% | 100% | 0 | 100% the vendor's own page | no | no
Remotive | 16 | 100% | 56% | 100% | 0 | 100% the vendor's own page | no | no
Greenhouse (1 employer) | 50 | 100% | 0% | 100% | 1 | 100% another domain | no | no
Lever (1 employer) | 50 | 100% | 0% | 100% | 0 | 100% an ATS application page | no | no
Ashby (1 employer) | 50 | 100% | 100% | 100% | 0 | 100% an ATS application page | no | no
Job Opportunities API (DE) | 50 | 92% | 8% | 100% | 2 | 96% an ATS application page, 4% another domain | yes | yes

Job Opportunities API /v1/jobs/closed: 5 of 5 rows carry closed_at and closed_reason
</code></pre>
<p>Reading the table:</p>
<ul>
<li><p><strong>Apply links.</strong> Arbeitnow, The Muse and Remotive link to their own pages for every row. That is normal for an aggregator or board and is part of how they get traffic, but it means one more click before the candidate reaches the employer. Lever and Ashby link to an application page on the ATS. Greenhouse's link goes to another domain, which for a single employer like Stripe is the company's own site. JOA's sample linked to an ATS page in 96% of rows and another domain in the rest.</p>
</li>
<li><p><strong>Text and salary.</strong> Text is present nearly everywhere (Greenhouse needs <code>?content=true</code> to include it, which the script requests). Salary is the uneven field: none for Arbeitnow, The Muse, Lever or Greenhouse in this request, 56% for Remotive, and 100% for Ashby's own jobs, which says more about that one company than about Ashby. JOA's German sample had a published salary on 8% of rows. That is low, and it is why JOA's own documentation tells you to filter with <code>require_fields=salary</code> when salary matters. Employers mostly do not publish one.</p>
</li>
<li><p><strong>Duplicates.</strong> One in Greenhouse's 50 rows and two in JOA's 50 share a title, company and location. Those may be genuinely separate openings for the same role, so treat this as a prompt to look, not an error count. The aggregator samples showed none, but they were small.</p>
</li>
<li><p><strong>Closed roles and provenance.</strong> None of the other six responses carries a closure date or a list of closed roles, and none marks which fields the source stated. JOA does: every row has <code>closed_at</code>, <code>closed_reason</code>, <code>last_verified_at</code> and a <code>field_sources</code> block that marks each field <code>published</code>, <code>inferred</code> or <code>absent</code>. The last line of the output is a real call to JOA's closed-roles endpoint with a free key: all five rows came back with a closure date and reason.</p>
</li>
</ul>
<h2>What this does not tell you</h2>
<p>A single request is a sample, not a benchmark. Specifically it does not tell you:</p>
<ul>
<li><p><strong>Coverage.</strong> Whether the API has the employers and countries you need. Arbeitnow's German focus and JOA's German filter are not comparable to a US-only source.</p>
</li>
<li><p><strong>Freshness.</strong> How quickly a new role appears, or a closed one disappears, over days. You would need to repeat the request over time.</p>
</li>
<li><p><strong>Licence.</strong> What you may do with the rows. Adzuna requires an attribution label, Remotive delays jobs by 24 hours and bars passing them on to some sites, and several vendors bar competing data products. Read the terms.</p>
</li>
<li><p><strong>Price at your volume.</strong> Free tiers are for testing. Convert each vendor's price to a cost per 1,000 rows you will actually use.</p>
</li>
</ul>
<h2>Adding the sources that need sign-up</h2>
<p>JSearch, Adzuna and TheirStack all publish free tiers, but they need an account first, so I did not include them in the script. To add one: sign up, read its docs for the search endpoint, write a function that returns rows in the same <code>row(...)</code> shape, and add it to the list in <code>__main__</code>. The summary logic does the rest. Their free-tier limits as published on 3 October 2026: <a href="https://www.openwebninja.com/api/jsearch">JSearch</a> 200 requests a month, <a href="https://theirstack.com/en/docs/pricing/plans">TheirStack</a> 200 API credits a month, <a href="https://developer.adzuna.com/docs/terms_of_service">Adzuna</a> 2,500 requests a month by default.</p>
<h2>Which would I pick?</h2>
<p>This is my own conclusion from these measurements and my reading of each vendor's pages, and I build one of the seven, so check it against your own test.</p>
<ul>
<li><p><strong>For employer-direct postings, a job board or an AI agent that must not recommend closed roles:</strong> I would start with Job Opportunities API. In this request it was the only one of the seven whose rows carried a closure date and reason and a per-field published, inferred or absent marker, and 96% of its apply links went to an ATS application page. It is not free of problems: its salary fill was low (8% in this German sample), two of its 50 rows shared a title, company and location, and its coverage is smaller than the aggregators'.</p>
</li>
<li><p><strong>For one employer you already know:</strong> use that employer's own Greenhouse, Lever or Ashby board API. It is free and it is the source.</p>
</li>
<li><p><strong>For a quick prototype over board data:</strong> JSearch or Adzuna, within their terms.</p>
</li>
<li><p><strong>For hiring signals aimed at sales:</strong> TheirStack.</p>
</li>
</ul>
<p>The longer guide linked below explains each of those choices, with dated sources.</p>
<h2>Next steps</h2>
<p>If you want the broader picture, with a use-case table and a "choose X if" for each vendor, I wrote a longer comparison: <a href="https://jobopportunitiesapi.org/guides/best-job-posting-apis">Best job posting APIs in 2026: how to choose</a>. It is a separate piece, with its own structure and reasoning.</p>
<p>To repeat this test with Job Opportunities API, get a free key at <a href="https://jobopportunitiesapi.org/register">jobopportunitiesapi.org</a>; the free Explore plan needs no card, and the pricing page lists the current allowance. If you find a result in this tutorial that you think is wrong, tell me and I will correct it.</p>
]]></content:encoded></item><item><title><![CDATA[Track which companies are hiring (and closing roles) with a jobs API]]></title><description><![CDATA[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…]]></description><link>https://jobapi.hashnode.dev/track-which-companies-are-hiring-and-closing-roles-with-a-jobs-api</link><guid isPermaLink="true">https://jobapi.hashnode.dev/track-which-companies-are-hiring-and-closing-roles-with-a-jobs-api</guid><category><![CDATA[Python]]></category><category><![CDATA[api]]></category><category><![CDATA[API]]></category><category><![CDATA[APIs]]></category><dc:creator><![CDATA[Loukas Tzekos]]></dc:creator><pubDate>Sat, 03 Oct 2026 09:55:58 GMT</pubDate><content:encoded><![CDATA[<p><em>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.</em></p>
<p>Counting live job postings is easy. Knowing <em>which companies are hiring more</em> and <em>which roles just disappeared</em> is more interesting, and most job feeds can't tell you the second part because a closed role simply vanishes.</p>
<p>The <a href="https://jobopportunitiesapi.org/docs">Job Opportunities API</a> 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:</p>
<ul>
<li><p>a daily snapshot of each company's open-role count (so you can see movement over time),</p>
</li>
<li><p>the roles posted in the last week,</p>
</li>
<li><p>the roles that closed in the last week, with the closure reason,</p>
</li>
<li><p>a rough "days listed" figure that is honest about where it came from.</p>
</li>
</ul>
<p>If you have not used the API before, the <a href="https://dev.to/lucagiftzek/get-european-job-postings-from-an-api-in-python-in-5-minutes-1ib8">first post in this pair</a> 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: <code>/v1/companies/{slug}</code>, <code>/v1/jobs</code> and <code>/v1/jobs/closed</code>. (Get a free key at <a href="https://jobopportunitiesapi.org/login">/login</a>, no card; the key goes in <code>JOA_API_KEY</code>.)</p>
<h2>What "closed" means here</h2>
<p>Each closed row has <code>closed_at</code> and <code>closed_reason</code>. The documentation is explicit about the reasons:</p>
<ul>
<li><p><code>expired_upstream</code>: the ledger proved the vacancy dead (re-checked against the board listing or the job URL itself).</p>
</li>
<li><p><code>not_seen</code>: the role stopped appearing at its source for the grace period. Usually a closure, occasionally a careers site that broke.</p>
</li>
<li><p><code>employer_closed</code> / <code>deadline_passed</code>: stated by the employer itself, for roles listed through Erioun for Teams.</p>
</li>
</ul>
<p>A role an employer merely paused or unlisted is <em>not</em> a closure. Keep that in mind: a closure means "gone from the source", not "filled".</p>
<h2>The tracker</h2>
<p>Save as <code>hiring_tracker.py</code> (only <code>requests</code> and the standard library):</p>
<pre><code class="language-python">"""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&lt;? 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']}")
</code></pre>
<p>Find slugs with <code>/v1/companies?q=&lt;name&gt;</code>. The ones above are real company slugs in the ledger.</p>
<h2>Real output</h2>
<pre><code class="language-text">spotify: 84 open roles (first snapshot, no comparison yet)
  posted in the last 7 days: 9
    + Partner Manager - Lifestyle &amp; Wellness
    + Program Manager - Podcast Sales Strategy &amp; 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 &amp; 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 &amp; Operations
    + Steuerberater (Tax Manager)
  closed in the last 7 days: 10+
    - Backend Engineer - Investments &amp; 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
</code></pre>
<p>(A <code>10+</code> means the sample cap was hit. Raise <code>SAMPLE</code> if you want more rows and have the records to spend.)</p>
<h2>Details that make it more honest</h2>
<p><strong>Days listed uses provenance.</strong> Every row carries <code>field_sources</code>. When <code>posted_at</code> is <code>published</code>, the employer's own date is used. Otherwise the code falls back to <code>first_seen_at</code> (when the ledger first saw the role), which can only <em>under</em>-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.</p>
<p><strong>The delta column needs a second day.</strong> 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 <code>84 open roles (+4 since &lt;yesterday&gt;)</code>. From the second real day on, you get the movement for free.</p>
<h2>Staying inside the free allowance</h2>
<p>On Explore, <strong>one row returned is one record</strong>, and the allowance is 1,000 a month. With four companies and <code>SAMPLE=10</code>, 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 <code>SAMPLE</code>: reading three rows instead of ten per list makes a run a handful of records. <code>/v1/me</code> shows your usage and costs nothing.</p>
<p>Schedule it with cron:</p>
<pre><code class="language-cron">0 7 * * 1  cd /home/me/tracker &amp;&amp; JOA_API_KEY=... python3 hiring_tracker.py &gt;&gt; weekly.txt
</code></pre>
<h2>Limits to be upfront about</h2>
<ul>
<li><p>Closure is evidence, not proof of a filled role. Companies close and repost roles all the time.</p>
</li>
<li><p><code>not_seen</code> can occasionally mean a company's careers page was down. Look at several closures at one company before reading anything into it.</p>
</li>
<li><p>Coverage differs by country and by how an employer publishes jobs. Check the <a href="https://jobopportunitiesapi.org/facts">facts page</a> for the markets you care about.</p>
</li>
</ul>
<h2>If you outgrow it</h2>
<p>Re-querying works fine for a small watchlist. If you track many companies, the <a href="https://jobopportunitiesapi.org/docs/endpoints/changes">delta feed</a> (<code>/v1/changes</code>) 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 <a href="https://jobopportunitiesapi.org/pricing">pricing</a>. There is also a keyless, beta <code>/public/companies/{slug}/open-roles-history</code> endpoint with a daily open-roles series per company. The history only started recently, so treat it as thin for now.</p>
<p>If you build something with this, or find a number that looks wrong, tell me in the comments.</p>
]]></content:encoded></item><item><title><![CDATA[Get European job postings from an API in Python in 5 minutes]]></title><description><![CDATA[Disclosure: I build JOA, the jobs API used below. Everything here works on its free plan, and I ran every snippet against the live API in early October…]]></description><link>https://jobapi.hashnode.dev/get-european-job-postings-from-an-api-in-python-in-5-minutes</link><guid isPermaLink="true">https://jobapi.hashnode.dev/get-european-job-postings-from-an-api-in-python-in-5-minutes</guid><dc:creator><![CDATA[Loukas Tzekos]]></dc:creator><pubDate>Sat, 03 Oct 2026 09:54:39 GMT</pubDate><content:encoded><![CDATA[<p><em>Disclosure: I build JOA, the jobs API used below. Everything here works on its free plan, and I ran every snippet against the live API in early October 2026 before writing this.</em></p>
<p>If you have ever needed European job postings as data (for a dashboard, a research project, a job board, or just to see what a market looks like), you know the usual options: scrape career pages yourself, or buy a feed that is a mix of aggregator copies.</p>
<p>This post uses the <a href="https://jobopportunitiesapi.org/docs">Job Opportunities API (JOA)</a>, which serves postings taken from employers' own career sites and applicant-tracking systems. We will write a small Python client that:</p>
<ul>
<li><p>authenticates with a free key,</p>
</li>
<li><p>filters by country, remote status and recent date,</p>
</li>
<li><p>pages through results with the API's cursor,</p>
</li>
<li><p>uses the per-field <em>provenance</em> tags to ask for salaries the employer actually published,</p>
</li>
<li><p>writes a CSV, and</p>
</li>
<li><p>stays inside the free record allowance.</p>
</li>
</ul>
<h2>Step 0 - look before you sign up</h2>
<p>The statistics endpoints need no key and no account. This gives you the shape of the data (coverage per country, freshness, which sources feed it):</p>
<pre><code class="language-bash">curl https://api.jobopportunitiesapi.org/public/coverage
curl https://api.jobopportunitiesapi.org/public/coverage/countries
</code></pre>
<p>Coverage is not uniform across countries, and the report says so openly, so check the countries you care about before building on them. The numbers are live, which is why I am not quoting any here: the always-current version is on the <a href="https://jobopportunitiesapi.org/facts">facts page</a>.</p>
<h2>Step 1 - get a free key</h2>
<p>Sign in with your email at <a href="https://jobopportunitiesapi.org/login">jobopportunitiesapi.org/login</a>, click the link it sends, and press <strong>Create a free key</strong> on the dashboard. There is no password and no card. The free <strong>Explore</strong> plan has a monthly record allowance rather than a trial period, so it does not expire (<a href="https://jobopportunitiesapi.org/docs/account/plans">plans and limits</a>).</p>
<pre><code class="language-bash">export JOA_API_KEY='paste-your-key-here'
curl -s -H "Authorization: Bearer $JOA_API_KEY" https://api.jobopportunitiesapi.org/v1/me
</code></pre>
<p><code>/v1/me</code> tells you the plan, the rate limits and today's usage, and it costs no records. Useful to know early: <strong>one row returned = one record</strong>, so while exploring, keep <code>limit</code> small.</p>
<h2>Step 2 - the client</h2>
<p>Save this as <code>joa_europe_jobs.py</code> (it needs only <code>requests</code>):</p>
<pre><code class="language-python">"""Pull European job postings from the Job Opportunities API (JOA) with Python.

Needs: Python 3.9+, `pip install requests`, and a free Explore key in JOA_API_KEY.
"""
import csv
import os
import random
import sys
import time
from datetime import date, timedelta

import requests

API = "https://api.jobopportunitiesapi.org"
# EU27 + EFTA + UK as ISO-3166 alpha-2 codes; the API accepts a comma-separated list.
EUROPE = (
    "AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IT,LV,LT,LU,MT,NL,PL,PT,RO,SK,SI,ES,SE,"
    "IS,LI,NO,CH,GB"
)

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


def get(path, **params):
    """GET with the retry rule from the docs: retry 429/503 after Retry-After, never retry 402."""
    for attempt in range(4):
        resp = session.get(API + path, params=params, timeout=30)
        if resp.status_code in (429, 503):
            wait = int(resp.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait + random.random())  # jitter
            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:  # the API explains itself: {"error": ..., "message": ...}
            sys.exit(f"{resp.status_code} {resp.json().get('message', resp.text)}")
        return resp
    sys.exit("Still rate limited after 4 tries.")


def iter_jobs(max_rows=25, page_size=25, **filters):
    """Walk /v1/jobs with the keyset cursor, stopping after max_rows (each row is one record)."""
    cursor, seen = None, 0
    while seen &lt; max_rows:
        params = dict(filters, limit=min(page_size, max_rows - seen))
        if cursor:
            params["cursor"] = cursor
        resp = get("/v1/jobs", **params)
        body = resp.json()
        for job in body["data"]:
            yield job
            seen += 1
        if not body["has_more"]:
            break
        cursor = body["next_cursor"]
    left = resp.headers.get("x-ratelimit-records-remaining")
    print(f"  (records left this month: {left})", file=sys.stderr)


def provenance(job, field):
    """Where did this field come from? published / inferred / absent."""
    return job["field_sources"].get(field, "absent")


if __name__ == "__main__":
    me = get("/v1/me").json()  # costs no records
    print(f"plan={me['plan']} per_minute={me['limits']['per_minute']} per_day={me['limits']['per_day']}")

    week_ago = (date.today() - timedelta(days=7)).isoformat()

    print("\n== Remote data-engineering roles in Europe, last 7 days ==")
    rows = []
    for job in iter_jobs(
        max_rows=10,
        country=EUROPE,
        remote="remote",
        title="data engineer",
        posted_after=week_ago,
    ):
        rows.append(job)
        print(f"{job['country']}  {job['title'][:48]:&lt;48}  {job['company'][:28]:&lt;28}  "
              f"remote={provenance(job, 'remote')}")

    print("\n== Roles where the employer itself published the salary ==")
    for job in iter_jobs(
        max_rows=6,
        country="DE,FR,NL,IE,ES,IT",
        has_salary="true",
        require_fields="salary",
        title="engineer",
    ):
        lo, hi = job["salary_min"], job["salary_max"]
        print(f"{job['country']}  {job['title'][:44]:&lt;44}  {lo}-{hi} {job['salary_currency']}/{job['salary_period']}")

    with open("european_jobs.csv", "w", newline="", encoding="utf-8") as fh:
        writer = csv.writer(fh)
        writer.writerow(["title", "company", "country", "city", "remote", "posted_at", "apply_url"])
        for job in rows:
            writer.writerow([job["title"], job["company"], job["country"], job.get("city", ""),
                             job["remote"], job["posted_at"], job["apply_url"]])
    print(f"\nwrote european_jobs.csv with {len(rows)} rows")
</code></pre>
<p>A few choices worth explaining:</p>
<ul>
<li><p><strong>Cursor pagination.</strong> <code>/v1/jobs</code> is keyset-paginated: every response carries <code>next_cursor</code> and <code>has_more</code>, and you pass the cursor back. There are no offsets, so rows that appear while you page can't shift your window.</p>
</li>
<li><p><strong>Retries follow the docs.</strong> Retry 429 and 503 after <code>Retry-After</code> (with jitter), and never retry 402, which means the month's records are spent.</p>
</li>
<li><p><strong>Errors explain themselves.</strong> The API replies with <code>{"error": ..., "message": ...}</code>, and the client prints the message. I found this out the hard way, see the gotcha below.</p>
</li>
</ul>
<h2>Step 3 - run it</h2>
<pre><code class="language-bash">pip install requests
python joa_europe_jobs.py
</code></pre>
<p>This is the real output from my run (yours will differ, since the data moves every day):</p>
<pre><code class="language-text">plan=explore per_minute=30 per_day=5000

== Remote data-engineering roles in Europe, last 7 days ==
FR  Data Engineer - Transport - Nantes                Sopra Steria                  remote=published
EE  Senior Backend Engineer - Data Core (remote, Eur  Modash                        remote=published
FR  Senior Software Engineer - Data Search (remote,   Modash                        remote=published
HR  Data / DevOps Engineer (m/f)                      solvership                    remote=inferred
DE  Senior Data Platform Engineer (m/w/d) | Snowflak  Jobrad Loop                   remote=inferred
FR  Finance Data Engineer                             Voodoo                        remote=published
FR  Stage - Data Engineer - Aeroline - Toulouse       Sopra Steria                  remote=inferred

== Roles where the employer itself published the salary ==
IT  Field Automation Engineer                     28000-35000 EUR/year
IT  Junior Digital Verification Engineer          35000-40000 EUR/year
DE  QA Engineer (gn)                              55000-75000 EUR/year
IT  SALES ENGINEER (m/f/d)                        43000-50000 EUR/year
IT  PROGRAMMATORE PLC / AUTOMATION SOFTWARE ENGI  35000-45000 EUR/year
IT  Data Engineer – Cloud &amp; AI Solutions          33000-40000 EUR/year

wrote european_jobs.csv with 7 rows
</code></pre>
<h2>Provenance: why <code>remote=published</code> matters</h2>
<p>Every row has a <code>field_sources</code> object that tags each field as <code>published</code> (the employer stated it), <code>inferred</code> (derived by the ledger, for example from the title or location) or <code>absent</code>. Look at the first query above: some roles are <code>remote=published</code>, others <code>remote=inferred</code>. If you are building something where a wrong "remote" label is costly, you can now filter on it, and the second query shows the server-side version of the same idea: <code>require_fields=salary</code> returns only rows where the salary came from the employer, not from a guess.</p>
<h2>Gotchas I hit while testing</h2>
<ul>
<li><p><code>require_fields</code> accepts a fixed set of fields (<code>description</code>, <code>employment_type</code>, <code>location</code>, <code>posted_at</code>, <code>remote</code>, <code>salary</code>, <code>source_type</code>). My first attempt used <code>salary_currency</code> and got a clear <code>422 bad_require_fields</code> listing the valid ones. That is why the client prints the API's message.</p>
</li>
<li><p><code>title=</code> searches the job title only. <code>q=</code> searches title, company and location, and <code>description_contains=</code> searches the advert body. Pick the narrowest one.</p>
</li>
<li><p>Structured salaries are a small share of postings in most markets, so a salary-filtered query returns far fewer rows than an unfiltered one. That is expected, not a bug.</p>
</li>
<li><p>This is not a census of every vacancy. Employers with public career pages are better represented than those without.</p>
</li>
</ul>
<h2>Where to go next</h2>
<ul>
<li><p>Roles that have <em>left</em> their source are kept with a closure date and reason: <code>/v1/jobs/closed</code>. I use it in the follow-up post, <a href="https://dev.to/lucagiftzek/track-which-companies-are-hiring-and-closing-roles-with-a-jobs-api-5bbo">tracking which companies are hiring and which roles are closing</a>.</p>
</li>
<li><p>If you work with AI agents, there is an <a href="https://jobopportunitiesapi.org/docs/mcp">MCP connector</a>, so an agent can query the same data without you writing a client.</p>
</li>
<li><p>The full parameter list is generated from the OpenAPI spec: <a href="https://jobopportunitiesapi.org/docs/api/parameters">docs</a>.</p>
</li>
<li><p>When 1,000 records a month is not enough, paid plans start at EUR 29/month: <a href="https://jobopportunitiesapi.org/pricing">pricing</a>.</p>
</li>
</ul>
<p>If you hit a rough edge, tell me in the comments. It is one person's product and I read everything.</p>
]]></content:encoded></item></channel></rss>