- criticalWhy does the job board page contain no jobs?
- The ViewJobPostings route is only a signed bootstrap describing the data source, fields, filter, and paging hashes. The vacancies come from a separate POST to /{virtualDirectory}/SkySys/VueData/GetData/ using those exact descriptors.
- highWhy do fields belong to the wrong job after decoding?
- VueData returns parallel columnar arrays plus a repeat count rather than row objects. Validate that every array length equals the repeat count before zipping them into rows; a truncated column silently shifts every field after it.
- highDoes an HTTP 200 access-error page mean the job was removed?
- No. A page carrying cmaErrorMessage is an access-required state and the posting may still be live. Classify it as an authentication outcome and retry later. Only a same-route 404 or 410 is removal evidence; everything else must fail closed.
- mediumWhy do some postings have an empty description?
- Districts may attach the description as a document instead of publishing inline text, so the detail page carries no srcdoc body. Treat those as parse failures rather than hydrating an empty string, and keep the job flagged for manual review.
- mediumWhy do two districts collide on the same identity?
- Many districts share one hosted authority such as skyward.iscorp.com. The virtual directory is the district identity and the authority is the deployment scope; folding the authority into the tenant merges unrelated districts into one employer.
Skyward Qmlativ Applicant Tracking Jobs API.
Pull school district vacancies from Skyward Qmlativ boards through the native VueData endpoint that backs the applicant-tracking browse, then hydrate descriptions from the ViewAll route.
What's in every response.
Data fields, real-world applications, and the companies already running on Skyward Qmlativ Applicant Tracking.
Data fields
- Native Paged Listings
- Full Position Descriptions
- Numeric Job Posting IDs
- Per-District Virtual Directories
- Shared & Self-Hosted Deployments
- Columnar Response Alignment
Use cases
- 01K-12 Education Job Boards
- 02School District Hiring Trackers
- 03Public-Sector Talent Research
- 04Careers Page Monitoring
Trusted by
- Medical Lake School District
- Nooksack Valley School District
- Peninsula School District
How to scrape Skyward Qmlativ Applicant Tracking.
Step-by-step guide to extracting jobs from Skyward Qmlativ Applicant Tracking-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
# Known first-party hosted authorities. Custom hosts need their own proof.
HOSTED = {"skyward.iscorp.com", "online.skyward.com"}
def parse_qmlativ(url: str) -> dict:
parsed = urlparse(url)
parts = [p for p in parsed.path.strip("/").split("/") if p]
# /{vdir}/ApplicantTracking/JobBoard/{ViewJobPostings|ViewAll/{id}}
if len(parts) < 4 or parts[1] != "ApplicantTracking" or parts[2] != "JobBoard":
raise ValueError("not a Qmlativ applicant-tracking route")
job_id = None
if parts[3] == "ViewAll" and len(parts) == 5 and parts[4].isdigit():
job_id = parts[4]
elif parts[3] != "ViewJobPostings":
raise ValueError("unrecognised JobBoard route")
host = parsed.netloc.lower()
return {
"authority": host,
"virtual_directory": parts[0],
"job_id": job_id,
"is_hosted": host in HOSTED or host.endswith(".q.wa-k12.net"),
}
print(parse_qmlativ(
"https://www.q.wa-k12.net/penins/ApplicantTracking/JobBoard/ViewJobPostings"))import requests
REQUIRED = {
"dataSourceType": "myjobpostings",
"filterTypeIdentifier": "NoFiltering",
"pagingTypeIdentifier": "InfiniteScroll",
}
def board_url(board: dict) -> str:
return (f"https://{board['authority']}/{board['virtual_directory']}"
"/ApplicantTracking/JobBoard/ViewJobPostings")
session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"
board = parse_qmlativ("https://www.q.wa-k12.net/medica/ApplicantTracking/JobBoard/ViewJobPostings")
bootstrap = session.get(board_url(board), timeout=30)
bootstrap.raise_for_status()
for token in REQUIRED.values():
if token not in bootstrap.text:
raise RuntimeError(f"board omitted its native {token} configuration")
print("bootstrap verified")def data_url(board: dict) -> str:
return (f"https://{board['authority']}/{board['virtual_directory']}"
"/SkySys/VueData/GetData/")
def fetch_page(board: dict, first: bool) -> dict:
form = {
"dataSourceType": "myjobpostings",
"filterTypeIdentifier": "NoFiltering",
"pagingTypeIdentifier": "InfiniteScroll",
"pagingPage": "first" if first else "next",
}
resp = session.post(data_url(board), data=form, timeout=30)
resp.raise_for_status()
return resp.json()
def fetch_all(board: dict, max_pages: int = 500) -> list[dict]:
rows, seen, first = [], set(), True
for _ in range(max_pages):
payload = fetch_page(board, first)
first = False
page_rows = decode_columns(payload)
added = [row for row in page_rows if row["job_id"] not in seen]
for row in added:
seen.add(row["job_id"])
rows.extend(added)
# A short page, an empty page, or a page with no new IDs ends the walk.
if not page_rows or not added:
return rows
raise RuntimeError("Qmlativ paging exceeded its 500-page bound")def decode_columns(payload: dict) -> list[dict]:
repeat = payload.get("$repeat")
if not isinstance(repeat, int):
raise RuntimeError("VueData response omitted its $repeat accounting")
columns = {key: value for key, value in payload.items()
if isinstance(value, list) and key != "$repeat"}
for key, values in columns.items():
if len(values) != repeat:
raise RuntimeError(f"column {key} has {len(values)} entries, expected {repeat}")
rows = []
for index in range(repeat):
job_id = str(columns["JobPostingID"][index])
if not job_id.isdigit():
raise RuntimeError("non-numeric JobPostingID — reject the snapshot")
rows.append({key: values[index] for key, values in columns.items()} | {"job_id": job_id})
return rowsimport time
from bs4 import BeautifulSoup
def job_url(board: dict, job_id: str) -> str:
return (f"https://{board['authority']}/{board['virtual_directory']}"
f"/ApplicantTracking/JobBoard/ViewAll/{job_id}")
def hydrate(board: dict, job_id: str) -> dict | None:
url = job_url(board, job_id)
resp = session.get(url, timeout=30)
if resp.status_code in (404, 410):
return None # canonical removal
resp.raise_for_status()
page = BeautifulSoup(resp.text, "html.parser")
if page.select_one(".cmaErrorMessage .cmaMessageText") is not None:
# HTTP 200 access page: the job may still exist. Never record removal here.
raise PermissionError(f"Qmlativ requires access for job {job_id}")
if job_id not in resp.url:
raise RuntimeError("detail route did not round-trip the requested job ID")
# Published description bodies are delivered as srcdoc HTML fragments.
fragments = [frame.get("srcdoc") for frame in page.select("[srcdoc]") if frame.get("srcdoc")]
if not fragments:
# Attachment-only postings are a parse failure, not an empty description.
raise RuntimeError(f"job {job_id} publishes no inline description")
return {"job_id": job_id, "listing_url": url, "description_html": "\n".join(fragments)}
for row in fetch_all(board)[:3]:
print(hydrate(board, row["job_id"])["job_id"])
time.sleep(0.25)- 1Verify the bootstrap's myjobpostings, NoFiltering, and InfiniteScroll descriptors before any data call
- 2Key the district on the virtual directory and scope it by the URL authority
- 3Follow native first/next paging and bound the walk with a page cap plus a repeated-ID guard
- 4Validate every columnar array length against the response's repeat count
- 5Require the ViewAll route to round-trip the requested JobPostingID
- 6Classify cmaErrorMessage pages as access-required, never as removed jobs
One endpoint. All Skyward Qmlativ Applicant Tracking jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=skyward qmlativ applicant tracking" \
-H "X-Api-Key: YOUR_KEY"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.
Access Skyward Qmlativ Applicant Tracking
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.