- highNot every /vacancies/ page is a scrapable Harbour board — corporate marketing sites and unverified custom domains use similar paths.
- 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.
- mediumPagination differs per tenant: some boards page server-side via /vacancies/page/{n}/ while others render the full fleet and paginate client-side.
- 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.
- mediumThe Apply button sometimes points to a downstream external host, and downstream URLs can carry their own IDs.
- 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.
- mediumAn empty board and a blocked or challenged page both render few or no vacancy cards, which can look like the same thing.
- 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.
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.
What's in every response.
Data fields, real-world applications, and the companies already running on Harbour ATS.
Data fields
- Full job descriptions
- Salary ranges & currency
- Contract type & category
- Closing dates
- JobPosting JSON-LD
- Provider-hosted apply URLs
Use cases
- 01UK Vacancy Monitoring
- 02Care & Professional-Services Hiring Data
- 03Salary Benchmarking
- 04Job Board Aggregation
Trusted by
- TLT
- Aria Care Homes
- NTT DATA
DIY GUIDE
How to scrape Harbour ATS.
Step-by-step guide to extracting jobs from Harbour ATS-powered career pages—endpoints, authentication, and working code.
Step 1: Fetch a Harbour board's first page
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.textStep 2: Extract vacancy cards and numeric IDs
import 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 cardsStep 3: Walk pagination (and handle client-side fleets)
NEXT_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_cardsStep 4: Parse each vacancy's JobPosting JSON-LD
import 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"),
}Step 5: Resolve the apply URL safely
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" Common issues
Best practices
- 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.
Or skip the complexity
One endpoint. All Harbour ATS jobs. No scraping, no sessions, no maintenance.
Get API accesscURL
curl "https://connect.jobo.world/api/jobs?sources=harbour ats" \
-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 Harbour ATS
Access Harbour ATS
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