Harbour ATS Jobs API.
Pull UK vacancies from Harbour's branded career boards, where each posting ships JobPosting JSON-LD alongside rendered salary, contract, and closing-date fields.
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 Harbour ATS.
- Full job descriptions
- Salary ranges & currency
- Contract type & category
- Closing dates
- JobPosting JSON-LD
- Provider-hosted apply URLs
- 01UK Vacancy Monitoring
- 02Care & Professional-Services Hiring Data
- 03Salary Benchmarking
- 04Job Board Aggregation
How to scrape Harbour ATS.
Step-by-step guide to extracting jobs from Harbour ATS-powered career pages—endpoints, authentication, and working code.
import requests
# Provider-owned tenant: https://{tenant}.amal.harbourats.com/vacancies/ (also .atom / .srs)
# Verified custom host: https://apply.tlt.com/vacancies/
base_url = "https://apply.tlt.com/vacancies/"
resp = requests.get(base_url, timeout=15, headers={"User-Agent": "jobo-bot/1.0"})
resp.raise_for_status()
html = resp.textimport re
from urllib.parse import urljoin, urlparse
from bs4 import BeautifulSoup
JOB_PATH = re.compile(r"^/vacancies/(\d+)(?:/[^/?#]+)?/?$")
def text(node):
return re.sub(r"\s+", " ", node.get_text()).strip() if node else None
def parse_cards(html, page_url):
soup = BeautifulSoup(html, "html.parser")
host = urlparse(page_url).netloc
cards = {}
for a in soup.select("a[href*='/vacancies/']"):
detail = urljoin(page_url, a.get("href", "")).split("?")[0].split("#")[0]
u = urlparse(detail)
m = JOB_PATH.match(u.path)
if not m or u.netloc != host:
continue
job_id = m.group(1)
card = a.find_parent(class_="bio") or a.find_parent(class_="vacancy-details") or a
cards.setdefault(job_id, {
"id": job_id,
"url": detail.rstrip("/"),
"title": text(card.select_one(".vacancy_title, .vacancy-title, strong")) or text(a),
"location": text(card.select_one(".value_location")),
"category": text(card.select_one(".value_job_category")),
"contract_type": text(card.select_one(".value_contract_type")),
"salary_text": text(card.select_one(".value_salary")),
"closing_date": text(card.select_one(".value_closing_date")),
})
return cardsNEXT_PAGE = re.compile(r"^/vacancies/page/\d+/?$", re.IGNORECASE)
def next_page_url(html, page_url):
soup = BeautifulSoup(html, "html.parser")
host = urlparse(page_url).netloc
for a in soup.select("a[href]"):
label = (a.get_text() or "").strip()
classes = " ".join(a.get("class") or []).lower()
is_next = "next" in (a.get("rel") or []) or "next" in classes or label.lower().startswith("next")
href = a.get("href", "")
if is_next and re.search(r"/vacancies/page/\d+", href, re.IGNORECASE):
nxt = urljoin(page_url, href).split("#")[0]
u = urlparse(nxt)
if u.netloc == host and NEXT_PAGE.match(u.path):
return nxt
return None
def crawl_board(base_url):
all_cards, url = {}, base_url
while url:
html = requests.get(url, timeout=15).text
page = parse_cards(html, url)
if not set(page) - set(all_cards): # no new IDs -> fleet exhausted
break
all_cards.update(page)
url = next_page_url(html, url)
return all_cardsimport json
def job_posting(soup):
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 {}
def fetch_detail(card):
html = requests.get(card["url"], timeout=15).text
soup = BeautifulSoup(html, "html.parser")
jp = job_posting(soup)
salary = (jp.get("baseSalary") or {}).get("value") or {}
return {
"id": card["id"],
"url": card["url"],
"title": jp.get("title") or card["title"],
"company": (jp.get("hiringOrganization") or {}).get("name"),
"description": jp.get("description") or text(soup.select_one(
".value_job_advert_description, .vacancy_description, [itemprop='description']")),
"posted_at": jp.get("datePosted"),
"closes_at": card["closing_date"] or jp.get("validThrough"),
"employment_type": jp.get("employmentType") or card["contract_type"],
"salary_min": salary.get("minValue"),
"salary_max": salary.get("maxValue"),
"salary_currency": (jp.get("baseSalary") or {}).get("currency"),
}def apply_url(soup, card):
u = urlparse(card["url"])
same_host_default = f"{u.scheme}://{u.netloc}/vacancies/{card['id']}/apply/"
for a in soup.select("a[href]"):
label = (a.get_text() or "").strip().lower()
if label not in ("apply", "apply now", "apply for this job"):
continue
href = urljoin(card["url"], a.get("href", ""))
target = urlparse(href)
# provider-hosted apply route on the same host
if target.netloc == u.netloc and re.match(rf"^/vacancies/{card['id']}/apply/?$", target.path):
return href, "provider_hosted"
# explicit downstream apply on another HTTPS host
if target.scheme == "https" and target.netloc != u.netloc:
return href, "downstream"
return same_host_default, "provider_hosted"Restrict to the three provider suffixes (.amal / .atom / .srs.harbourats.com, each with a single tenant label) plus known verified custom hosts like apply.tlt.com. Reject corporate domains such as www.tlt.com and arbitrary careers.example.com/vacancies/ pages.
Follow only same-host /vacancies/page/{n}/ Next links, and stop once a fetched page yields no new numeric IDs so single-page client-side fleets (e.g. NTT DATA) terminate. Cross-check against the 'There are N results that match your search' total when it is present.
Prefer the provider-hosted /vacancies/{id}/apply/ route; accept an external link only when it is an explicit HTTPS Apply anchor. Always key the job on the numeric /vacancies/{id} — never the downstream query ID.
Only treat a board as empty when the page text explicitly says so (e.g. 'no current vacancies', 'no vacancies found', '0 vacancies'). Otherwise retry rather than recording zero jobs.
- 1Validate the host: only *.amal/atom/srs.harbourats.com (single tenant label) or a known custom board such as apply.tlt.com.
- 2Key every job on the numeric /vacancies/{id} and treat the slug and any downstream apply ID as display-only.
- 3Prefer JobPosting JSON-LD for title, description, salary, and dates; fall back to the rendered .value_* fields.
- 4Follow only same-host /vacancies/page/{n}/ Next links and stop when a page adds no new IDs.
- 5Reconcile your count against the 'There are N results' total and keep to ~3 concurrent requests with a short delay.
- 6Only record an empty board when the page text explicitly states there are no vacancies.
One endpoint. All Harbour ATS jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=harbour ats" \
-H "X-Api-Key: YOUR_KEY" Access Harbour 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.