# Gem Job Board API: Get Gem Job Postings as JSON (No Key)

> Gem job boards load jobs from a public GraphQL endpoint with no key. Two batched queries return every posting, its description, publish time and pay.

Source: https://cleanjobdata.com/articles/how-to-scrape-gem-job-listings
Published: 2026-11-01

TL;DR: Gem job boards at jobs.gem.com/{board} are client-rendered pages that call a public GraphQL endpoint, https://jobs.gem.com/api/public/graphql/batch, with no API key. One JobBoardList query returns every posting on the board with no pagination; the description, firstPublishedTsSec and compensationHtml come from an ExternalJobPosting query per job, and many of those can be sent in one batched POST. A board that doesn't exist returns HTTP 200 with jobBoardExternal set to null, not a 404.

To get a company's jobs from Gem, send a `JobBoardList` GraphQL query to `https://jobs.gem.com/api/public/graphql/batch` with the board's slug, then an `ExternalJobPosting` query per job for the description, publish date and pay. The endpoint is public and needs no API key, and it's what the `jobs.gem.com` career page calls in your browser. Gem's documented Job Board API is a different thing: it needs the employer's own key, so it can't be used for other companies' boards.

That's the DIY route, and it works for a handful of companies you already know. If you'd rather not build and maintain a Gem scraper, CleanJobData already ingests ATS platforms and company career sites and serves the jobs normalized through one REST API — see the [Jobs API docs](https://cleanjobdata.com/docs/api/jobs).

## At a Glance

| | |
|---|---|
| Endpoint | `POST https://jobs.gem.com/api/public/graphql/batch` |
| API key | None |
| List | `JobBoardList` query: every posting, one call |
| Detail | `ExternalJobPosting` query, one per job, batchable |
| Pagination | None |
| Post date | Detail only: `firstPublishedTsSec` |
| Salary field | Detail only: `compensationHtml` (text) |
| Remote | `job.locationType` and per-location `isRemote` |

## What Gem Is

Gem is a recruiting platform that started as a sourcing CRM and now includes a full applicant tracking system. Its customers are mostly tech companies and startups. Companies that use Gem as their ATS get a hosted job board at `jobs.gem.com/{board}`.

That page is a small client-rendered shell with no job data in its HTML. Your browser downloads Gem's JavaScript, which then calls the GraphQL endpoint. So fetching the page HTML gets you nothing; the GraphQL call is the source.

## Finding the Board Slug

The board slug is the path segment after `jobs.gem.com`:

```
https://jobs.gem.com/acme         -> board slug "acme"
https://jobs.gem.com/acme/{jobId} -> a job page on that board
```

If a company embeds the board on its own site, open the Network tab on its careers page and look for a `POST` to `jobs.gem.com/api/public/graphql/batch`. The slug is the `boardId` variable in the request body.

## Listing Jobs

The endpoint takes a JSON **array** of operations and returns an array of results in the same order. A single list query looks like this:

```bash
curl -X POST "https://jobs.gem.com/api/public/graphql/batch" \
  -H "content-type: application/json" \
  -H "origin: https://jobs.gem.com" \
  -d '[{
    "operationName": "JobBoardList",
    "variables": { "boardId": "acme" },
    "query": "query JobBoardList($boardId: String!) { oatsExternalJobPostings(boardId: $boardId) { jobPostings { id extId title locations { id name city isoCountry isRemote extId } job { id department { id name extId } locationType employmentType } } } jobBoardExternal(vanityUrlPath: $boardId) { id teamDisplayName } }"
  }]'
```

The response (trimmed, values illustrative):

```json
[
  {
    "data": {
      "oatsExternalJobPostings": {
        "jobPostings": [
          {
            "id": "T2F0c0pvYlBvc3Q6Mjc0Mjg4MQ==",
            "extId": "4630409005",
            "title": "Compiler Engineer",
            "locations": [
              { "id": "72382", "name": "United States / Canada", "city": "", "isoCountry": "USA", "isRemote": true, "extId": "4012125005" },
              { "id": "99967", "name": "United Kingdom", "city": "", "isoCountry": "GBR", "isRemote": true, "extId": "bG9jOgxP" }
            ],
            "job": {
              "id": "T2F0c0pvYjoxOTMyMzA3",
              "department": { "id": "75211", "name": "Engineering", "extId": "4135998005" },
              "locationType": "REMOTE",
              "employmentType": "FULL_TIME"
            }
          }
        ]
      },
      "jobBoardExternal": { "id": "…", "teamDisplayName": "Acme" }
    }
  }
]
```

There's no pagination: one call returns every posting on the board. There's also no total to check the list against, so if a large board returns a suspiciously round number of jobs (100, 250, 500), compare it with what the career page shows.

`extId` is the ID to keep. It's what job page URLs use (`https://jobs.gem.com/acme/{extId}`) and what the detail query takes. `teamDisplayName` is the company's display name.

## Fetching the Detail

The list has no description, no date and no pay. Those come from `ExternalJobPosting`:

```graphql
query ExternalJobPosting($boardId: String!, $extId: String!) {
  oatsExternalJobPosting(boardId: $boardId, extId: $extId) {
    id title descriptionHtml extId startDateTs firstPublishedTsSec isUnlistedExternally
    locations { id extId name city isoCountry isRemote }
    job { id locationType employmentType requisitionId teamDisplayName department { id extId name } }
    jobPostSectionHtml { introHtml outroHtml }
    compensationHtml
  }
}
```

Because the endpoint takes an array, you can put several detail queries in one `POST`, one operation per job, and get the results back in the same order. Keep batches modest; around 10 per request is comfortable.

## Fetching Gem Jobs (Python)

```python
import time
from datetime import datetime, timezone

import requests

URL = "https://jobs.gem.com/api/public/graphql/batch"

LIST_QUERY = """query JobBoardList($boardId: String!) {
  oatsExternalJobPostings(boardId: $boardId) {
    jobPostings { id extId title
      locations { id name city isoCountry isRemote extId }
      job { id department { id name extId } locationType employmentType } }
  }
  jobBoardExternal(vanityUrlPath: $boardId) { id teamDisplayName }
}"""

DETAIL_QUERY = """query ExternalJobPosting($boardId: String!, $extId: String!) {
  oatsExternalJobPosting(boardId: $boardId, extId: $extId) {
    id title descriptionHtml extId firstPublishedTsSec isUnlistedExternally
    locations { id extId name city isoCountry isRemote }
    job { id locationType employmentType requisitionId department { id extId name } }
    jobPostSectionHtml { introHtml outroHtml }
    compensationHtml
  }
}"""


def post(board, ops):
    res = requests.post(
        URL,
        json=ops,
        headers={"origin": "https://jobs.gem.com", "referer": f"https://jobs.gem.com/{board}"},
        timeout=40,
    )
    res.raise_for_status()
    return res.json()


def list_postings(board):
    [result] = post(board, [{"operationName": "JobBoardList", "variables": {"boardId": board}, "query": LIST_QUERY}])
    if result.get("errors"):
        raise RuntimeError(result["errors"][0].get("message"))
    data = result["data"]
    if data["jobBoardExternal"] is None:
        raise LookupError(f"Gem board {board!r} not found")  # HTTP 200, not 404
    return data["oatsExternalJobPostings"]["jobPostings"]


def fetch_details(board, ext_ids, batch_size=10):
    out = []
    for i in range(0, len(ext_ids), batch_size):
        chunk = ext_ids[i : i + batch_size]
        ops = [
            {"operationName": "ExternalJobPosting", "variables": {"boardId": board, "extId": e}, "query": DETAIL_QUERY}
            for e in chunk
        ]
        for ext_id, result in zip(chunk, post(board, ops)):
            posting = (result.get("data") or {}).get("oatsExternalJobPosting")
            if not posting or posting["extId"] != ext_id:
                continue  # missing jobs come back as null, not an error
            sections = posting.get("jobPostSectionHtml") or {}
            parts = [sections.get("introHtml"), posting.get("descriptionHtml"),
                     posting.get("compensationHtml"), sections.get("outroHtml")]
            out.append({
                "id": ext_id,
                "title": posting["title"].strip(),
                "published": datetime.fromtimestamp(posting["firstPublishedTsSec"], tz=timezone.utc),
                "description_html": "".join(f"<div>{p}</div>" for p in parts if p),
                "pay_text": posting.get("compensationHtml"),
                "locations": posting["locations"],
                "location_type": (posting.get("job") or {}).get("locationType"),
                "employment_type": (posting.get("job") or {}).get("employmentType"),
                "url": f"https://jobs.gem.com/{board}/{ext_id}",
            })
        time.sleep(0.3)
    return out


postings = list_postings("acme")
jobs = fetch_details("acme", [p["extId"] for p in postings])
```

## Fields Worth Knowing

- **`firstPublishedTsSec`** is the publish time in epoch **seconds**. It's only on the detail, so you need a detail call for every new job before you know how old it is. Don't confuse it with `startDateTs`, which is the job's start date and was `null` on every posting we sampled.
- **`descriptionHtml`** is the posting itself, but not the whole page. Some boards add the same intro blurb (`jobPostSectionHtml.introHtml`, several hundred characters on one board) and an outro, usually the EEO statement (`outroHtml`), to every job. Others leave both empty. Include them if you want what candidates see.
- **`compensationHtml`** is pay as formatted text, such as "$120,000 – $195,000 per year" or "$90 – $100 per hour". There are no numeric fields, so parse the text and annualize hourly rates. The [salary extraction guide](https://cleanjobdata.com/guides/salary-data-extraction) covers this.
- **`locations[].isoCountry`** is an ISO 3166 **alpha-3** code (`USA`, `GBR`, `CAN`), not alpha-2, so convert it before matching against most other sources.
- **`locations[].name`** can name several countries with one code: "United States / Canada" carries `isoCountry: "USA"` only. When `city` is empty, read country names from `name` as well.
- **`locations[].city`** is sometimes a placeholder like "Remote" or "Remote within Canada" rather than a city.
- **Use the posting's `locations`, not `job.locations`.** If you also request the job-level `locations`, you'll find it's the requisition's list and can include places the posting doesn't advertise. On 17 of 107 jobs we checked it was longer, for example adding "United States" to a UK-only posting.
- **`job.locationType`** is `REMOTE`, `HYBRID` or `IN_OFFICE`, and it agreed with the per-location `isRemote` flags on every job we sampled.
- **`job.employmentType`** is an enum such as `FULL_TIME`, `PART_TIME`, `CONTRACT` or `INTERN`.
- **No company website or logo.** `teamDisplayName` gives you the name only.

## Gotchas

**"Not found" is a 200.** A board slug that doesn't exist returns HTTP 200, an empty posting list and no `errors`. The only signal is `jobBoardExternal: null`. Before you delete a company's jobs on that basis, retry once after a pause and check that a board you know exists (Gem's own, at `jobs.gem.com/gem`) still returns normally. If several boards read as missing at once, the API has probably changed.

**Missing jobs are also nulls.** A detail query for a job that's gone, a wrong board or a malformed `extId` returns `oatsExternalJobPosting: null` with no error. Treat it as "skip for now" and let the job expire when it drops out of the list.

**Shared endpoint, shared failures.** Every board uses the same URL, so an HTTP 401, 403 or 404 from it is about the endpoint, not about one board.

**Old jobs cost a detail call every run.** The list has no date, so a job you've decided is too old will be fetched again next time unless you remember its `extId`.

**No webhooks.** Poll `JobBoardList`, compare `extId` values with your last complete list, and expire jobs that disappear.

## When This Is Enough

For one company, this is a tidy API: one list call, batched details, and real pay text on many postings.

## When It Isn't

Across many boards you still need to know each slug, parse pay from text, convert alpha-3 country codes and resolve free-text location names. Compare it with [Ashby](https://cleanjobdata.com/articles/how-to-scrape-ashby-job-listings), another startup-focused ATS with structured compensation, and see the [ATS public job postings API list](https://cleanjobdata.com/articles/ats-public-job-postings-api-list) for 33 platforms side by side.

## Skip the scraper: get this data from the CleanJobData API

If you'd rather not maintain a Gem collector, you can query normalized jobs from ATS platforms and company career sites with a single request. The response has structured city/state/country locations, parsed salary when the employer publishes one, a remote type, and company metadata, and you page through results with the cursor in `pagination.next_page`.

```bash
curl "https://api.cleanjobdata.com/jobs?title=engineer&domain=jobs.gem.com&limit=20" \
  -H "Authorization: Bearer $CLEANJOBDATA_API_KEY"
```

The `domain` filter is an exact match on the host of each job's `application_url`, so it only returns jobs whose apply link resolves to `jobs.gem.com`; use `company_website_url` or `employer_id` to scope to one employer instead. Coverage depends on which employers are in the dataset, so check that the companies you care about appear before you rely on it.

- [Jobs API reference](https://cleanjobdata.com/docs/api/jobs) for every filter and response field
- [Playground](https://cleanjobdata.com/playground) to run queries without writing code
- [Pricing](https://cleanjobdata.com/pricing) — the Trial is free with no card required

## FAQ

### Do I need an API key to get jobs from a Gem job board?

No. The GraphQL endpoint at `https://jobs.gem.com/api/public/graphql/batch` that powers `jobs.gem.com` boards is public. Gem's documented Job Board API does need a key, but only the employer can create one, so it doesn't help with other companies' boards.

### How do I get the job description from Gem?

Send an `ExternalJobPosting` query with the board slug and the job's `extId`. The list query doesn't include descriptions, dates or pay, and you can batch several detail queries in one `POST`.

### How do I tell if a Gem board doesn't exist?

Check `jobBoardExternal` in the `JobBoardList` response. A missing board still returns HTTP 200; `jobBoardExternal` is `null` and the posting list is empty.

### Does Gem include salary data?

Many postings carry `compensationHtml`, a formatted pay range such as "$120,000 – $195,000 per year". It's text, not numbers, so you have to parse it and annualize hourly rates yourself.
