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.
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.
- Full Job Descriptions
- Pay Grade & Incentives
- Shift Schedules
- Department & Requisition Numbers
- Employment Type & Remote Flag
- Facility Location & ZIP
- 01Senior-Care Job Aggregation
- 02Healthcare Staffing Feeds
- 03Shift & Pay Benchmarking
- 04Long-Term-Care Market Tracking
How to scrape OnShift Employ.
Step-by-step guide to extracting jobs from OnShift Employ-powered career pages—endpoints, authentication, and working code.
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"})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}")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"))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']}",
}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 rThe 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.
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.
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.
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.
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.
- 1Key jobs on opening.id (positionId), never on the reusable job_id.
- 2Filter manual_close, deleted, archived, and status=false rows before persisting.
- 3Throttle to one request at a time with a ~2s delay (the scraper's default).
- 4Treat 403/429 as a block, not an empty board — back off and retry rather than expiring jobs.
- 5Merge position_description with description_append for the complete description.
- 6Build canonical URLs as /job_positions/view/{positionId}, dropping the advertising segment.
One endpoint. All OnShift Employ jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=onshift employ" \
-H "X-Api-Key: YOUR_KEY" 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.