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.
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.
- Full Job Descriptions
- Structured Location Data
- Employment Type & Department
- Salary Text
- Posted & Closing Dates
- Apply URLs
- 01SMB Job Aggregation
- 02Multi-Tenant Job Discovery
- 03Recruiting Data Pipelines
- 04Local Hiring Monitoring
How to scrape ApplicantStack.
Step-by-step guide to extracting jobs from ApplicantStack-powered career pages—endpoints, authentication, and working code.
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/openingsimport 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")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
}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 outimport 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)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.
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.
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.
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.
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.
- 1Lowercase the tenant subdomain before building /x/openings and /x/detail URLs
- 2Prefer JSON-LD JobPosting fields; use the .formtable summary and .listing_description only as fallbacks
- 3Filter detail links with ^/x/detail/[a-z0-9]+$ to skip apply and account pages
- 4Fetch the openings page once — it is a single non-paginated table, so do not attempt pagination
- 5Cap detail fetches at ~3 concurrent with ~400ms between requests to stay polite
- 6Treat 401 as a private tenant board (skip) and a 404 on a detail page as a removed job
One endpoint. All ApplicantStack jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=applicantstack" \
-H "X-Api-Key: YOUR_KEY" 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.