All platforms

Gem Jobs API.

Pull structured job listings, departments, and compensation from Gem-powered career boards through a single public GraphQL batch endpoint — no API key required.

Get API access
Gem
Live
50K+jobs indexed monthly
<3haverage discovery time
1hrefresh interval
Companies using Gem
RetoolBioRenderModular
Developer tools

Try the API.

Test Jobs and Feed endpoints against https://connect.jobo.world with live request/response examples, then copy ready-to-use curl commands.

What's in every response.

Data fields, real-world applications, and the companies already running on Gem.

Data fields
  • Full Job Descriptions
  • Structured Pay Ranges
  • Department & Team Names
  • Location Type & Remote Flags
  • Employment Type
  • First-Published Timestamps
Use cases
  1. 01Startup Job Aggregation
  2. 02Tech Hiring Trend Analysis
  3. 03Compensation Benchmarking
  4. 04Career Board Monitoring
Trusted by
RetoolBioRenderModular
DIY GUIDE

How to scrape Gem.

Step-by-step guide to extracting jobs from Gem-powered career pages—endpoints, authentication, and working code.

GraphQLintermediateNo published limit; keep ~200ms between requestsNo auth

Fetch all job listings from the board

POST the JobBoardList operation to the GraphQL batch endpoint to retrieve every posting for a company. The listings response carries titles, locations, and department info, but not full descriptions.

Step 1: Fetch all job listings from the board
import requests

board_id = "retool"
url = "https://jobs.gem.com/api/public/graphql/batch"

payload = [{
    "operationName": "JobBoardList",
    "variables": {"boardId": board_id},
    "query": """query JobBoardList($boardId: String!) {
        oatsExternalJobPostings(boardId: $boardId) {
            jobPostings {
                id
                extId
                title
                locations { id name city isoCountry isRemote }
                job { id department { id name } locationType employmentType }
            }
        }
        jobBoardExternal(vanityUrlPath: $boardId) {
            id teamDisplayName descriptionHtml pageTitle
        }
    }"""
}]

headers = {"Content-Type": "application/json", "batch": "true"}
response = requests.post(url, json=payload, headers=headers)
data = response.json()[0]["data"]

jobs = data["oatsExternalJobPostings"]["jobPostings"]
company_name = data["jobBoardExternal"]["teamDisplayName"]
print(f"Found {len(jobs)} jobs at {company_name}")

Parse the listing fields

Flatten each posting into a record. Descriptions and pay are absent here, so capture the extId now — you'll use it to fetch full details in the next step.

Step 2: Parse the listing fields
for job in jobs:
    locations = job.get("locations", [])
    job_info = job.get("job", {})

    print({
        "id": job["id"],
        "ext_id": job["extId"],
        "title": job["title"],
        "department": job_info.get("department", {}).get("name"),
        "location_type": job_info.get("locationType"),
        "employment_type": job_info.get("employmentType"),
        "is_remote": any(loc.get("isRemote") for loc in locations),
        "url": f"https://jobs.gem.com/{board_id}/{job['extId']}"
    })

Fetch full job details per posting

Run the ExternalJobPostingQuery operation with the boardId and extId to pull descriptionHtml, compensationHtml, and the intro/outro sections for a single job.

Step 3: Fetch full job details per posting
import requests

def get_job_details(board_id: str, ext_id: str) -> dict:
    url = "https://jobs.gem.com/api/public/graphql/batch"

    payload = [{
        "operationName": "ExternalJobPostingQuery",
        "variables": {"boardId": board_id, "extId": ext_id},
        "query": """query ExternalJobPostingQuery($boardId: String!, $extId: String!) {
            oatsExternalJobPosting(boardId: $boardId, extId: $extId) {
                id title descriptionHtml extId firstPublishedTsSec
                locations { id name city isoCountry isRemote }
                job {
                    id department { id name }
                    locationType employmentType requisitionId
                }
                jobPostSectionHtml { introHtml outroHtml }
                compensationHtml
            }
        }"""
    }]

    headers = {"Content-Type": "application/json", "batch": "true"}
    response = requests.post(url, json=payload, headers=headers)
    return response.json()[0]["data"]["oatsExternalJobPosting"]

# Fetch details for a specific job
job_details = get_job_details("retool", "4003629005")
print(job_details["title"])

Compose the complete description

Gem splits the posting body across three fields. Concatenate introHtml, descriptionHtml, and outroHtml in order to reconstruct the full job content.

Step 4: Compose the complete description
def compose_full_description(job_details: dict) -> str:
    sections = job_details.get("jobPostSectionHtml", {})
    parts = []

    if sections.get("introHtml"):
        parts.append(sections["introHtml"])
    if job_details.get("descriptionHtml"):
        parts.append(job_details["descriptionHtml"])
    if sections.get("outroHtml"):
        parts.append(sections["outroHtml"])

    return "\n".join(parts)

full_description = compose_full_description(job_details)
print(f"Full description length: {len(full_description)} characters")

Throttle requests and handle errors

Loop the detail fetch across every listing, pausing between calls and catching failures so one bad job doesn't abort the run. Gem publishes no rate limit, so stay conservative.

Step 5: Throttle requests and handle errors
import time
import requests

def fetch_all_jobs_with_details(board_id: str) -> list:
    # Step 1: Get listings
    url = "https://jobs.gem.com/api/public/graphql/batch"
    listings_query = [{
        "operationName": "JobBoardList",
        "variables": {"boardId": board_id},
        "query": "query JobBoardList($boardId: String!) { oatsExternalJobPostings(boardId: $boardId) { jobPostings { id extId title } } jobBoardExternal(vanityUrlPath: $boardId) { teamDisplayName } }"
    }]

    headers = {"Content-Type": "application/json", "batch": "true"}
    resp = requests.post(url, json=listings_query, headers=headers, timeout=30)
    jobs = resp.json()[0]["data"]["oatsExternalJobPostings"]["jobPostings"]

    # Step 2: Fetch details for each job
    results = []
    for i, job in enumerate(jobs):
        try:
            details = get_job_details(board_id, job["extId"])
            results.append({**job, "details": details})
            time.sleep(0.5)  # Be respectful with rate limiting
        except Exception as e:
            print(f"Error fetching job {job['extId']}: {e}")

        if (i + 1) % 10 == 0:
            print(f"Processed {i + 1}/{len(jobs)} jobs")

    return results
Common issues
criticalMissing 'batch: true' header breaks the request

The batch GraphQL endpoint expects a 'batch: true' header alongside Content-Type: application/json. Omit it and the endpoint rejects the call — set it on every request.

highJob descriptions are absent from the listings response

JobBoardList returns only basic fields. Issue a separate ExternalJobPostingQuery per extId to obtain descriptionHtml, compensationHtml, and the intro/outro sections.

mediumInvalid boardId returns null instead of an error

A non-existent board yields null data rather than an HTTP error. Confirm jobBoardExternal.teamDisplayName is present before treating the board as valid.

lowTwo different extId formats in the wild

extIds appear as numeric strings (e.g. '4003629005') and hash-like tokens (e.g. 'am9icG9zdDpK...'). Treat extId as an opaque string and never assume a numeric shape.

lowTimestamp units differ between fields

firstPublishedTsSec is in Unix seconds while startDateTs is in milliseconds. Divide or multiply by 1000 accordingly before converting to a datetime.

Best practices
  1. 1Send 'batch: true' plus Content-Type: application/json on every request
  2. 2Fetch listings first, then request per-job details only for postings you need
  3. 3Treat extId as an opaque string to survive both numeric and hash formats
  4. 4Rebuild descriptions from introHtml + descriptionHtml + outroHtml, in that order
  5. 5Add a short delay (~200-500ms) between detail requests to stay respectful
  6. 6Validate a board by checking jobBoardExternal.teamDisplayName before scraping
Or skip the complexity

One endpoint. All Gem jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=gem" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access Gem
job data today.

One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.

99.9%API uptime
<200msAvg response
50M+Jobs processed