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.

Get API access

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

  1. 01Hourly & Frontline Job Boards
  2. 02Restaurant & Retail Hiring Feeds
  3. 03Franchise Location Tracking
  4. 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.

API type
HTML
Difficulty
beginner
Rate limit
No published limit; ~250ms between requests, max 3 concurrent detail fetches
Authentication
No auth

Read the company ID and slug from the board URL

Workstream boards are workstream.us/j/{companyId}/{companySlug}. Job pages add a location slug and a job slug: /j/{companyId}/{companySlug}/{locationSlug}/{jobId}. The short company ID is the tenant key.

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"))

Collect job links from the board page

The board is server-rendered and lists every public posting in one response — there is no pagination to follow. Walk its anchors, keep only links that stay on the same company ID and slug, and dedupe on the job slug segment.

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")

Hydrate each posting from JobPosting JSON-LD

Every detail page carries a JobPosting JSON-LD block with the description, dates, employment type and hiring organisation. A page that has no valid block is a parse failure, not an empty job.

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)

Handle throttling and empty boards separately

Map HTTP 403 and 429 to throttling and back off; those are not evidence that a board is empty. A board that returns HTTP 200 with no matching job links really is empty, which is a valid snapshot for a franchise between hiring pushes.

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
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.
Best practices
  1. 1Take the tenant from the short company ID in the /j/ path
  2. 2Send a Referer of the board URL on both board and detail requests
  3. 3Keep only anchors that stay on the same company ID and slug
  4. 4Dedupe on the job slug segment, since multi-location roles repeat per site
  5. 5Treat a detail page with no JobPosting JSON-LD as a parse failure, not an empty job
  6. 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 access
cURL
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
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