# 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: https://hyperjobs.io/ats/gem

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`](/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=gem" \
  --data-urlencode "time_frame=7d" \
  --data-urlencode "limit=2"
```

```json title="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](/ats/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](/ats/rippling) or [Personio](/ats/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](/ats/linkedin) or another board, and the
[company join](/docs/objects/company) — 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](/docs/reference/coverage) for how the
tiers work and how often each one runs.

Query it with `?source=gem`.
[Quickstart](/docs/quickstart) · [Pricing](/pricing)
