All platforms

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.

?source=gemsource reference

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#

BASH
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"
Illustrative — the shape, not a live response
{
  "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:

BASH
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:

GreenhouseGem
Envelope{ "jobs": [ … ] }a bare array
ida numbera string, and not the same format on every tenant
contententity-escaped HTMLraw HTML
Bodiesneeds ?content=truealways 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