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

**What this covers:** A job board; Watching an employer; Hiring signal for a CRM; Integrations that exist, and ones that do not.

**Assumed knowledge:** [Client code](./recipes-languages.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/recipes/build  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

<a id="recipe-job-board"></a>

## 1. A 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.

### 1.1 In depth

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.

> **Honour closures and withdrawals** — A row that arrives as `delisted` or `withdrawn` must stop being served on your site too. `withdrawn` includes employer opt-outs, so ignoring it means continuing to publish a vacancy for an employer who asked to be removed.

### 1.2 Exact contract

1. **Pick the slice.** `?country=NL&category=Engineering` — check the volume on [the per-country table](./ledger-coverage.md#countries-list) first.
2. **Seed** with `/v1/jobs` and the cursor, or start keyless with `/public/jobs` if one page of 50 is enough to launch with.
3. **Sync** with `/v1/changes` every fifteen minutes. [The loop](./endpoints-changes.md#changes-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 whole front end of a keyless board, in twenty lines

```javascript
// 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('');
```

**See also**

- [/public/jobs and /public/jobs/{id}](./endpoints-public.md#public-jobs)
- [The sync loop, written correctly](./endpoints-changes.md#changes-loop)
- [field_sources — per-field provenance](./ledger-provenance.md#field-sources)

<a id="recipe-watch-employer"></a>

## 2. Watching 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.

### 2.1 In depth

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.

### 2.2 Exact contract

watch.py — openings and closures for a set of employers

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

**See also**

- [Company — slug and domain](./api-parameters.md#params-company)
- [Company slugs are not yet guaranteed stable](./endpoints-companies.md#slug-instability)
- [An incremental sync](./recipes-sync.md#recipe-incremental-sync)

<a id="recipe-crm-enrichment"></a>

## 3. Hiring signal for a CRM

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

### 3.1 In depth

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

### 3.2 Exact contract

enrich.py — a batch of domains in, a hiring summary out

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

**See also**

- [Company — slug and domain](./api-parameters.md#params-company)
- [The company row](./api-fields.md#company-fields)
- [What counts as a record](./account-record-meter.md#meter-what-counts)

<a id="recipe-integrations"></a>

## 4. Integrations 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.

### 4.1 In depth

| Route | Status | How |
| --- | --- | --- |
| **OpenAPI** | Available | [/openapi.json](https://jobopportunitiesapi.org/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](./recipes-languages.md). |
| **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](./BUILD-A-SITE.md) and the OpenAPI spec directly. |
| **Webhooks** | Not available | There is no push. Poll [`/v1/changes`](./endpoints-changes.md) — 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](https://jobopportunitiesapi.org/contact) — that is how the order gets decided.

### 4.2 Exact contract

Generate a client from the spec

```bash
# 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
```

> **The one thing to configure in any tool** — Base URL `https://api.jobopportunitiesapi.org` — **not** the website host, which is edge-protected and will challenge a non-browser client. That single mistake is the most common support question this API gets.

**See also**

- [Two hosts, and which one to send requests to](./api-overview.md#hosts)
- [Three accepted spellings, and why](./api-authentication.md#auth-tolerant)
- [The paste-whole brief](./for-agents.md#agents-worked-brief)

---

## Where to go next

This file is part of **Recipes**. Others in the same group:

- [Client code](./recipes-languages.md) — A working client in curl, Python, JavaScript and Go. No SDK, no dependencies, and every request re-sent against the live API before this page ships.
- [Keeping data current](./recipes-sync.md) — Four jobs you will end up writing: a daily country pull, an incremental sync, closure detection, and a bulk export — with the record cost of each.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
