Avature Jobs API.
Pull structured vacancies from the hosted career sites that large global enterprises run on their own *.avature.net subdomains — full descriptions, locations, and posting dates included.
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 Avature.
- Full Job Descriptions
- Structured Job Locations
- Posting & Closing Dates
- Employment Type
- Hiring Organization Name
- Native Apply Links
- 01Enterprise Hiring Intelligence
- 02Global Talent Market Tracking
- 03Career-Site Job Aggregation
- 04Competitor Vacancy Monitoring
How to scrape Avature.
Step-by-step guide to extracting jobs from Avature-powered career pages—endpoints, authentication, and working code.
import requests
tenant = "maximus" # left-most label of {tenant}.avature.net
portal = "careers" # path segment(s) before /SearchJobs/, kept per tenant
base_url = f"https://{tenant}.avature.net/{portal}"
search_url = f"{base_url}/SearchJobs/"
headers = {"User-Agent": "Mozilla/5.0 (compatible; JobBot/1.0)"}
html = requests.get(search_url, headers=headers, timeout=30).textimport re
from bs4 import BeautifulSoup
DETAIL_RE = re.compile(r"/(FolderDetail|JobDetail)/[^/?#]+/(\d+)(?:[/?#]|$)", re.I)
def parse_listings(page_html, page_url):
soup = BeautifulSoup(page_html, "html.parser")
jobs, order = {}, []
for a in soup.select("a[href]"):
absolute = requests.compat.urljoin(page_url, a.get("href", ""))
m = DETAIL_RE.search(absolute)
if not m:
continue
job_id = m.group(2)
title = (a.get("title") or a.get_text()).strip()
if job_id in jobs:
if not jobs[job_id]["title"] and title: # image + text anchor pair
jobs[job_id]["title"] = title
continue
order.append(job_id)
jobs[job_id] = {"id": job_id, "title": title, "url": absolute.split("?")[0]}
return [jobs[i] for i in order]
def results_total(page_html):
m = re.search(r"(\d[\d,]*)\s+results?\b", page_html, re.I)
return int(m.group(1).replace(",", "")) if m else None
mode = "folder" if "/FolderDetail/" in html else "job"
offset_param = "folderOffset" if mode == "folder" else "jobOffset"first = parse_listings(html, search_url)
page_size = len(first) or 6
total = results_total(html)
seen = {j["id"] for j in first}
all_jobs = list(first)
offset, MAX_PAGES = page_size, 500
for _ in range(1, MAX_PAGES):
if total is not None and len(seen) >= total:
break
page_url = f"{search_url}?{offset_param}={offset}"
page_html = requests.get(page_url, headers=headers, timeout=30).text
added = 0
for job in parse_listings(page_html, page_url):
if job["id"] not in seen:
seen.add(job["id"])
all_jobs.append(job)
added += 1
if added == 0: # no new IDs -> pagination exhausted
break
offset += page_size
print(f"Collected {len(all_jobs)} of {total or len(all_jobs)} jobs")import json, html as html_lib
def fetch_detail(url):
page = requests.get(url, headers=headers, timeout=30).text
soup = BeautifulSoup(page, "html.parser")
posting = None
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":
posting = node
break
if posting:
break
job = {"url": url}
if posting:
job["title"] = posting.get("title")
desc = posting.get("description")
if desc:
job["description"] = BeautifulSoup(
html_lib.unescape(desc), "html.parser").get_text(" ", strip=True)
job["posted_at"] = posting.get("datePosted")
job["closes_at"] = posting.get("validThrough")
job["employment_type"] = posting.get("employmentType")
org = posting.get("hiringOrganization")
job["company"] = org.get("name") if isinstance(org, dict) else None
return job, soupdef enrich_from_html(soup, job):
fields = {}
for field in soup.select(".article__content__view__field"):
label = field.select_one(".article__content__view__field__label")
value = field.select_one(".article__content__view__field__value")
if label and value:
fields.setdefault(label.get_text(strip=True), value.get_text(" ", strip=True))
job["company"] = job.get("company") or fields.get("Company")
job["location"] = (fields.get("Primary Location")
or fields.get("Job Location") or fields.get("Location"))
job["employment_type"] = (job.get("employment_type")
or fields.get("Employment Type") or fields.get("Contract Type"))
if not (job.get("description") and len(job["description"]) >= 80):
for article in soup.select(".article.article--details"):
head = (article.select_one(".article__header__text__title") or article)
heading = re.sub(r"[^a-z0-9]", "", head.get_text().lower())
if "description" in heading or heading in {"abouttherole", "therole", "roleoverview"}:
body = article.select_one(".article__content, .article__body")
text = body.get_text(" ", strip=True) if body else ""
if len(text) >= 80:
job["description"] = text
break
apply = soup.select_one("a[href*='/ApplicationMethods'], a[href*='/RegisterMethod']")
if apply:
job["apply_url"] = requests.compat.urljoin(job["url"], apply.get("href"))
return jobAvature runs two portal modes. Detect '/FolderDetail/' in the SearchJobs HTML and paginate with folderOffset; otherwise use jobOffset. Advance the offset by the first page's job count, not by a guessed page size — the *RecordsPerPage override is ignored.
Fall back to the labelled .article__content__view__field blocks and the description article whose heading contains 'description' / 'about the role'. When JSON-LD is present, HTML-unescape the description before stripping tags. Reject pages whose description is under ~80 characters as likely blocked or thin.
Match only canonical /FolderDetail/{slug}/{id} and /JobDetail/{slug}/{id} paths. Skip Login, RegisterInterest, ApplicationMethods, RegisterMethod, portal and asset (ASSET/assetFile) routes — they are candidate/apply/static pages, not vacancies. Ignore social-share links that merely embed a detail URL inside a query string.
Some tenants publish detail pages on a branded mirror (for example emea.workmyway.com) rather than the .avature.net host. Preserve the public host exactly and only drop the volatile ?qtvc= token when normalizing the detail URL.
Treat 401 as auth-required and 403/429 as a soft block: throttle to a few concurrent detail fetches with ~500ms between requests and back off on 429. A 404/410 on a detail page means the vacancy was removed.
- 1Keep the tenant subdomain and the full portal/locale path together — one account can host several independent job universes.
- 2Detect the portal mode from the link verb: /FolderDetail/ paginates with folderOffset, /JobDetail/ with jobOffset.
- 3Advance the offset by the first page's job count and stop when the deduplicated count meets the advertised 'N results' total.
- 4Prefer the JobPosting JSON-LD block, then fall back to labelled .article__content fields for description, location, and employment type.
- 5Exclude Login, RegisterInterest, ApplicationMethods, RegisterMethod, and asset paths from listing discovery.
- 6Throttle to ~3 concurrent detail requests with ~500ms spacing to stay under the block threshold.
One endpoint. All Avature jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=avature" \
-H "X-Api-Key: YOUR_KEY" Access Avature
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.