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.
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 rather than trusting this sentence.
Try it#
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"The value is join_com, with an underscore, not join.com. It is the key in
/v1/meta → sources and the source field on
every returned posting, so a value you read back can be fed straight in as a
filter.
{
"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:
# 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.
`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.
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 429s; 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&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 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; see 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 · Pricing
Every source above normalises onto the same schema, with the hiring company already joined onto each posting.
Get an API key