All platforms

Google Careers Jobs API.

Pull Google's first-party job listings — titles, global locations, authoritative result counts and full descriptions — parsed from the server-rendered careers pages and delivered through one normalized Jobo endpoint.

Get API access
Google Careers
Live
<3haverage discovery time
1hrefresh interval
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 Google Careers.

Data fields
  • Full Job Descriptions
  • Native Google Job IDs
  • Global Office Locations
  • First-Party Apply URLs
  • Authoritative Result Counts
  • Canonical Listing URLs
Use cases
  1. 01Big Tech Hiring Trends
  2. 02Competitive Talent Intelligence
  3. 03Job Board Aggregation
  4. 04Location & Result Coverage Analysis
DIY GUIDE

How to scrape Google Careers.

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

HTMLadvancedUndocumented; throttle to ~100ms between requests and cap detail fetches at ~6 concurrentNo auth

Fetch the server-rendered listings page

Google Careers has no anonymous jobs API or JobPosting JSON-LD — the global result list is server-rendered HTML at /about/careers/applications/jobs/results. Start with page=1, then use the exact global next URL published by Google for every continuation request.

Step 1: Fetch the server-rendered listings page
import requests
from bs4 import BeautifulSoup

LISTINGS_URL = "https://www.google.com/about/careers/applications/jobs/results"
HEADERS = {"Accept": "text/html"}

def first_page_url() -> str:
    return f"{LISTINGS_URL}?page=1"

def fetch_html(url: str) -> str:
    resp = requests.get(url, headers=HEADERS, timeout=30)
    resp.raise_for_status()
    return resp.text

Parse semantic listing contracts

Discover jobs from numeric /jobs/results/{id}- links with a `Learn more about …` accessible label, then use the nearest list item for the title fallback and location text beside the `place` icon. Resolve links through the exact declared document base and strip filter parameters from the canonical URL.

Step 2: Parse semantic listing contracts
import re
from urllib.parse import urljoin, urlsplit, urlunsplit

JOB_PATH_RE = re.compile(
    r"^/about/careers/applications/jobs/results/(\d+)-[^/]+/?$"
)
TOTAL_RE = re.compile(
    r"Showing\s+\d[\d,]*\s+to\s+\d[\d,]*\s+of\s+(\d[\d,]*)\s+rows?",
    re.I,
)
DOC_BASE = "https://www.google.com/about/careers/applications/"
LABEL_PREFIX = "Learn more about "

def parse_listings(html: str, page_url: str) -> tuple[list[dict], int, str | None]:
    soup = BeautifulSoup(html, "html.parser")
    base_tag = soup.find("base", href=True)
    doc_base = urljoin(page_url, base_tag["href"]) if base_tag else None
    if doc_base != DOC_BASE:
        raise ValueError("Google Careers document base changed")

    main = soup.find("main")
    status = main.select_one("[role='status']") if main else None
    total_match = TOTAL_RE.search(status.get_text(" ", strip=True)) if status else None
    if not total_match:
        raise ValueError("authoritative result total is missing")
    advertised_total = int(total_match.group(1).replace(",", ""))

    jobs = []
    anchors = main.select(
        "a[aria-label^='Learn more about '][href*='jobs/results/']"
    ) if main else []
    for anchor in anchors:
        card = anchor.find_parent("li")
        href = anchor.get("href")
        label = anchor.get("aria-label", "")
        title = label.removeprefix(LABEL_PREFIX).strip()
        if not title and card:
            heading = card.find("h3")
            title = heading.get_text(" ", strip=True) if heading else ""
        if not card or not href or not title:
            raise ValueError("malformed Google Careers result candidate")

        detail_url = urljoin(doc_base, href)
        parts = urlsplit(detail_url)
        if parts.scheme != "https" or parts.netloc != "www.google.com":
            raise ValueError("out-of-scope Google Careers detail URL")
        match = JOB_PATH_RE.fullmatch(parts.path)
        if not match:
            raise ValueError("detail route omitted its numeric job ID")

        locations = []
        place = next((node for node in card.select("[aria-hidden='true']")
                      if node.get_text(" ", strip=True).lower() == "place"), None)
        if place and place.parent:
            for node in place.parent.find_all("span", recursive=False):
                value = node.get_text(" ", strip=True).lstrip(";").strip()
                if value and not re.fullmatch(r"\+\d+\s+more", value, re.I):
                    locations.append(value)

        jobs.append({
            "external_id": match.group(1),
            "listing_url": urlunsplit((parts.scheme, parts.netloc, parts.path, "", "")),
            "title": title,
            "company": "Google",
            "locations": list(dict.fromkeys(locations)),
        })

    next_link = main.select_one("a[aria-label='Go to next page'][href]") if main else None
    next_url = urljoin(page_url, next_link["href"]) if next_link else None
    return jobs, advertised_total, next_url

Follow exact pagination and reconcile coverage

Read the authoritative total from the `role=status` text on every page. Follow only the exact global `Go to next page` URL, require its sole page number to advance by one, reject duplicate IDs, and reconcile the final emitted ID count against Google's advertised total. A zero-row continuation is a parse failure, not normal termination.

Step 3: Follow exact pagination and reconcile coverage
import time
from urllib.parse import parse_qs, urlsplit

def validate_page_url(url: str, expected_page: int) -> None:
    parts = urlsplit(url)
    query = parse_qs(parts.query)
    valid = (
        parts.scheme == "https"
        and parts.netloc == "www.google.com"
        and parts.path == "/about/careers/applications/jobs/results"
        and query == {"page": [str(expected_page)]}
    )
    if not valid:
        raise ValueError("inconsistent Google Careers next-page URL")

def scrape_all_listings() -> list[dict]:
    page, url = 1, first_page_url()
    jobs_by_id, advertised_total = {}, None
    while url:
        validate_page_url(url, page)
        page_jobs, page_total, next_url = parse_listings(fetch_html(url), url)
        if advertised_total is not None and page_total != advertised_total:
            raise ValueError("advertised total changed between pages")
        advertised_total = page_total
        if not page_jobs and (page > 1 or page_total > 0):
            raise ValueError("unexpected zero-row page")
        for job in page_jobs:
            if job["external_id"] in jobs_by_id:
                raise ValueError(f"duplicate job ID: {job['external_id']}")
            jobs_by_id[job["external_id"]] = job
        if next_url:
            validate_page_url(next_url, page + 1)
        url, page = next_url, page + 1
        time.sleep(0.1)  # ~100ms between requests

    if len(jobs_by_id) != advertised_total:
        raise ValueError(
            f"coverage mismatch: emitted {len(jobs_by_id)} of {advertised_total}"
        )
    return list(jobs_by_id.values())

Hydrate semantic job details

Read the title from Open Graph metadata and build the description from unique parents of the recognized semantic h3 headings. Resolve and validate the current first-party apply route without using its opaque token as job identity, use the complete preferred-working-location note when present, and otherwise retain listing locations. Treat 404/410 responses as removed roles.

Step 4: Hydrate semantic job details
DETAIL_HEADINGS = {
    "minimum qualifications",
    "preferred qualifications",
    "about the job",
    "responsibilities",
}

def fetch_job_details(listing: dict) -> dict | None:
    resp = requests.get(listing["listing_url"], headers=HEADERS, timeout=30)
    if resp.status_code in (404, 410):
        return None  # the role was taken down
    resp.raise_for_status()
    soup = BeautifulSoup(resp.text, "html.parser")

    base_tag = soup.find("base", href=True)
    document_base = urljoin(listing["listing_url"], base_tag["href"]) if base_tag else None
    if document_base != DOC_BASE:
        raise ValueError("Google Careers detail document base changed")

    title_meta = soup.select_one("meta[property='og:title'][content]")
    title = title_meta["content"].strip() if title_meta else None
    main = soup.find("main")
    headings = [
        heading for heading in (main.find_all("h3") if main else [])
        if heading.get_text(" ", strip=True).rstrip(":").lower() in DETAIL_HEADINGS
    ]
    section_parents, seen = [], set()
    for heading in headings:
        parent_key = id(heading.parent)
        if parent_key not in seen:
            seen.add(parent_key)
            section_parents.append(heading.parent)
    description = "\n\n".join(
        section.get_text(" ", strip=True) for section in section_parents
    )

    apply_el = soup.select_one("a#apply-action-button[href]")
    apply_url = urljoin(document_base, apply_el["href"]) if apply_el else None
    apply_parts = urlsplit(apply_url) if apply_url else None
    if not apply_parts or not (
        apply_parts.scheme == "https"
        and apply_parts.netloc == "www.google.com"
        and apply_parts.path == "/about/careers/applications/apply"
        and parse_qs(apply_parts.query).get("jobId", [""])[0]
        and not apply_parts.fragment
    ):
        raise ValueError("detail omitted its first-party apply route")

    location_note = next((bold for bold in (main.find_all("b") if main else [])
                          if "preferred working location from the following"
                          in bold.parent.get_text(" ", strip=True).lower()), None)
    locations = ([part.strip() for part in location_note.get_text().split(";")]
                 if location_note else listing["locations"])

    return {
        "title": title,
        "description": description,
        "apply_url": apply_url,
        "locations": locations,
        "detail_sections": [h.get_text(" ", strip=True) for h in headings],
    }

for job in scrape_all_listings():
    detail = fetch_job_details(job)
    if detail:
        print(job["external_id"], detail["title"])
Common issues
highResolving the relative `./apply?...` anchor against the job-detail URL produces `/jobs/results/apply`, which displays Google's ‘Job not found’ page.

Resolve the Apply anchor through the detail document's exact `<base href="https://www.google.com/about/careers/applications/">`, then require `/about/careers/applications/apply` with a non-empty `jobId` query.

highResolving each relative detail link against the search page URL produces a duplicated /about/careers/applications/jobs/jobs/results/... path that returns HTTP 404.

Read the document's declared <base href> (https://www.google.com/about/careers/applications/) and resolve every detail href through it, then validate the result is a www.google.com jobs/results URL before requesting it.

highOpaque generated CSS classes can change on any Google redeploy and silently yield zero listings or incomplete details.

Anchor on semantic contracts: numeric /jobs/results/{id}- routes, 'Learn more about …' accessible labels, main/list landmarks, role=status totals, Open Graph metadata, and recognized h3 section headings.

mediumDetail URLs still carry the search-filter query string, so storing them as-is creates duplicate records for the same job across filter combinations.

Strip the query string and persist only the canonical /jobs/results/{id}-{slug} path as the job's stable key.

mediumGuessing page numbers or treating an empty continuation page as normal termination can silently truncate a snapshot.

Follow the exact accessible next-page link, require the role=status total to stay consistent on every page, and reject the snapshot unless the final unique-ID count equals that total.

lowDetail fetches for expired roles return HTTP 404 or 410 and can abort a crawl if treated as hard failures.

Catch 404/410 on the detail request and mark the job as removed, letting the rest of the run continue.

Best practices
  1. 1Resolve every relative link through the document's <base href> before requesting it.
  2. 2Send an Accept: text/html header and a realistic browser User-Agent for consistent markup.
  3. 3Throttle to roughly one request every 100ms and cap concurrent detail fetches at about 6.
  4. 4Fail loudly on a missing result total, malformed candidate, inconsistent next link, or unexpected zero-row continuation.
  5. 5Reconcile unique emitted IDs against the advertised role=status total before accepting a snapshot.
  6. 6Store the canonical, query-free detail URL as each job's stable identifier.
  7. 7Treat 404/410 detail responses as removals, not scrape failures.
Or skip the complexity

One endpoint. All Google Careers jobs. No scraping, no sessions, no maintenance.

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

Access Google Careers
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