Gem Job Board API: Get Gem Job Postings as JSON (No Key)
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
| 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:
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
firstPublishedTsSecis 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 withstartDateTs, which is the job's start date and wasnullon every posting we sampled.descriptionHtmlis 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.compensationHtmlis 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[].isoCountryis an ISO 3166 alpha-3 code (USA,GBR,CAN), not alpha-2, so convert it before matching against most other sources.locations[].namecan name several countries with one code: "United States / Canada" carriesisoCountry: "USA"only. Whencityis empty, read country names fromnameas well.locations[].cityis sometimes a placeholder like "Remote" or "Remote within Canada" rather than a city.- Use the posting's
locations, notjob.locations. If you also request the job-levellocations, 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.locationTypeisREMOTE,HYBRIDorIN_OFFICE, and it agreed with the per-locationisRemoteflags on every job we sampled.job.employmentTypeis an enum such asFULL_TIME,PART_TIME,CONTRACTorINTERN.- No company website or logo.
teamDisplayNamegives 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.
- Jobs API reference for every filter and response field
- Playground to run queries without writing code
- 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.