Reach ATS Jobs API.
Extract structured vacancies from UK employer and agency careers boards built on the Reach ATS runtime — whether a tenant serves its jobs as a JSON helper feed or paginated server-rendered HTML.
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 Reach ATS.
- Job Title & Vacancy ID
- Salary Descriptions
- Employment Type
- Job Location
- Closing Dates
- Full Job Descriptions
- 01UK Vacancy Aggregation
- 02Recruitment Market Research
- 03Careers Board Mirroring
- 04Job Board Syndication
How to scrape Reach ATS.
Step-by-step guide to extracting jobs from Reach ATS-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
def origin_of(board_url):
parts = urlparse(board_url)
return f"{parts.scheme}://{parts.netloc}"
def pick_adapter(board_url):
host = urlparse(board_url).netloc.lower()
if host == "careers.lcp.com":
return fetch_surface_html # anti-forgery POST tenant
if host == "jobs.sense.org.uk":
return fetch_snippet_html # asp.html fragment tenant
return fetch_json_helper # JSON helper tenant (e.g. Bellway)import requests
def fetch_json_helper(board_url):
origin = origin_of(board_url)
resp = requests.get(
f"{origin}/?action=helper/a-t-s/get-vacancies",
headers={"Accept": "application/json"},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if not data.get("success") or not isinstance(data.get("vacancies"), list):
raise RuntimeError("Reach ATS omitted its successful vacancies array")
jobs = []
for row in data["vacancies"]:
vid = str(row.get("id", "")).strip()
if not vid.isdigit():
continue # rows without a numeric native id are rejected
jobs.append({
"external_id": vid,
"title": row.get("title"),
"location": row.get("location"),
"employment_type": row.get("type"),
"salary": row.get("salaryDescription"),
"closing_date": row.get("closingDate"),
"listing_url": f"{origin}/vacancies/{vid}",
"apply_url": row.get("applyUrl") or f"{origin}/vacancies/{vid}",
})
return jobsimport json
from bs4 import BeautifulSoup
def fetch_surface_html(board_url, page=1):
origin = origin_of(board_url)
session = requests.Session()
# The anti-forgery cookie and token are issued together by the board page.
boot = session.get(board_url, timeout=30)
boot.raise_for_status()
field = BeautifulSoup(boot.text, "html.parser").select_one(
"input[name='__RequestVerificationToken']")
if not field or not field.get("value"):
raise RuntimeError("LCP board omitted its request verification token")
resp = session.post( # session jar re-sends the anti-forgery cookie
f"{origin}/umbraco/surface/jobvacancies/GetJobVacancies",
headers={
"Content-Type": "application/json; charset=utf-8",
"RequestVerificationToken": field["value"],
"Origin": origin,
"Referer": board_url,
},
data=json.dumps({"query": "", "categories": "", "specialism": "",
"location": "", "page": page}),
timeout=30,
)
resp.raise_for_status() # HTTP 429 -> back off and retry
fragment = BeautifulSoup(resp.text, "html.parser")
jobs = []
for card in fragment.select(".job-vacancies__job-container"):
anchor = card.select_one("a.job-vacancies__job-more-button[href*='id=']")
title = card.select_one(".job-vacancies__job-title")
if not anchor or not title:
continue
jobs.append({
"detail_href": anchor.get("href"),
"title": title.get_text(strip=True),
})
return jobsimport re
def fetch_snippet_html(board_url, page=1):
origin = origin_of(board_url)
resp = requests.post(
f"{origin}/asp.html?snippet=jobListingREACH",
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": origin,
"Referer": board_url,
},
data=(f"descLen=400&showResults=1&showSearch=0&page={page}"
"&keywords=®ion=&role=&fields="),
timeout=30,
)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
jobs = []
for card in soup.select(".edge-tertiary"):
anchor = card.select_one("a[href*='jobid=']")
title = card.select_one("h2")
if not anchor or not title:
continue
job_id = re.search(r"jobid=(\d+)", anchor["href"], re.I)
jobs.append({
"external_id": job_id.group(1) if job_id else None,
"title": title.get_text(strip=True),
"detail_href": anchor["href"], # resolve against origin, not /find-a-job/
})
# Stop paging once the reported total is covered (10 results per page).
total = re.search(r"showing\s+\d+\s*-\s*\d+\s+of\s+(\d+)",
soup.get_text(), re.I)
total_pages = -(-int(total.group(1)) // 10) if total else None
return jobs, total_pagesdef fetch_details(listing_url):
resp = requests.get(listing_url, timeout=30)
if resp.status_code in (404, 410):
return None # vacancy was removed
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
title = soup.select_one("h1, [class*='vacancy-title'], [class*='job-title']")
body = soup.select_one(
"[class*='vacancy-description'], [class*='job-description'], article, main")
if not body:
raise RuntimeError("Reach ATS detail page omitted its description")
apply = soup.select_one("a[href*='apply']")
return {
"title": title.get_text(strip=True) if title else None,
"description_html": body.decode_contents().strip(),
"apply_url": apply.get("href") if apply else listing_url,
}Route by host and keep a per-tenant adapter: a JSON helper feed (get-vacancies), an anti-forgery Umbraco surface POST (GetJobVacancies), and an asp.html fragment (jobListingREACH). Do not assume one contract across boards.
Bootstrap the board page first with a requests.Session so the anti-forgery cookie is stored, scrape __RequestVerificationToken from the hidden input, and send it in the RequestVerificationToken header on the POST.
Reach ATS deployments live on each employer's own domain (for example bellwaycareers.co.uk or careers.lcp.com), not a shared reach-ats.com host. Detect the ATS by its runtime signal — a reach-ats.com asset or a /helper/a-t-s/get-vacancies request — rather than the visible domain.
Snippet fragments are authored against <base href="/">, so resolve their root-relative hrefs against the scheme+host origin, not against the listing route (such as /find-a-job/) that requested the fragment.
Require a digit-only native id: drop JSON rows whose id is not numeric and HTML cards whose detail anchor lacks an id= or jobid= param, then mark the snapshot incomplete when rows are rejected.
- 1Route by host and keep separate adapters for JSON-helper, Umbraco-surface, and asp.html snippet tenants.
- 2Reuse one requests.Session per board so the anti-forgery cookie persists across the bootstrap and POST.
- 3Resolve fragment links against the scheme+host origin, since snippets are authored against <base href="/">.
- 4Terminate pagination on the reported 'showing X - Y of N' / 'page X of Y' total rather than scanning for empty pages.
- 5Treat 404/410 on a vacancy page as a removal, not a hard failure.
- 6Self-throttle to ~2 concurrent detail fetches roughly 300ms apart and back off on HTTP 429.
One endpoint. All Reach ATS jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=reach ats" \
-H "X-Api-Key: YOUR_KEY" Access Reach ATS
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.