All platforms

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.

Get API access
Harbour ATS
Live
<3haverage discovery time
1hrefresh interval
Companies using Harbour ATS
TLTAria Care HomesNTT DATA
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.

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
  1. 01UK Vacancy Monitoring
  2. 02Care & Professional-Services Hiring Data
  3. 03Salary Benchmarking
  4. 04Job Board Aggregation
Trusted by
TLTAria Care HomesNTT 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.

HTMLintermediateNo published limit; keep to ~3 concurrent requests with a short delayNo auth

Fetch a Harbour board's first page

Harbour serves job boards from provider-owned tenants ({tenant}.amal / .atom / .srs.harbourats.com) or a verified custom host such as apply.tlt.com. Listings always live under /vacancies/, so fetch that page as anonymous HTML — there is no structured jobs API.

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.text

Extract vacancy cards and numeric IDs

Every vacancy links to /vacancies/{id} where {id} is a numeric ID (the trailing slug is display-only). Collect the anchors, keep the numeric ID as the stable identity, and read the visible .value_* fields off each card.

Step 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 cards

Walk pagination (and handle client-side fleets)

TLT and Aria paginate server-side via /vacancies/page/{n}/ Next links; NTT DATA renders its whole fleet into one page and paginates client-side. Follow only same-host page links, and defensively stop once no new IDs appear so client-side boards terminate cleanly.

Step 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_cards

Parse each vacancy's JobPosting JSON-LD

Detail pages at /vacancies/{id} embed a JobPosting JSON-LD block carrying title, description, hiringOrganization, datePosted, validThrough, baseSalary, and jobLocation. Prefer it, and fall back to the rendered .value_* fields when a value is absent.

Step 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"),
    }

Resolve the apply URL safely

The provider-hosted apply route is /vacancies/{id}/apply/ on the same host. Some vacancies instead expose an explicit external Apply link — accept that only when it is a plain HTTPS anchor to another host, and always keep the numeric /vacancies/{id} as identity rather than any downstream query ID.

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
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.

Best practices
  1. 1Validate the host: only *.amal/atom/srs.harbourats.com (single tenant label) or a known custom board such as apply.tlt.com.
  2. 2Key every job on the numeric /vacancies/{id} and treat the slug and any downstream apply ID as display-only.
  3. 3Prefer JobPosting JSON-LD for title, description, salary, and dates; fall back to the rendered .value_* fields.
  4. 4Follow only same-host /vacancies/page/{n}/ Next links and stop when a page adds no new IDs.
  5. 5Reconcile your count against the 'There are N results' total and keep to ~3 concurrent requests with a short delay.
  6. 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 access
cURL
curl "https://connect.jobo.world/api/jobs?sources=harbour ats" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

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.

99.9%API uptime
<200msAvg response
50M+Jobs processed