Jobaline / Jobalign Jobs API.

Read hourly hiring from Jobaline (Jobalign) employer boards, where each tenant subdomain is a search surface and every posting resolves to a canonical apply page carrying the full description.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on Jobaline / Jobalign.

Data fields

  • Full Job Descriptions
  • Brand and Employer Names
  • City, State and Postal Code
  • Native Numeric Job IDs
  • JobPosting JSON-LD On Many Tenants
  • Multi-Location Employer Boards

Use cases

  1. 01Hourly & Shift Work Aggregation
  2. 02Restaurant and Retail Job Boards
  3. 03Multi-Site Employer Monitoring
  4. 04Local Labour Market Research

Trusted by

  • Arctic Circle
  • Anna's House
  • Revlon
DIY GUIDE

How to scrape Jobaline / Jobalign.

Step-by-step guide to extracting jobs from Jobaline / Jobalign-powered career pages—endpoints, authentication, and working code.

API type
HTML
Difficulty
intermediate
Rate limit
No published limit; ~200ms between requests and at most 3 concurrent detail fetches
Authentication
No auth

Take the tenant from the subdomain

Each Jobaline employer has its own {tenant}.jobaline.com board, and the canonical board URL is that bare host. Detail links are /ApplyForJob with a numeric jobid, optionally alongside the s and p tracking parameters. The shared jobs.jobaline.com host is not a tenant, so exclude it along with www.

Step 1: Take the tenant from the subdomain
from urllib.parse import urlparse, parse_qs

SUFFIX = ".jobaline.com"
GLOBAL_HOST = "jobs.jobaline.com"

def parse_url(url: str) -> tuple[str, str | None] | None:
    parsed = urlparse(url)
    host = parsed.netloc.lower()
    if not host.endswith(SUFFIX):
        return None
    tenant = host[: -len(SUFFIX)]
    if tenant in {"jobs", "www"} or not tenant:
        return None

    path = parsed.path.rstrip("/")
    if not path:
        return (tenant, None)          # the board itself
    if path.lower() != "/applyforjob":
        return None
    job_id = (parse_qs(parsed.query).get("jobid") or [""])[0]
    return (tenant, job_id) if job_id.isdigit() else None

def board_url(tenant: str) -> str:
    return f"https://{tenant}{SUFFIX}"

print(parse_url("https://acr.jobaline.com/ApplyForJob?jobid=556078&s=12"))  # ('acr', '556078')

Page the tenant search surface

A tenant board is a search page rather than a plain list, so ask for a radius wide enough to return everything the employer has posted. Results come back ten at a time in div.searchItem elements, and the page body carries a HasMoreResults flag that tells you whether another page exists.

Step 2: Page the tenant search surface
import re
import time
import requests
from bs4 import BeautifulSoup

HAS_MORE = re.compile(r'"HasMoreResults"\s*:\s*true', re.IGNORECASE)
MAX_PAGES = 500

def fetch_page(session: requests.Session, tenant: str, page: int) -> str:
    # A very wide radius makes the tenant's search surface behave as a full board.
    url = f"{board_url(tenant)}/?loc=Kansas%20City%2C%20MO&range=3000&start={page}"
    response = session.get(
        url, headers={"Accept": "text/html,application/xhtml+xml"}, timeout=30
    )
    response.raise_for_status()
    if "Jobalign" not in response.text:
        raise RuntimeError("Jobaline board omitted its first-party tenant proof")
    return response.text

def crawl(session: requests.Session, tenant: str) -> list[dict]:
    listings, seen, page = [], set(), 1
    while page <= MAX_PAGES:
        body = fetch_page(session, tenant, page)
        for row in parse_rows(BeautifulSoup(body, "html.parser"), tenant):
            if row["id"] not in seen:
                seen.add(row["id"])
                listings.append(row)
        if not HAS_MORE.search(body):
            return listings        # the provider says this was the last page
        page += 1
        time.sleep(0.2)
    raise RuntimeError("Jobaline pagination did not terminate within the page ceiling")

Map each search row

Every row carries its native job id twice: in the data-id attribute and in the a.search_job_title link. Require the two to agree and the link to stay on this tenant before accepting the row — a row whose anchor points at a different subdomain belongs to another employer.

Step 3: Map each search row
from urllib.parse import urljoin

def text_of(row, selector: str) -> str | None:
    node = row.select_one(selector)
    return " ".join(node.get_text().split()) if node else None

def parse_rows(soup: BeautifulSoup, tenant: str) -> list[dict]:
    rows = []
    for row in soup.select("div.searchItem[data-id]"):
        anchor = row.select_one("a.search_job_title[href]")
        if not anchor:
            continue
        parsed = parse_url(urljoin(board_url(tenant), anchor["href"]))
        data_id = (row.get("data-id") or "").strip()
        title = " ".join(anchor.get_text().split())
        # The anchor's tenant and job id must both match the row itself.
        if not parsed or parsed[0] != tenant or parsed[1] != data_id or not title:
            continue

        city = text_of(row, "#DesktopBrand [data-ts='city']")
        state = text_of(row, "#DesktopBrand [data-ts='state']")
        rows.append({
            "id": data_id,
            "title": title,
            "company": text_of(row, "#DesktopBrand [data-ts='brandname']"),
            "location": ", ".join(p for p in [city and city.strip(" ,"), state] if p) or None,
            "listing_url": f"{board_url(tenant)}/ApplyForJob?jobid={data_id}",
        })
    return rows

session = requests.Session()
listings = crawl(session, "acr")
print(f"{len(listings)} vacancies")

Hydrate from the canonical apply page

Tenant pages are the apply surface but omit the structured record; Jobaline declares the matching jobs.jobaline.com/ApplyForJob URL as canonical and publishes JobPosting JSON-LD there. Verify the page's og:url resolves back to the same tenant and job before mapping, and fall back to the labelled HTML fields on tenants that publish no JSON-LD.

Step 4: Hydrate from the canonical apply page
import json

def canonical_detail_url(job_id: str) -> str:
    return f"https://{GLOBAL_HOST}/ApplyForJob?jobid={job_id}&s=34"

def find_job_posting(soup) -> dict | None:
    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 None

def fetch_detail(session: requests.Session, listing: dict, tenant: str) -> dict | None:
    response = session.get(canonical_detail_url(listing["id"]), timeout=30)
    if response.status_code in (404, 410):
        return None  # canonical removal
    response.raise_for_status()
    soup = BeautifulSoup(response.text, "html.parser")

    # The page must be a real detail render AND name this tenant and job.
    required = (".job-title", ".customer-name", ".job-description-container")
    og_url = soup.select_one("meta[property='og:url']")
    proved = parse_url((og_url.get("content") if og_url else "") or "")
    if any(soup.select_one(s) is None for s in required) or proved != (tenant, listing["id"]):
        raise RuntimeError("Jobaline detail page contradicted its native tenant proof")

    posting = find_job_posting(soup)
    if posting:
        return {
            **listing,
            "title": posting.get("title") or listing["title"],
            "description_html": posting.get("description"),
            "posted_at": posting.get("datePosted"),
            "employment_type": posting.get("employmentType"),
            "company": (posting.get("hiringOrganization") or {}).get("name") or listing["company"],
        }

    # Tenants without JSON-LD still expose the same fields as labelled HTML.
    body = soup.select_one(".job-description-container")
    return {
        **listing,
        "title": " ".join(soup.select_one(".job-title").get_text().split()),
        "company": " ".join(soup.select_one(".customer-name").get_text().split()),
        "description_html": body.decode_contents() if body else None,
        "detail_format": "jobaline_html",
    }

for listing in listings[:3]:
    job = fetch_detail(session, listing, "acr")
    if job:
        print(job["title"], "-", job["company"])
Common issues
highA shared jobs.jobaline.com link names no employer
The global jobs.jobaline.com/ApplyForJob shape carries a job id but no tenant. Fetch the page and read the tenant out of its og:url meta tag, which is where Jobaline declares the owning employer board; treat a page with no og:url as unattributable rather than assigning it a guess.
highThe tenant board returns only the first ten jobs
Tenant boards are search surfaces, not lists. Request a wide radius and walk the start parameter, driving the loop from the HasMoreResults flag in the response body rather than from the number of rows returned, and stop at a page ceiling so a misbehaving board cannot spin forever.
mediumDetail pages have no JSON-LD on some tenants
Only part of the estate publishes JobPosting JSON-LD. Try the structured block first and fall back to the labelled HTML fields .job-title, .customer-name and .job-description-container, which are consistent across the tenants that omit it.
mediumA search row is attributed to the wrong employer
Require the row's data-id, the a.search_job_title link's jobid, and the link's own subdomain to agree with the board being crawled. A row that fails any of those checks belongs to a different tenant and should be dropped rather than merged into this employer.
Best practices
  1. 1Treat the bare {tenant}.jobaline.com host as the canonical board and exclude jobs and www
  2. 2Search with a wide radius so the tenant surface behaves as a complete board
  3. 3Drive pagination from the HasMoreResults flag and cap the page count defensively
  4. 4Cross-check data-id against the anchor's jobid before accepting a row
  5. 5Hydrate from the canonical jobs.jobaline.com apply page and verify og:url names the same tenant
  6. 6Fall back to the labelled HTML fields when a tenant publishes no JobPosting JSON-LD
Or skip the complexity

One endpoint. All Jobaline / Jobalign jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=jobaline / jobalign" \
  -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 Jobaline / Jobalign
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