- mediumShould I use Workstream's Positions API instead?
- Not for public job data. Workstream's documented Positions API requires OAuth bearer credentials issued to the employer, so it is not a public board contract. The server-rendered career page publishes the same public index anonymously.
- mediumWhy does the same job show up several times?
- A multi-location employer publishes one route per location, so a shared role appears under several location slugs. Dedupe on the job slug segment of the path, and keep the location slug if you want per-site coverage rather than a single merged row.
- lowHow does pagination work on a Workstream board?
- It does not — the board lists every public posting in one response. If you are getting fewer jobs than the employer advertises, the cause is a filtered anchor selector or a throttled response, not a missing page parameter.
- lowDoes an empty board mean the employer left Workstream?
- No. Frontline employers cycle between hiring pushes, so an HTTP 200 board with no job links is an authoritative empty snapshot. Keep the tenant resolvable and only treat a 404 or 410 on the board URL as a removed employer.
Workstream Jobs API.
Extract hourly and frontline openings from a Workstream career page in two steps: read the board's own job links, then pull JobPosting JSON-LD from each posting.
What's in every response.
Data fields, real-world applications, and the companies already running on Workstream.
Data fields
- Full Job Descriptions
- JobPosting JSON-LD
- Per-Location Job Routes
- Multi-Brand Employers
- Single-Request Board Snapshot
- Direct Apply URLs
Use cases
- 01Hourly & Frontline Job Boards
- 02Restaurant & Retail Hiring Feeds
- 03Franchise Location Tracking
- 04Local Labour Market Research
Trusted by
- Ace Hardware
- Chick-fil-A
- 151 Coffee
DIY GUIDE
How to scrape Workstream.
Step-by-step guide to extracting jobs from Workstream-powered career pages—endpoints, authentication, and working code.
Step 1: Read the company ID and slug from the board URL
from urllib.parse import urlparse
HOSTS = {"www.workstream.us", "workstream.us"}
RESERVED = {"locations", "positions"}
def parse_workstream(url: str) -> dict:
parsed = urlparse(url)
if parsed.netloc.lower() not in HOSTS:
raise ValueError("not a Workstream URL")
parts = [p for p in parsed.path.strip("/").split("/") if p]
if len(parts) < 3 or parts[0].lower() != "j":
raise ValueError("expected /j/{companyId}/{companySlug}")
company_id, company_slug = parts[1].lower(), parts[2].lower()
job_id = None
if len(parts) >= 5 and parts[3].lower() not in RESERVED:
job_id = parts[4]
return {
"company_id": company_id,
"company_slug": company_slug,
"job_id": job_id,
"board_url": f"https://www.workstream.us/j/{company_id}/{company_slug}",
}
print(parse_workstream("https://www.workstream.us/j/61bd2424/ace-hardware"
"/yarmouth-75190/retail-cashier-b928086c"))Step 2: Collect job links from the board page
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
session = requests.Session()
def fetch_board(board: dict) -> list[dict]:
resp = session.get(
board["board_url"],
headers={"Accept": "text/html,application/xhtml+xml", "Referer": board["board_url"]},
timeout=30,
)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
listings, seen = [], set()
for anchor in soup.select("a[href]"):
url = urljoin(board["board_url"], anchor["href"])
try:
identity = parse_workstream(url)
except ValueError:
continue
if (identity["company_id"] != board["company_id"]
or identity["company_slug"] != board["company_slug"]
or not identity["job_id"]
or identity["job_id"] in seen):
continue
seen.add(identity["job_id"])
detail_url = f"https://www.workstream.us{urlparse(url).path}"
listings.append({
"job_id": identity["job_id"],
"title": anchor.get_text(strip=True) or None,
"listing_url": detail_url,
"apply_url": detail_url,
})
return listings
board = parse_workstream("https://www.workstream.us/j/61bd2424/ace-hardware")
listings = fetch_board(board)
print(f"{len(listings)} postings")Step 3: Hydrate each posting from JobPosting JSON-LD
import json
import time
def find_job_posting(html: str) -> dict | None:
soup = BeautifulSoup(html, "html.parser")
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 hydrate(board: dict, row: dict) -> dict | None:
resp = session.get(
row["listing_url"],
headers={"Accept": "text/html,application/xhtml+xml", "Referer": board["board_url"]},
timeout=30,
)
if resp.status_code in (404, 410):
return None # canonical removal
resp.raise_for_status()
posting = find_job_posting(resp.text)
if posting is None:
raise RuntimeError("Workstream detail page carried no valid JobPosting JSON-LD")
location = ((posting.get("jobLocation") or {}).get("address") or {})
return {
**row,
"title": posting.get("title") or row["title"],
"description_html": posting.get("description"),
"posted_at": posting.get("datePosted"),
"closes_at": posting.get("validThrough"),
"employment_type": posting.get("employmentType"),
"company": (posting.get("hiringOrganization") or {}).get("name"),
"city": location.get("addressLocality"),
"region": location.get("addressRegion"),
}
for row in listings[:3]:
print(hydrate(board, row)["title"])
time.sleep(0.25)Step 4: Handle throttling and empty boards separately
def crawl(board_url: str) -> dict:
board = parse_workstream(board_url)
try:
listings = fetch_board(board)
except requests.HTTPError as error:
status = error.response.status_code
if status in (403, 429):
return {"state": "throttled"} # back off, do not expire jobs
if status in (404, 410):
return {"state": "board_removed"}
if status == 401:
return {"state": "auth_required"}
raise
if not listings:
# A live board with no current openings is an authoritative empty snapshot.
return {"state": "empty", "jobs": []}
return {"state": "ok", "jobs": listings}
for url in ("https://www.workstream.us/j/61bd2424/ace-hardware",
"https://www.workstream.us/j/224b40b5/151-coffee"):
result = crawl(url)
print(url, "->", result["state"], len(result.get("jobs", []))) Common issues
Best practices
- 1Take the tenant from the short company ID in the /j/ path
- 2Send a Referer of the board URL on both board and detail requests
- 3Keep only anchors that stay on the same company ID and slug
- 4Dedupe on the job slug segment, since multi-location roles repeat per site
- 5Treat a detail page with no JobPosting JSON-LD as a parse failure, not an empty job
- 6Map 403 and 429 to throttling and back off rather than recording an empty board
Or skip the complexity
One endpoint. All Workstream jobs. No scraping, no sessions, no maintenance.
Get API accesscURL
curl "https://connect.jobo.world/api/jobs?sources=workstream" \
-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 Workstream
Access Workstream
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