Skip to main content

Comparing job APIs with real requests

Updated 11 min readMarkdown

One request to seven job data sources, five measurements and a script you can run yourself. Run on 3 October 2026.

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.

This tutorial does that. We send one real request 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 no sign-up for six of the seven sources.

Disclosure. 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.

What we measure

These five checks come from questions I ask before trusting any job feed:

  1. Where does the apply link go? To the employer's application page, or to a page owned by the API vendor?

  2. Is there text? Does each row carry the job description, not just a title and a link?

  3. Is there a salary? Only counted when the response to this exact request contains one.

  4. Are there duplicates? Rows with the same title, company and location inside one response.

  5. Can the API say a role has closed? 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.

Setup

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.

read -rs JOA_API_KEY && 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

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.

The script

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".

#!/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&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&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&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)

What came back (3 October 2026, 14:38 UTC)

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

Reading the table:

  • Apply links. 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.

  • Text and salary. Text is present nearly everywhere (Greenhouse needs ?content=true 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 require_fields=salary when salary matters. Employers mostly do not publish one.

  • Duplicates. 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.

  • Closed roles and provenance. 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 closed_at, closed_reason, last_verified_at and a field_sources block that marks each field published, inferred or absent. 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.

What this does not tell you

A single request is a sample, not a benchmark. Specifically it does not tell you:

  • Coverage. 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.

  • Freshness. How quickly a new role appears, or a closed one disappears, over days. You would need to repeat the request over time.

  • Licence. 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.

  • Price at your volume. Free tiers are for testing. Convert each vendor's price to a cost per 1,000 rows you will actually use.

Adding the sources that need sign-up

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 row(...) shape, and add it to the list in __main__. The summary logic does the rest. Their free-tier limits as published on 3 October 2026: JSearch 200 requests a month, TheirStack 200 API credits a month, Adzuna 2,500 requests a month by default.

Which would I pick?

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.

  • For employer-direct postings, a job board or an AI agent that must not recommend closed roles: 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'.

  • For one employer you already know: use that employer's own Greenhouse, Lever or Ashby board API. It is free and it is the source.

  • For a quick prototype over board data: JSearch or Adzuna, within their terms.

  • For hiring signals aimed at sales: TheirStack.

The longer guide linked below explains each of those choices, with dated sources.

Next steps

If you want the broader picture, with a use-case table and a "choose X if" for each vendor, I wrote a longer comparison: Best job posting APIs in 2026: how to choose. It is a separate piece, with its own structure and reasoning.

To repeat this test with Job Opportunities API, get a free key at jobopportunitiesapi.org; 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.

No comments yet. Be the first to comment.