CGA Recruiting App Jobs API.

CGA Recruiting is a Salesforce managed package that publishes careers pages on Salesforce Sites. Boards render at cga__JobListing and each vacancy at cga__JobDetails with a native job number in the query string.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on CGA Recruiting App.

Data fields

  • Full Job Descriptions
  • Native Job Numbers
  • JobPosting JSON-LD
  • Address Locality & Region
  • Direct Apply Targets
  • Complete Paged Traversal

Use cases

  1. 01Salesforce Careers Page Ingestion
  2. 02Government Contractor Job Feeds
  3. 03Niche ATS Aggregation
  4. 04Careers Page Monitoring

Trusted by

  • Compass Government Solutions
  • Kolekto
DIY GUIDE

How to scrape CGA Recruiting App.

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

API type
HTML
Difficulty
advanced
Rate limit
No published limit; ~300ms between requests, max 2 concurrent detail fetches
Authentication
No auth

Recognise the managed-package route

CGA boards live on Salesforce Sites hosts — {org}.my.site.com or {org}.my.salesforce-sites.com — and the page name is the last path segment: cga__JobListing for the board, cga__JobDetails for a vacancy. Anything before that segment is the site path and varies per org.

Step 1: Recognise the managed-package route
from urllib.parse import urlparse, parse_qs

SUFFIXES = (".my.site.com", ".my.salesforce-sites.com")
PAGES = {"cga__joblisting", "cga__jobdetails"}

def parse_cga(url: str) -> dict | None:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    if parsed.scheme != "https" or not host.endswith(SUFFIXES):
        return None

    path = parsed.path
    slash = path.rfind("/")
    page = path[slash + 1:] if slash >= 0 else path.lstrip("/")
    if page.lower() not in PAGES:
        return None

    site_path = path[:slash] if slash > 0 else ""
    query = {k.lower(): v[0] for k, v in parse_qs(parsed.query).items()}
    return {
        "org": host.split(".")[0],
        "site_path": site_path,
        "board_url": f"https://{host}{site_path}/cga__JobListing",
        "job_number": query.get("jobnumber"),
    }

print(parse_cga("https://kolekto.my.salesforce-sites.com/jobs/cga__JobDetails"
                "?q=&jobNumber=JOB00440"))

Collect job links from the listing page

Confirm the page really is the managed package by looking for its own artifacts, then read every a.searchResultLink whose href points at cga__JobDetails with a jobNumber. That job number is the native identifier and it is a string like JOB00248, not an integer.

Step 2: Collect job links from the listing page
import re
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

ARTIFACTS = re.compile("cga__(?:RecruitingAppAssets|JobListing)", re.IGNORECASE)

def parse_listings(html: str, page_url: str) -> list[dict]:
    if not ARTIFACTS.search(html):
        raise RuntimeError("page did not contain CGA Recruiting artifacts")

    soup = BeautifulSoup(html, "html.parser")
    rows = []
    selector = "a.searchResultLink[href*='cga__JobDetails'][href*='jobNumber=']"
    for anchor in soup.select(selector):
        detail_url = urljoin(page_url, anchor["href"])
        identity = parse_cga(detail_url)
        title = anchor.get_text(strip=True)
        if not identity or not identity["job_number"] or not title:
            continue
        rows.append({
            "id": identity["job_number"],
            "title": title,
            "url": detail_url,
        })
    return rows

session = requests.Session()
board_url = "https://compassgovernmentsolutions.my.site.com/cga__JobListing"
first = session.get(board_url, timeout=30)
first.raise_for_status()
listings = parse_listings(first.text, board_url)

Replay the Visualforce postback to paginate

There is no page query parameter. The Next control is a Visualforce postback whose onclick names a source parameter, and the server only answers if you resubmit the entire form. Collect every named input, add the source pair, and POST the lot as form-encoded data.

Step 3: Replay the Visualforce postback to paginate
SOURCE_PARAM = re.compile(
    "'parameters'[ ]*:[ ]*[{][ ]*'([^']+)'[ ]*:[ ]*'([^']+)'", re.IGNORECASE)

def build_next_request(html: str, page_url: str) -> dict | None:
    soup = BeautifulSoup(html, "html.parser")
    button = soup.select_one("input[title='Next']:not([disabled])")
    if not button or not button.get("onclick"):
        return None

    match = SOURCE_PARAM.search(button["onclick"])
    form = button.find_parent("form")
    if not match or form is None:
        return None

    fields = {}
    for node in form.select("input[name]"):
        if node.get("type") in ("checkbox", "radio") and not node.has_attr("checked"):
            continue
        fields[node["name"]] = node.get("value") or ""
    fields[match.group(1)] = match.group(2)

    action = form.get("action") or page_url
    return {"url": urljoin(page_url, action), "fields": fields}

def scrape_board(session, board_url: str, max_pages: int = 20) -> list[dict]:
    resp = session.get(board_url, timeout=30)
    resp.raise_for_status()
    html, page_url = resp.text, board_url
    jobs, seen = [], set()

    for _ in range(max_pages):
        for row in parse_listings(html, page_url):
            if row["id"] not in seen:
                seen.add(row["id"])
                jobs.append(row)

        nxt = build_next_request(html, page_url)
        if not nxt:
            break
        resp = session.post(nxt["url"], data=nxt["fields"],
                            headers={"Referer": board_url}, timeout=30)
        resp.raise_for_status()
        html, page_url = resp.text, nxt["url"]
    return jobs

Parse the job detail page

Detail pages usually carry JobPosting JSON-LD for the title and address, but the narrative lives in the managed package's rich-text blocks. Join the .htmlDetailElementTable .sfdc_richtext elements for the description, and fall back to the JSON-LD description only if that comes up short.

Step 4: Parse the job detail page
import json

def find_job_posting(html: str) -> dict | None:
    soup = BeautifulSoup(html, "html.parser")
    for tag in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(tag.string or "")
        except (json.JSONDecodeError, TypeError):
            continue
        for node in (data if isinstance(data, list) else [data]):
            if isinstance(node, dict) and node.get("@type") == "JobPosting":
                return node
    return None

APPLY_TARGET = re.compile("window[.]location[ ]*=[ ]*'([^']+)'", re.IGNORECASE)

def fetch_detail(session, listing: dict) -> dict | None:
    resp = session.get(listing["url"], timeout=30)
    if resp.status_code in (404, 410):
        return None
    resp.raise_for_status()

    soup = BeautifulSoup(resp.text, "html.parser")
    posting = find_job_posting(resp.text) or {}
    blocks = [b.decode_contents().strip()
              for b in soup.select(".htmlDetailElementTable .sfdc_richtext")
              if len(b.get_text(strip=True)) >= 30]
    description = "".join(blocks) or posting.get("description")

    address = ((posting.get("jobLocation") or {}).get("address")) or {}
    apply_button = soup.select_one("input.applyButton[onclick]")
    apply_match = APPLY_TARGET.search(apply_button["onclick"]) if apply_button else None

    return {
        "id": listing["id"],
        "title": posting.get("title") or listing["title"],
        "description_html": description,
        "city": address.get("addressLocality"),
        "state": address.get("addressRegion"),
        "country": address.get("addressCountry"),
        "url": listing["url"],
        "apply_url": (urljoin(listing["url"], apply_match.group(1))
                      if apply_match else listing["url"]),
    }
Common issues
criticalPagination returns the first page again
The Next control is a Visualforce postback, so a plain GET or a POST carrying only the source parameter re-renders page one. Resubmit every named input from the surrounding form together with the source name and value taken out of the button's onclick handler.
highThe site path differs from one org to the next
Some orgs serve the package at the host root and others under a prefix such as /jobs. Derive the site path from the segment preceding cga__JobListing rather than hardcoding it, and rebuild detail URLs against the same prefix or every link 404s.
highThe JSON-LD description is empty or missing
The managed package renders its narrative into rich-text blocks and the structured block often carries only a title and address. Join the .sfdc_richtext elements first and use the JSON-LD description only as a fallback, otherwise most jobs land with no body text.
lowJob numbers are strings, not integers
The native identifier is the jobNumber query value, formatted like JOB00248. Parsing it as a number drops the prefix and collides across orgs. Store it verbatim and scope it by the Salesforce org that served it.
Best practices
  1. 1Derive the site path from the segment before cga__JobListing rather than assuming the root
  2. 2Require the cga__ package artifacts before parsing a page as a board
  3. 3Resubmit the full form when replaying the Next postback
  4. 4Prefer the .sfdc_richtext blocks for the description and JSON-LD as a fallback
  5. 5Deduplicate on jobNumber across pages, since postbacks can repeat rows
  6. 6Throttle to ~300ms between requests with at most two concurrent detail fetches
Or skip the complexity

One endpoint. All CGA Recruiting App jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=cga recruiting app" \
  -H "X-Api-Key: YOUR_KEY"
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.

Ready to integrate

Access CGA Recruiting App
job data today.

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

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