All platforms

OnShift Employ Jobs API.

Pull senior-care and long-term-care openings straight from each employer's OnShift Employ board — an anonymous per-tenant JSON API carrying shift schedules, pay grades, and full descriptions.

Get API access
OnShift Employ
Live
<3haverage discovery time
1hrefresh interval
Companies using OnShift Employ
Embassy HealthcareSabal Health & RehabFalck Global Assistance
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 OnShift Employ.

Data fields
  • Full Job Descriptions
  • Pay Grade & Incentives
  • Shift Schedules
  • Department & Requisition Numbers
  • Employment Type & Remote Flag
  • Facility Location & ZIP
Use cases
  1. 01Senior-Care Job Aggregation
  2. 02Healthcare Staffing Feeds
  3. 03Shift & Pay Benchmarking
  4. 04Long-Term-Care Market Tracking
Trusted by
Embassy HealthcareSabal Health & RehabFalck Global Assistance
DIY GUIDE

How to scrape OnShift Employ.

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

RESTintermediateNo published limit; CloudFront returns 403/429 under load — self-throttle to one request at a time with ~2s delays.No auth

Resolve the tenant board

Every employer gets its own OnShift Employ subdomain; the tenant slug is the leftmost DNS label. The API is anonymous, so no key, token, or cookie is needed.

Step 1: Resolve the tenant board
import requests

# e.g. "embassyhealthcare" in embassyhealthcare.employ.onshift.com
tenant = "embassyhealthcare"
base = f"https://{tenant}.employ.onshift.com"

session = requests.Session()
session.headers.update({"Accept": "application/json"})

Fetch the openings array

The board endpoint returns the complete tenant-scoped array of openings in one call — there is no pagination cursor and no advertised total. Drop rows the API still lists but flags closed.

Step 2: Fetch the openings array
resp = session.get(f"{base}/api/v1/external/openings", timeout=15)
resp.raise_for_status()
openings = resp.json()  # a JSON array of board-level rows

# manual_close=true rows are closed but still present; rows without an id are junk.
active = [o for o in openings if o.get("id") and not o.get("manual_close")]
print(f"{len(active)} active openings for {tenant}")

Hydrate each opening via the detail API

The listings array carries no description, so call the per-position detail endpoint. The trailing slash stands in for the optional advertising id, which the anonymous API does not require.

Step 3: Hydrate each opening via the detail API
def fetch_opening(position_id: int) -> dict | None:
    url = f"{base}/api/v1/internal/job-positions/{position_id}/"
    r = session.get(url, timeout=15)
    if r.status_code == 404:
        return None  # opening removed
    r.raise_for_status()
    return r.json().get("opening")

for row in active:
    opening = fetch_opening(row["id"])
    if not opening:
        continue
    description = opening.get("position_description") or ""
    if opening.get("description_append"):
        description += "\n" + opening["description_append"]
    print(row["id"], opening.get("title"), opening.get("pay_grade"))

Normalize fields and the posting key

Key each job on opening.id (the position id) — never on job_id, which is a reusable job-template id shared across postings and tenants. Map the numeric employment_type and build the canonical public URL.

Step 4: Normalize fields and the posting key
EMPLOYMENT_TYPES = {0: "full_time", 1: "part_time", 2: "per_diem", 3: "contract", 4: "temporary"}

def is_closed(o: dict) -> bool:
    return bool(o.get("deleted") or o.get("archived")
                or o.get("manual_close") or o.get("status") is False)

def record(o: dict) -> dict:
    dept = o.get("department") or {}
    return {
        "external_id": o["id"],                          # positionId = posting key
        "title": o.get("title"),
        "employment_type": EMPLOYMENT_TYPES.get(o.get("employment_type")),
        "shift_list": o.get("shift_list"),
        "department": dept.get("department"),
        "is_remote": o.get("is_remote") == 1,
        # Drop the optional /{advertisingId} segment; it is not the posting key.
        "url": f"{base}/job_positions/view/{o['id']}",
    }

Throttle and handle CloudFront blocks

CloudFront fronts every tenant. Keep to one request at a time with a short pause; a 403 'Request blocked' or 429 means the edge is throttling, not that the board is empty — back off and retry rather than recording zero jobs.

Step 5: Throttle and handle CloudFront blocks
import time

def fetch_with_backoff(url: str, tries: int = 4) -> requests.Response:
    for attempt in range(tries):
        r = session.get(url, timeout=15)
        if r.status_code in (403, 429):
            time.sleep(2 * (attempt + 1))  # scraper default: ~2s between requests
            continue
        return r
    r.raise_for_status()
    return r
Common issues
highCloudFront returns HTTP 403 'Request blocked' or 429

The JSON contract works, but CloudFront blocks some regions and IPs at the edge. Treat 403/429 as a rate-limit/block (retry with backoff, run from an allowed region), never as an empty board or deletion evidence.

highUsing job_id as the unique posting key

The detail response's job_id is a reusable job-template id shared across postings and even across tenants. Key jobs on opening.id (the position id), which is the immutable posting key present in both API phases and the public URL.

mediumListings have no description

The /api/v1/external/openings array only returns board-level fields (id, title, location, company) — no description and no job_id. Call /api/v1/internal/job-positions/{positionId}/ per opening to get the full body and stable metadata.

mediumClosed openings still return HTTP 200

Rows flagged manual_close=true stay in the listings array, and their detail endpoint still returns 200. Filter manual_close, and on the detail treat deleted/archived/status=false/manual_close as removed before persisting.

lowTwo-segment view URLs confuse the id parser

Public URLs may look like /job_positions/view/{positionId}/{advertisingId}. The second segment is an advertising/application route id, not the posting key, and is not returned by the API — parse only the first numeric segment.

Best practices
  1. 1Key jobs on opening.id (positionId), never on the reusable job_id.
  2. 2Filter manual_close, deleted, archived, and status=false rows before persisting.
  3. 3Throttle to one request at a time with a ~2s delay (the scraper's default).
  4. 4Treat 403/429 as a block, not an empty board — back off and retry rather than expiring jobs.
  5. 5Merge position_description with description_append for the complete description.
  6. 6Build canonical URLs as /job_positions/view/{positionId}, dropping the advertising segment.
Or skip the complexity

One endpoint. All OnShift Employ jobs. No scraping, no sessions, no maintenance.

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

Access OnShift Employ
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