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

CleanJobData Engineering

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.

On this page

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.

At a Glance

EndpointPOST https://jobs.gem.com/api/public/graphql/batch
API keyNone
ListJobBoardList query: every posting, one call
DetailExternalJobPosting query, one per job, batchable
PaginationNone
Post dateDetail only: firstPublishedTsSec
Salary fieldDetail only: compensationHtml (text)
Remotejob.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:

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

[
  {
    "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:

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)

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 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, another startup-focused ATS with structured compensation, and see the 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.

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.

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.