All platforms

ApplicantStack Jobs API.

Pull structured openings from the SwipeClock/WorkforceHub small-business recruiting and onboarding platform, where each employer runs its own tenant subdomain and every posting embeds JSON-LD inside server-rendered HTML.

Get API access
ApplicantStack
Live
<3haverage discovery time
1hrefresh interval
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 ApplicantStack.

Data fields
  • Full Job Descriptions
  • Structured Location Data
  • Employment Type & Department
  • Salary Text
  • Posted & Closing Dates
  • Apply URLs
Use cases
  1. 01SMB Job Aggregation
  2. 02Multi-Tenant Job Discovery
  3. 03Recruiting Data Pipelines
  4. 04Local Hiring Monitoring
DIY GUIDE

How to scrape ApplicantStack.

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

HTMLintermediateNo published limit; the reference scraper uses ~400ms between requests and caps detail fetches at 3 concurrentNo auth

Resolve the tenant subdomain and openings URL

Every ApplicantStack employer is a subdomain of applicantstack.com. Extract the tenant from any listing or detail URL and normalize its case, then build the single openings page that lists all jobs.

Step 1: Resolve the tenant subdomain and openings URL
import requests
from urllib.parse import urlparse

def openings_url(url: str) -> str:
    host = (urlparse(url).hostname or "").lower()
    if not host.endswith(".applicantstack.com"):
        raise ValueError("Not an ApplicantStack URL")
    # Lowercase the tenant so external_id-based dedup is stable (e.g. TBHA -> tbha)
    tenant = host.split(".", 1)[0]
    return f"https://{tenant}.applicantstack.com/x/openings"

print(openings_url("https://TBHA.applicantstack.com/x/detail/a20hmwddcjf0"))
# https://tbha.applicantstack.com/x/openings

Parse the openings HTML table

The openings page is server-rendered HTML with a single non-paginated results table. Read the rows of table#data-table, keep only anchors that point at real detail pages, and pull the title and location off each row.

Step 2: Parse the openings HTML table
import re
import requests
from urllib.parse import urljoin, urlparse
from bs4 import BeautifulSoup

# Detail shortcodes are lowercase alnum (~10-14 chars); /x/apply and /x/myaccount are filtered out
DETAIL_RE = re.compile(r"^/x/detail/([a-z0-9]+)$", re.IGNORECASE)

def scrape_listings(openings: str) -> list[dict]:
    html = requests.get(openings, timeout=15).text
    soup = BeautifulSoup(html, "html.parser")

    table = soup.select_one("table#data-table tbody") or soup.select_one("table#data-table")
    if table is None:
        return []  # no table => zero openings, not an error

    jobs = []
    for row in table.select("tr"):
        anchor = row.select_one('a[href*="/x/detail/"]')
        if anchor is None:
            continue
        href = urljoin(openings, anchor.get("href", ""))
        match = DETAIL_RE.match(urlparse(href).path)
        if not match:
            continue
        cells = row.select("td")
        location = cells[1].get_text(strip=True) if len(cells) >= 2 else None
        jobs.append({
            "external_id": match.group(1),
            "title": anchor.get_text(strip=True),
            "location": location,
            "listing_url": href,
        })
    return jobs

jobs = scrape_listings("https://tanks.applicantstack.com/x/openings")
print(f"Found {len(jobs)} openings")

Extract job details from embedded JSON-LD

Detail pages have no JSON API — the structured data lives in a <script type="application/ld+json"> JobPosting block. Fetch each detail page and read title, description, dates, employment type, hiring organization, and locations from that block.

Step 3: Extract job details from embedded JSON-LD
import json
import requests
from bs4 import BeautifulSoup

def find_job_posting(soup: BeautifulSoup) -> 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 scrape_detail(detail_url: str) -> dict:
    soup = BeautifulSoup(requests.get(detail_url, timeout=15).text, "html.parser")
    jp = find_job_posting(soup) or {}
    org = jp.get("hiringOrganization") or {}
    ident = jp.get("identifier") or {}
    return {
        "title": jp.get("title"),
        "description_html": jp.get("description"),
        "employment_type": jp.get("employmentType"),
        "posted_at": jp.get("datePosted"),
        "closes_at": jp.get("validThrough"),
        "company_name": org.get("name"),
        "ats_identifier": ident.get("value"),
        "locations": jp.get("jobLocation"),  # address: locality/region/country/postalCode
    }

Fall back to the HTML summary table when JSON-LD is absent

Some detail pages omit JSON-LD. Recover the title from #contenttitle, the Id/Location/Department/Job Type/Salary rows from table.formtable, the description from .listing_description, and the apply link from the /x/apply/ anchor.

Step 4: Fall back to the HTML summary table when JSON-LD is absent
def html_fallback(soup) -> dict:
    out: dict = {}

    title_el = soup.select_one("#contenttitle")
    if title_el:
        out["title"] = title_el.get_text(strip=True)

    # Summary table rows look like <th>Location:</th><td>...</td>
    keys = {"id", "location", "department", "salary", "type", "job type"}
    for row in soup.select("table.formtable tr"):
        th, td = row.find("th"), row.find("td")
        if not th or not td:
            continue
        key = th.get_text(strip=True).rstrip(":").lower()
        value = td.get_text(strip=True)
        if key in keys and value:
            out[key.replace(" ", "_")] = value

    desc = soup.select_one(".listing_description")
    if desc:
        out["description_html"] = desc.decode_contents()

    apply = soup.select_one('a[href*="/x/apply/"]')
    if apply:
        out["apply_url"] = apply.get("href")

    return out

Classify HTTP errors and throttle politely

Map status codes the way the reference scraper does: 401 means the tenant board is private, 403 is a temporary block, a 404 on a detail page means the job was removed, and 429 is rate limiting. Space detail requests out to stay under the radar.

Step 5: Classify HTTP errors and throttle politely
import time
import requests

def fetch(url: str) -> requests.Response:
    resp = requests.get(url, timeout=15)
    if resp.status_code == 401:
        raise RuntimeError("Auth required — this tenant's board is not public")
    if resp.status_code == 403:
        raise RuntimeError("Blocked (403) — back off and retry later")
    if resp.status_code == 404:
        raise RuntimeError("Tenant or job not found / removed (404)")
    if resp.status_code == 429:
        raise RuntimeError("Rate limited (429) — slow down")
    resp.raise_for_status()
    return resp

# The reference scraper caps detail fetches at 3 concurrent with ~400ms between requests
for job in jobs:
    detail = scrape_detail(job["listing_url"])
    time.sleep(0.4)
Common issues
highTenant board returns HTTP 401 (authentication required)

Some employers keep their openings page private/internal, and both the /x/openings HTML and the internal /api/jobs probe respond 401. Treat 401 as auth-required and skip the tenant — only public subdomains return scrapeable openings HTML.

mediumJob descriptions are only in embedded JSON-LD, not a JSON API

Detail data ships as a <script type="application/ld+json"> JobPosting block inside server-rendered HTML. Parse that block first, then fall back to the .listing_description element when it is missing.

mediumMixed-case subdomains create duplicate records

The same tenant appears as both TBHA and tbha. Lowercase the tenant when building /x/openings and /x/detail URLs so external_id-based dedup and provider resolution stay stable.

lowNon-job links captured from the openings table

Rows also link to /x/apply/... and /x/myaccount. Filter hrefs with ^/x/detail/([a-z0-9]+)$ so only real job detail pages become listings.

lowEmpty or restructured openings table yields zero jobs

The page renders a single, non-paginated table#data-table. If the table is absent or empty, return zero openings rather than erroring, and do not attempt pagination — there is none.

Best practices
  1. 1Lowercase the tenant subdomain before building /x/openings and /x/detail URLs
  2. 2Prefer JSON-LD JobPosting fields; use the .formtable summary and .listing_description only as fallbacks
  3. 3Filter detail links with ^/x/detail/[a-z0-9]+$ to skip apply and account pages
  4. 4Fetch the openings page once — it is a single non-paginated table, so do not attempt pagination
  5. 5Cap detail fetches at ~3 concurrent with ~400ms between requests to stay polite
  6. 6Treat 401 as a private tenant board (skip) and a 404 on a detail page as a removed job
Or skip the complexity

One endpoint. All ApplicantStack jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=applicantstack" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

Access ApplicantStack
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