Gem Jobs API
Query jobs from Gem boards through one API with ?source=gem — plus how its job_posts endpoint behaves, and why it is not the Greenhouse clone it looks like.
Gem started as a recruiting CRM and sourcing tool and grew an ATS around it, now
used by mid-market tech companies running their own boards at
jobs.gem.com/<slug>. The public feed is one of the cleanest in this dataset:
a single unauthenticated GET returns a company's whole board with every
description already inline. No auth, no pagination, no detail pass. Gem is a
small source here at 858 postings, 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=gem" \
--data-urlencode "time_frame=7d" \
--data-urlencode "limit=2"{
"data": [
{
"id": "gem:4965519002",
"title": "Staff Software Engineer, Platform",
"company": {
"slug": "felix",
"name": "Felix",
"domain": "felix.io",
"website": "https://felix.io",
"logo": "https://media.licdn.com/dms/image/…",
"industry": "Software Development",
"employee_count": 140
},
"location": {
"remote": true,
"countries": ["United States"],
"cities": null,
"regions": null,
"raw": ["Remote - US"]
},
"employment_type": ["FULL_TIME"],
"work_arrangement": "Remote",
"seniority": "Staff",
"salary": null,
"skills": ["go", "kubernetes"],
"taxonomies": ["Engineering"],
"apply_url": "https://jobs.gem.com/felix/am9icG9zdDqhSGVsbG8",
"url": "https://jobs.gem.com/felix/am9icG9zdDqhSGVsbG8",
"source": "gem",
"posted_at": "2026-07-09T18:40:00.000Z",
"expires_at": null,
"updated_at": "2026-07-14T09:02:44.000Z"
}
],
"total": 2,
"limit": 2,
"offset": 0
}salary is null and expires_at is null on every Gem row, and that is the
platform rather than a gap in our parsing. See below.
How Gem boards actually work#
One GET, no auth, no pagination, whole board with bodies:
curl "https://api.gem.com/job_board/v0/<slug>/job_posts/"<slug> is the segment from jobs.gem.com/<slug>. The trailing slash
matters. Every query parameter is silently ignored — per_page, limit,
page, offset all return the same full board — which is harmless only because
the one response is already complete.
It looks like Greenhouse and it is not Greenhouse#
This is the whole story of the Gem integration. The endpoint's shape is close enough to Greenhouse's job board API that copying that mapper is the obvious move, and it fails four separate ways:
| Greenhouse | Gem | |
|---|---|---|
| Envelope | { "jobs": [ … ] } | a bare array |
id | a number | a string, and not the same format on every tenant |
content | entity-escaped HTML | raw HTML |
| Bodies | needs ?content=true | always inline, and ignores the param |
Each of those breaks quietly rather than loudly. The envelope difference is the kind that throws immediately and is therefore the harmless one. The others are not:
- The id is a string and its format is not stable across tenants. One board
serves
"4965519002", which coerces to a number perfectly happily. Another serves an opaque base64 token. Code that coerces ids to numbers works on the first tenant and corrupts the rest. - The content is already raw HTML. Running it through an unescape step, as you must for Greenhouse, mangles every description on the board.
The fields Gem does give you#
location_type is a clean three-way enum, remote / hybrid / in_office,
rather than a boolean. That is genuinely rare, and it means hybrid survives
instead of collapsing to remote-or-not — no parsing a location label the way
Rippling or Personio make you.
departments[] is a real hierarchy rather than a flat list: entries carry a
parent_id, so the parent is the department and a child is the team. Join them
into one string and you lose the distinction that was handed to you.
first_published_at is a proper ISO timestamp with a zone.
What bites you#
There is no salary anywhere. Not a range, not a summary, not a currency — zero compensation keys across all 104 jobs on the largest board we've measured. Anything you see attributed to a Gem posting was inferred from the description, not published by the platform.
There is no expiry either. No validThrough, no close date, no expiry key on
any posting. The only way to know a Gem role is gone is to fetch the board again
and notice it's missing, which is what board diffing is, and it means a pulled
role is reaped only as fast as you re-fetch.
location is free text. It's whatever the employer typed. offices[] is
the structured fallback when the free-text field is blank, but it is a fallback,
not ground truth.
Or use ours#
The fetch really is one line. The reason to not write it yourself is everything downstream of the fetch.
You get ids kept as strings so no tenant silently corrupts, the raw HTML left
raw, the department and team pulled apart on parent_id instead of concatenated,
the three-way location_type mapped straight through to work_arrangement,
expiry recovered by board diffing since the platform publishes none, dedup
against the same role on LinkedIn or another board, and the
company join — a Gem board tells you the slug and
nothing about who that is.
Gem is in the fast refresh tier, attempted every cycle, because one call returns a whole board with bodies. See coverage for how the tiers work and how often each one runs.
Query it with ?source=gem.
Quickstart · Pricing
Every source above normalises onto the same schema, with the hiring company already joined onto each posting.
Get an API key