Engage ATS Jobs API.
Pull public-sector and higher-education vacancies from white-label council and university boards on engageats.co.uk, complete with directorate, closing dates, and tenant-owned apply links.
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 Engage ATS.
- Full Job Descriptions
- Posted & Closing Dates
- Directorate & Service Fields
- Job Reference & Type
- Tenant-Owned Apply URLs
- Structured Location Data
- 01Public Sector Job Aggregation
- 02Council Vacancy Monitoring
- 03Higher Education Recruitment Feeds
- 04Closing Date Alerts
How to scrape Engage ATS.
Step-by-step guide to extracting jobs from Engage ATS-powered career pages—endpoints, authentication, and working code.
import requests
def fetch_page(origin: str, page: int = 1) -> str:
body = (
"searchControlViewModel.Criteria="
"&searchControlViewModel.PostCode="
"&searchControlViewModel.TravelDistance="
"&searchControlViewModel.SortBy="
"&searchControlViewModel.Type=External"
f"&searchControlViewModel.PageNo={page}"
)
resp = requests.post(
f"{origin}/V2/Vacancy/ApplySearchFilter",
data=body,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"Referer": f"{origin}/V2/Vacancy",
},
timeout=30,
)
resp.raise_for_status()
# The vacancy markup is a string nested inside the JSON response.
return resp.json()["searchResults"]
origin = "https://barnsley.engageats.co.uk"
fragment = fetch_page(origin)import re
from urllib.parse import urljoin
from bs4 import BeautifulSoup
# /Vacancies/{channel}/{organisationId}/{sequence}/{id}/{locationId}/{slug}
CARD_URL = re.compile(
r"/Vacanc(?:y|ies)/([^/?#]+)/(\d+)/(\d+)/(\d+)/(\d+)/[^/?#]+",
re.IGNORECASE,
)
def parse_cards(fragment: str, origin: str) -> list[dict]:
soup = BeautifulSoup(fragment, "html.parser")
jobs = []
for button in soup.select("button.btn-search-results-view[data-param1]"):
url = urljoin(origin, (button.get("data-param1") or "").strip())
match = CARD_URL.search(url)
if not match:
continue
channel, org_id, _seq, vacancy_id, location_id = match.groups()
card = button.find_parent(class_=["ats-gray-panel", "ats-white-panel"])
title_el = card.select_one(".ats-heading-font") if card else None
jobs.append({
"external_id": vacancy_id,
"listing_url": url,
"title": title_el.get_text(strip=True) if title_el else None,
"channel_id": channel,
"organisation_id": org_id,
"location_id": location_id,
})
return jobsPAGE_COUNT = re.compile(r"Page\s+(\d+)\s+of\s+(\d+)", re.IGNORECASE)
def total_pages(fragment: str, current_page: int) -> int:
text = BeautifulSoup(fragment, "html.parser").get_text(" ")
match = PAGE_COUNT.search(text)
return int(match.group(2)) if match else current_page
def scrape_all(origin: str) -> list[dict]:
page, results = 1, []
while True:
fragment = fetch_page(origin, page)
cards = parse_cards(fragment, origin)
if not cards:
break
results.extend(cards)
if page >= total_pages(fragment, page):
break
page += 1
return resultsdef fetch_detail(listing: dict) -> dict | None:
html = requests.get(listing["listing_url"], timeout=30).text
soup = BeautifulSoup(html, "html.parser")
root = soup.select_one(".view-vacancy-container")
save_btn = soup.select_one("#btn-save-vacancy[data-vacancyid]")
native_id = save_btn.get("data-vacancyid") if save_btn else None
# Guard against a mismatched or stale detail page.
if root is None or native_id != listing["external_id"]:
return None
title_el = root.select_one("[role='heading'][aria-level='1'].ats-title-font")
desc_el = root.select_one("div.order-md-1 > div.row > div.ats-normal-font")
apply_btn = root.select_one("button.apply-application-btn[data-vacancyapplyurl]")
apply_url = (
urljoin(listing["listing_url"], apply_btn["data-vacancyapplyurl"])
if apply_btn else None
)
return {
**listing,
"title": title_el.get_text(strip=True) if title_el else listing["title"],
"description": desc_el.get_text(" ", strip=True) if desc_el else None,
"apply_url": apply_url,
}The /V2/Vacancy/ApplySearchFilter endpoint returns a JSON envelope. Read the searchResults property first, then feed that string into your HTML parser.
The path is /Vacancies/{channel}/{org}/{sequence}/{id}/{location}/{slug}; the id is the fourth path segment, not the first number. Extract it with the six-part regex and confirm it against #btn-save-vacancy[data-vacancyid] on the detail page.
Always send searchControlViewModel.Type=External in the form body so the board returns only externally advertised vacancies.
On page 1 with no cards, look for an explicit 'no vacancies' message before declaring the board empty; otherwise treat it as a parse error so real breakage surfaces.
Engage renders en-GB dates (DD/MM/YYYY). Parse with a UK locale and treat closing dates as end-of-day.
- 1Send searchControlViewModel.Type=External so you only collect publicly linkable vacancies.
- 2Re-parse the searchResults string as HTML — the outer response is a JSON envelope.
- 3Validate each detail page's data-vacancyid against the id from the card URL before trusting it.
- 4Paginate off the 'Page N of M' marker and stop once the current page reaches the reported total.
- 5Throttle requests with a short delay and modest concurrency, and treat HTTP 429 as a retryable rate-limit signal.
- 6Parse dates as en-GB and keep closing dates as end-of-day.
One endpoint. All Engage ATS jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=engage ats" \
-H "X-Api-Key: YOUR_KEY" Access Engage 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.