# Join.com Jobs API

> Query jobs from Join.com boards through one API with ?source=join_com — plus how Join.com's company page and jobs API really behave.

Source: https://hyperjobs.io/ats/join

Join.com is an ATS built for European small and mid-sized employers, and it is
the platform in this dataset with the best company identity by a wide margin.
Each customer's board lives at `join.com/companies/<slug>`, and every posting
carries the employer's own domain in its structured data — so a Join.com job
arrives already knowing who is hiring, which is not true of most boards. There
are 44,225 Join.com postings here, a number that moves hourly, so read it from
[`/v1/meta`](/docs/api-reference/meta) rather than trusting this sentence.

## Try it

```bash
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "source=join_com" \
  --data-urlencode "time_frame=7d" \
  --data-urlencode "limit=2"
```

<Note>
  The value is `join_com`, with an underscore, not `join.com`. It is the key in
  [`/v1/meta`](/docs/api-reference/meta) → `sources` and the `source` field on
  every returned posting, so a value you read back can be fed straight in as a
  filter.
</Note>

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "join_com:284917",
      "title": "Kundenberater (m/w/d)",
      "company": {
        "slug": "ea-placements",
        "name": "EA Placements",
        "domain": "eaplacements.de",
        "website": "https://eaplacements.de",
        "logo": "https://media.licdn.com/dms/image/…",
        "industry": "Staffing and Recruiting",
        "employee_count": 24
      },
      "location": {
        "remote": false,
        "countries": ["Germany"],
        "cities": ["Hamburg"],
        "regions": ["Hamburg"],
        "raw": ["Hamburg, Hamburg, Germany"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "On-site",
      "seniority": null,
      "salary": {
        "min": 42000,
        "max": 52000,
        "currency": "EUR",
        "unit": "YEAR",
        "annual_min": 42000,
        "annual_max": 52000,
        "summary": "€42,000 – €52,000"
      },
      "skills": ["kundenbetreuung", "crm"],
      "taxonomies": ["Customer Service"],
      "apply_url": "https://join.com/companies/ea-placements/jobs/284917",
      "url": "https://join.com/companies/ea-placements/jobs/284917",
      "source": "join_com",
      "posted_at": "2026-07-11T07:22:00.000Z",
      "expires_at": "2026-09-09T00:00:00.000Z",
      "updated_at": "2026-07-15T04:03:11.000Z"
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

Join.com is one of the few platforms here where `expires_at` can be non-null:
its detail pages carry a schema.org `validThrough` when the employer sets one, so
a role can age out on its own rather than only by disappearing from the board.
Most platforms in this collection publish no expiry at all.

## How Join.com boards actually work

Two steps, and the first one is not optional:

```bash
# 1. The company page — the only place the numeric company id is published
curl "https://join.com/companies/<slug>"

# 2. The jobs API, keyed by that id
curl "https://join.com/api/public/companies/<id>/jobs?page=1&pageSize=5"
```

The API is **id-only**. Every slug-shaped form of it 404s — `/companies/<slug>/jobs`,
`/companies/slug/<slug>/jobs` — which was probed rather than assumed. So you
cannot go straight from a board URL to its jobs. You have to fetch the page
first, and the id sits at roughly 96% of the way through a 130KB body. `Range`
is not honoured either: ask for the tail and you get a 200 with the whole thing.
The sitemap is 1,113 marketing URLs and contains no companies at all.

<Warning title="`pageSize` is capped at 5">
  Six or more returns **422**. This is the kind of limit that punishes the
  obvious optimisation: a value of `10` scraped literally zero jobs, because
  every page errored and an errored page looks like an empty board.
</Warning>

### The two surfaces need opposite treatment

This is the interesting part of Join.com, and it is the reverse of what anyone
would guess.

| Surface | Size | Rate limit |
| --- | --- | --- |
| `/companies/<slug>` — the company page | ~130KB | none measured (200 rapid fetches, 0 errors) |
| `/api/public/companies/<id>/jobs` — the JSON | a few KB | severe, per IP |

The fat HTML page is unthrottled. The tiny JSON API is throttled hard: 60 calls
at concurrency 2 returned 25 `429`s; at 8, 56 of 60 failed; at 24, 59 of 60.

So the correct configuration is the opposite of the intuitive one. **Proxy the
few-KB JSON, and send the 130KB page direct.** Rotating IPs give each API call
its own rate budget and the payload is small enough that proxying it costs
almost nothing, while the page needs no help and would burn the whole bandwidth
budget going through a metered path.

Our config had this exactly inverted, because the JSON helper defaulted to no
proxy: the one surface that needed rotating IPs never got them and spent its
life retrying its own 429s, while the fat page that needed nothing rode the
residential proxy. Fixing the direction took a cycle from ~2.7GB to ~153MB.

### Under stress it answers 403, not 429

Join.com's burst response is a WAF `403` rather than a rate limit, and the
distinction matters: `403` is not in the usual transient set, so each one fails
fast and burns an error strike toward the five that disable a board. That made
cautious concurrency look mandatory. It isn't. Measured at concurrency 96, a
403 burst hit 147 of 4,000 boards and all 147 succeeded on the very next
attempt. The 403 is a burst, not a block. Retried rather than counted, the warm
cycle went from 12m08s to 4m43s.

## What bites you

**Finding the boards is the entire problem.** Join.com has a finished, correct
adapter and it scraped almost nothing for months, for one reason: nobody had a
list of boards. We had 141. The public JobHive dataset had 23,548 sitting in its
largest file, which was the one file of that dataset we had never imported.
141 boards became 23,601. The fetch was never the bottleneck.

**The list item is richer than the detail page.** It carries structured location,
coordinates, category, an employment-type enum at 100% fill, and salary **in
cents**. The detail page's JSON-LD has the body and not much else you can't get
cheaper. Read only the detail and you pay an N+1 to get less.

**The board token is just a slug, and it is often not the employer.**
`fawazcentralcom` is really "EA Placements". The employer's actual name and
domain are in the posting's `hiringOrganization`, which ships `sameAs` on every
row. That is why identity here is 99% employer-site against Greenhouse's 15%.

**Names arrive HTML-escaped.** About 10% of companies come through as `F&amp;E GmbH`,
and sometimes as `&Amp;`. That reads as cosmetic and isn't: the escaped and
unescaped forms key apart, so the same employer lands twice as two companies.

**Franchise fan-out is real and should not be filtered.** Nine separate advisors
under `dvag.de`, several `subway.ch` locations. Each franchisee is a genuinely
separate business posting under the franchisor's domain, so collapsing them
would be wrong.

## Or use ours

The fetch is two calls. Everything around them isn't.

You get the company id cached rather than re-learned every cycle (a naive hourly
run pays 23,601 fetches and ~3GB to re-download a constant that never changes),
the 403 bursts absorbed in-cycle instead of disabling healthy boards, the rich
list preferred over the detail with the detail as fallback, salary converted out
of cents, names unescaped so an employer doesn't split in two, and the
[company join](/docs/objects/company) already done.

We can also say something about Join.com that isn't true of any other platform
here: **we have essentially all of it, and that is measured.** Its ids are
integers and its API is id-addressable, so there is nothing to discover. The id
space runs 34 to 182,590; we hold 23,533 of them. Probing 899 ids we don't hold,
56.3% are real companies but only 1.1% have a single live job. The rest are dead
accounts that signed up and never posted. Extrapolated, about 1,769 boards and
4,334 jobs are missing — roughly 1.1x what we already have.

Field coverage across the pool: descriptions 99.9%, contract type 100%, remote
100%, country 99.8%, coordinates 99.5%, salary 31% (most European SMBs simply
don't post one). Read the live numbers from
[`/v1/meta`](/docs/api-reference/meta); see [coverage](/docs/reference/coverage)
for how fill rates are measured.

Join.com sits in the heavy tier rather than the fast one, because of the page
fetch its id step requires. Query it with `?source=join_com`.
[Quickstart](/docs/quickstart) · [Pricing](/pricing)
