Job Opportunities API

Check the data. Then trust it.

recipes

Things to build

Three complete builds (a job board, an employer watcher, a CRM enrichment job) and an honest list of which integrations exist today.

Last verified 2026-10-01 · Assumes: Client code. · Markdown copy

1A job board

A niche board is a filtered listing, a detail page and an apply link. It needs a free key from day one now: there is no keyless row surface left to start on (see Keyless access), but the key is free and the rest of the build is unchanged.

In depthWhy it exists, what it is not, what people get wrong

The commercially important decision is the niche, not the code. A board scoped to one country and one category has a filter that returns a useful number of rows and a story about why it exists; a board over the whole ledger is a worse version of this site.

Two licence obligations from the terms: wherever you display listings publicly, show a visible credit, “Data: Job Opportunities API”, linking to https://jobopportunitiesapi.org; and when we tell you an employer asked for a listing's removal, remove it from your product too. Three more things make a board trustworthy. Link apply_url directly rather than proxying it: the promise of this data is that candidates reach the employer. Show last_verified_at, because a board without a freshness date is indistinguishable from a dead one. And when you display a remote flag or a salary, say whether it was stated or inferred: field_sources is on every row precisely so that you can.

Exact contractTypes, defaults, ranges, errors, edge cases
  1. Pick the slice. ?country=NL&category=Engineering. Check the volume on the per-country table first.
  2. Seed with /v1/jobs and the cursor, with your free key: there is no keyless row tier left to launch on without one.
  3. Sync with /v1/changes every fifteen minutes. The loop.
  4. Detail pages from your own store, not from a live call: you already have the row. Fetch the description once with include_description=true (remember limit caps at 50 with it on).
  5. Facets from /public/facets, cached hourly, so your filter UI shows real counts and never offers a value the API will reject.
  6. Apply links straight to apply_url, with rel="nofollow noopener".
The front end, server-rendered with a key, in twenty lines
// Server-side (Node, a serverless function, a build step), never in a browser bundle, because a real key is a secret.
const qs = new URLSearchParams({ country: 'NL', limit: '20' });
const { data } = await (
  await fetch(`https://api.jobopportunitiesapi.org/v1/jobs?${qs}`, {
    headers: { Authorization: `Bearer ${process.env.JOA_API_KEY}` },
  })
).json();

const verified = iso =>
  new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });

const html = data.map(j => `
  <article>
    <h3><a href="${j.apply_url}" rel="nofollow noopener">${j.title}</a></h3>
    <p>${j.company}${j.city ? `, ${j.city}` : ''}</p>
    ${j.remote ? `<span>${j.remote}${
        // Say which kind of fact this is. It is on the row for a reason.
        j.field_sources.remote === 'inferred' ? ' (inferred)' : ''
      }</span>` : ''}
    <small>Last verified ${verified(j.last_verified_at)}</small>
  </article>`).join('') +
  // Licence obligation: a visible credit wherever listings are shown publicly.
  '<p><small>Data: <a href="https://jobopportunitiesapi.org">Job Opportunities API</a></small></p>';
// ...return `html` from your own API route, or render it into the page server-side.

2Watching an employer

Key on the domain, poll for that company's rows, and diff against what you saw last time. Closures matter as much as openings.

In depthWhy it exists, what it is not, what people get wrong

The naive version watches for new roles. The useful version watches both directions: a company that stops recruiting for a function is often a stronger signal than one that starts, and it is a signal almost nobody has because most job data deletes what closes.

Use company_domain rather than company where you can. It is the identifier you already hold, and company slugs are not currently guaranteed stable across refreshes. About 37% of companies carry a domain.

Exact contractTypes, defaults, ranges, errors, edge cases
watch.py: openings and closures for a set of employers
"""Report what changed for a watchlist of employers, by domain.

    export JOA_KEY=...
    python3 watch.py stripe.com figma.com linear.app
"""
import json, os, pathlib, sys, urllib.parse, urllib.request

API   = "https://api.jobopportunitiesapi.org"
KEY   = os.environ["JOA_KEY"]
STATE = pathlib.Path(".joa-watch.json")

def get(path, **params):
    req = urllib.request.Request(
        f"{API}{path}?" + urllib.parse.urlencode(params),
        headers={"Authorization": f"Bearer {KEY}"},
    )
    with urllib.request.urlopen(req, timeout=90) as r:
        return json.load(r)

domains = sys.argv[1:] or ["stripe.com"]
seen    = json.loads(STATE.read_text()) if STATE.exists() else {}

current = {}
for job in get("/v1/jobs", company_domain=",".join(domains), limit=200)["data"]:
    current[job["id"]] = {"company": job["company"], "title": job["title"]}

for jid, job in current.items():
    if jid not in seen:
        print(f"OPENED   {job['company']}\t{job['title']}")

# A role that vanished may be closed, withdrawn, or simply beyond page one.
# Ask, rather than assume: include_closed tells you which it was.
for jid, job in seen.items():
    if jid in current:
        continue
    row = get(f"/v1/jobs/{jid}", include_closed="true").get("data", {})
    if row.get("status") == "closed":
        print(f"CLOSED   {job['company']}\t{job['title']}\t({row.get('closed_reason')})")
    else:
        print(f"GONE     {job['company']}\t{job['title']}\t(withdrawn or off page one)")

STATE.write_text(json.dumps(current))

At scale, replace the polling with /v1/changes filtered on your side: the feed reports openings, updates, closures and withdrawals in one stream and charges only for what moved.

3Hiring signal for a CRM

You already have company domains. Ask which of them are hiring, for what, and where, in one request per batch.

In depthWhy it exists, what it is not, what people get wrong

company_domain is comma-separated, so a batch of domains is one request rather than one per account. That is the whole integration: read your account list, ask, write back a count and a few titles.

Two honesty notes worth building in. Only about 37% of companies in the ledger carry a domain, so a domain that returns nothing means “not matched”, not “not hiring”. And include_discovered=true on /v1/companies surfaces employers we have watched hiring on their own careers page under a source we do not redistribute (good evidence, not inventory), and it should be labelled differently in your CRM.

Exact contractTypes, defaults, ranges, errors, edge cases
enrich.py: a batch of domains in, a hiring summary out
"""Enrich a list of company domains with hiring signal.

    export JOA_KEY=...
    python3 enrich.py < domains.txt > enriched.json
"""
import collections, json, os, sys, urllib.parse, urllib.request

API   = "https://api.jobopportunitiesapi.org"
KEY   = os.environ["JOA_KEY"]
BATCH = 20   # comma-separated domains per request

def get(path, **params):
    req = urllib.request.Request(
        f"{API}{path}?" + urllib.parse.urlencode(params),
        headers={"Authorization": f"Bearer {KEY}"},
    )
    with urllib.request.urlopen(req, timeout=90) as r:
        return json.load(r)

domains = [d.strip().lower() for d in sys.stdin if d.strip()]
out = {d: {"matched": False, "open_roles": 0, "titles": [], "categories": {}}
       for d in domains}

for i in range(0, len(domains), BATCH):
    chunk = domains[i:i + BATCH]
    page = get("/v1/jobs", company_domain=",".join(chunk), limit=200)
    by_company = collections.defaultdict(list)
    for job in page["data"]:
        by_company[job["company_slug"]].append(job)
    # Map rows back to the domain we asked about via the company record.
    for slug, jobs in by_company.items():
        company = get(f"/v1/companies/{slug}").get("data", {})
        domain = (company.get("website") or "").lower()
        if domain not in out:
            continue
        out[domain]["matched"] = True
        out[domain]["open_roles"] = company.get("open_roles", len(jobs))
        out[domain]["titles"] = [j["title"] for j in jobs[:5]]
        cats = collections.Counter(j.get("category") for j in jobs if j.get("category"))
        out[domain]["categories"] = dict(cats)

# A domain with no match is NOT a company that is not hiring: only ~37% of
# companies in the ledger carry a domain at all.
json.dump(out, sys.stdout, indent=2)

Record cost: one per job row and one per company row. Batching the domains keeps the request count down but not the record count: that is set by how many rows you ask to see, so cap limit to what you will actually store.

4Integrations that exist, and ones that do not

OpenAPI, Postman, the MCP server and five packaged integrations (WordPress, n8n, Make, Activepieces, Apify) work today. Everything else is a normal HTTP call in whichever tool you use.

In depthWhy it exists, what it is not, what people get wrong
RouteStatusHow
OpenAPIAvailable/openapi.json and /openapi.yaml, keyless, rendered by the running API.
Postman / Insomnia / BrunoAvailableImport the OpenAPI URL directly. Set Authorization: Bearer <key> at collection level.
Generated clientsAvailableAny OpenAPI generator against the same URL. There is no hand-written SDK and none is planned (see why).
Zapier / Pipedream / Clay / BubbleWorks, not packagedUse their generic HTTP request block against https://api.jobopportunitiesapi.org. The bare key is accepted without the Bearer prefix precisely because those tools give you one field.
Published plugins and connectorsAvailableA WordPress plugin for WP Job Manager, an n8n workflow template, a Make scenario blueprint, an Activepieces piece and an Apify Actor, each with install steps at /integrations.
MCP serverAvailableA remote server at api.jobopportunitiesapi.org/mcp (Streamable HTTP), six tools, two keyless. Setup for every client.
WebhooksNot availableThere is no push. Poll `/v1/changes`. It is designed for exactly this.

The rows marked not available are listed rather than omitted because the question comes up and a silent absence reads as an oversight. If one of them is what would make this usable for you, say so at /contact: that is how the order gets decided.

Exact contractTypes, defaults, ranges, errors, edge cases
Generate a client from the spec
# Any OpenAPI generator works; the spec is 3.1.0 and keyless.
curl -s https://jobopportunitiesapi.org/openapi.json -o joa-openapi.json

# e.g. with openapi-generator (not a JOA product; use whatever you already have)
openapi-generator-cli generate \
  -i joa-openapi.json -g typescript-fetch -o ./joa-client

This page was rendered 27 October 2026, 11:29 UTC. Every figure on it comes from the endpoint named beside it, and every published request is re-sent against the live API before this site is allowed to build. If something here is wrong, the thumbs-down above reaches a person.