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.
1A job board
A niche board is a filtered listing, a detail page and an apply link. The keyless surface is enough to start; a key is what lets it page.
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.
Three things you must carry, and one of them is a licence obligation. 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
- Pick the slice.
?country=NL&category=Engineering— check the volume on the per-country table first. - Seed with
/v1/jobsand the cursor, or start keyless with/public/jobsif one page of 50 is enough to launch with. - Sync with
/v1/changesevery fifteen minutes. The loop. - Detail pages from your own store, not from a live call — you already have the row. Fetch the description once with
include_description=true(rememberlimitcaps at 50 with it on). - Facets from
/public/facets, cached hourly, so your filter UI shows real counts and never offers a value the API will reject. - Apply links straight to
apply_url, withrel="nofollow noopener".
// Keyless: safe in a browser, no backend, CORS is open.
const qs = new URLSearchParams({ country: 'NL', limit: '20' });
const { data } = await (
await fetch(`https://api.jobopportunitiesapi.org/public/jobs?${qs}`)
).json();
const verified = iso =>
new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
document.querySelector('#board').innerHTML = 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('');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
"""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 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 and Postman work today, from the published spec. Everything else is a normal HTTP call in whichever tool you use.
In depthWhy it exists, what it is not, what people get wrong
| Route | Status | How |
|---|---|---|
| OpenAPI | Available | /openapi.json and /openapi.yaml, keyless, rendered by the running API. |
| Postman / Insomnia / Bruno | Available | Import the OpenAPI URL directly. Set Authorization: Bearer <key> at collection level. |
| Generated clients | Available | Any OpenAPI generator against the same URL. There is no hand-written SDK and none is planned — see why. |
| Zapier / Make / n8n / Clay / Bubble | Works, not packaged | Use 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. |
| A published plugin or connector | Not available | None exists today. This row will say otherwise when one does. |
| MCP server | Not available | None is published. A model can use /docs/ai/BUILD-A-SITE.md and the OpenAPI spec directly. |
| Webhooks | Not available | There 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
# 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-clientThis page was rendered 10 September 2026, 15:56 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.