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.

Get API access

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

  1. 01K-12 Education Job Boards
  2. 02School District Hiring Trackers
  3. 03Public-Sector Talent Research
  4. 04Careers Page Monitoring

Trusted by

  • Medical Lake School District
  • Nooksack Valley School District
  • Peninsula School District
DIY GUIDE

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.

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

Split the board URL into authority and virtual directory

Qmlativ boards are https://{authority}/{virtualDirectory}/ApplicantTracking/JobBoard/ViewJobPostings. The virtual directory identifies the district and the authority identifies the deployment, which keeps districts distinct when many share one hosted authority.

Step 1: Split the board URL into authority and virtual directory
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"))

Read the signed bootstrap from the board page

The board page renders no jobs; it is a signed bootstrap that names the data source, fields, filter and paging descriptors the browse will use. Require the exact expected descriptors before issuing any data call, so a lookalike page cannot mint a district.

Step 2: Read the signed bootstrap from the board page
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")

Page the native VueData endpoint

Listings come from /{virtualDirectory}/SkySys/VueData/GetData/ using the board's published descriptors. Paging is native: send pagingPage=first for the opening request and pagingPage=next for each continuation, until a short or empty page arrives.

Step 3: Page the native VueData endpoint
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")

Decode the columnar payload against its repeat count

VueData answers in columns, not row objects: parallel arrays plus a repeat count. Every array must have exactly that many entries before you zip them into rows, otherwise a truncated response silently produces mismatched jobs.

Step 4: Decode the columnar payload against its repeat count
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 rows

Hydrate each posting and separate access errors from removals

The detail route is ViewAll/{JobPostingID} and must round-trip the job ID you asked for. Published descriptions arrive inside HTML srcdoc sections. A common HTTP 200 page carrying cmaErrorMessage means access is required, which is not removal; only a same-route 404 or 410 is.

Step 5: Hydrate each posting and separate access errors from removals
import 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)
Common issues
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.
Best practices
  1. 1Verify the bootstrap's myjobpostings, NoFiltering, and InfiniteScroll descriptors before any data call
  2. 2Key the district on the virtual directory and scope it by the URL authority
  3. 3Follow native first/next paging and bound the walk with a page cap plus a repeated-ID guard
  4. 4Validate every columnar array length against the response's repeat count
  5. 5Require the ViewAll route to round-trip the requested JobPostingID
  6. 6Classify cmaErrorMessage pages as access-required, never as removed jobs
Or skip the complexity

One endpoint. All Skyward Qmlativ Applicant Tracking jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=skyward qmlativ applicant tracking" \
  -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 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.

99.9%API uptime
<200msAvg response
50M+Jobs processed