Skyward FastTrack Jobs API.

Extract school district vacancies from legacy Skyward FastTrack WebSpeed boards, where the complete server-owned result set is embedded in the browse page's own JavaScript arrays.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on Skyward FastTrack.

Data fields

  • Complete Board Snapshots
  • Full Position Descriptions
  • Native Progress Rowids
  • District-Hosted Deployments
  • Employment Type & Category
  • Row Count Verification

Use cases

  1. 01K-12 Education Job Boards
  2. 02School District Hiring Trackers
  3. 03Legacy ATS Migration Audits
  4. 04Regional Education Feeds

Trusted by

  • Baker County Schools
  • Cedar Hill ISD
  • Peoria Public Schools
DIY GUIDE

How to scrape Skyward FastTrack.

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

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

Decompose the WebSpeed board URL

A FastTrack board is /scripts/{executable}/WService={service}/rappljoblst484.w on the district's own host. Two executables appear in the wild, wsisa.dll and cgiip.exe, and the WService value is case-sensitive — wsFin works where wsfin does not.

Step 1: Decompose the WebSpeed board URL
from urllib.parse import urlparse, parse_qs

BOARD_PROGRAM = "rappljoblst484.w"
DETAIL_PROGRAM = "rappljoblst0502.w"
EXECUTABLES = {"wsisa.dll", "cgiip.exe"}

def parse_fasttrack(url: str) -> dict:
    parsed = urlparse(url)
    parts = [p for p in parsed.path.split("/") if p]
    # /scripts/{executable}/WService={service}/{program}
    if len(parts) < 4 or parts[0].lower() != "scripts":
        raise ValueError("not a FastTrack route")

    executable = parts[1].lower()
    if executable not in EXECUTABLES:
        raise ValueError(f"unknown WebSpeed executable {executable}")
    if not parts[2].startswith("WService="):
        raise ValueError("missing WService segment")

    return {
        # The authority includes a non-default port where the district uses one (444 is common).
        "authority": parsed.netloc,
        "executable": executable,
        # Case is significant: wsFin is not wsfin.
        "service": parts[2][len("WService="):],
        "program": parts[3],
        "record": (parse_qs(parsed.query).get("currentrecord") or [None])[0],
    }

print(parse_fasttrack(
    "https://bakerskyward.nefec.org/scripts/wsisa.dll/WService=wsFin/rappljoblst484.w"))

Fetch the browse page and read its embedded arrays

The browse program does not render a plain table of jobs. It emits the complete server-owned result set as JavaScript: a set of zD[] column arrays holding the cell values, and a zR array holding one native Progress rowid per row.

Step 2: Fetch the browse page and read its embedded arrays
import json
import re
import requests
from bs4 import BeautifulSoup

ZD = re.compile(r"zD\[(?P<index>[0-9]+)\]\s*=\s*(?P<array>\[[^\r\n]*?\]);")
ZR = re.compile(r"var\s+zR\s*=\s*(?P<array>\[[^\r\n]*?\]);")

def board_url(authority: str, executable: str, service: str) -> str:
    return f"https://{authority}/scripts/{executable}/WService={service}/{BOARD_PROGRAM}"

session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"

board = parse_fasttrack(
    "https://finance.chisd.net/scripts/wsisa.dll/WService=wsFin/rappljoblst484.w")
resp = session.get(
    board_url(board["authority"], board["executable"], board["service"]),
    timeout=(4, 8),   # these are on-premises hosts; bound both connect and read
)
resp.raise_for_status()

columns = {int(m.group("index")): json.loads(m.group("array")) for m in ZD.finditer(resp.text)}
rowids = json.loads(ZR.search(resp.text).group("array"))
print(f"{len(columns)} columns, {len(rowids)} rowid slots")

Verify the two row counters agree before trusting the snapshot

The page publishes its own accounting twice: a hidden input numRows and a rendered dRecCount. Both must agree, and the arrays must line up with that count. A mismatch means you have a partially rendered page, which must never be treated as a complete board.

Step 3: Verify the two row counters agree before trusting the snapshot
def row_count(html: str) -> int:
    soup = BeautifulSoup(html, "html.parser")
    num_rows = soup.select_one("#numRows")
    displayed = soup.select_one("#dRecCount")
    if num_rows is None or displayed is None:
        raise RuntimeError("FastTrack browse page omitted its row accounting")

    declared = int(num_rows.get("value"))
    rendered = int("".join(ch for ch in displayed.get_text() if ch.isdigit()) or "-1")
    if declared != rendered:
        raise RuntimeError(f"count mismatch: numRows={declared}, dRecCount={rendered}")
    return declared

count = row_count(resp.text)
# zR is 1-based in the page's own JavaScript, so it carries count + 1 slots.
if any(len(values) != count for values in columns.values()) or len(rowids) != count + 1:
    raise RuntimeError("embedded arrays do not match the declared row count")
print(f"{count} vacancies, snapshot is complete")

Build one row per native rowid

Job identity is the native 16-byte Progress rowid, rendered as 0x followed by 16 hex characters. Every index and rowid must parse; a row that fails is a rejection that downgrades the snapshot rather than something to silently skip.

Step 4: Build one row per native rowid
def detail_url(board: dict, record: str) -> str:
    return (f"https://{board['authority']}/scripts/{board['executable']}"
            f"/WService={board['service']}/{DETAIL_PROGRAM}?currentrecord={record}")

def build_rows(board: dict, columns: dict, rowids: list, count: int) -> list[dict]:
    rows, rejected = [], 0
    for index in range(1, count + 1):
        record = rowids[index]
        if not isinstance(record, str) or not record.lower().startswith("0x"):
            rejected += 1
            continue
        rows.append({
            "record_id": record,
            "title": columns.get(0, [None] * count)[index - 1],
            "listing_url": detail_url(board, record),
        })
    if rejected:
        raise RuntimeError(f"{rejected} rows failed to parse — snapshot is incomplete")
    return rows

listings = build_rows(board, columns, rowids, count)
print(listings[:2])

Hydrate each posting and read removal evidence correctly

The detail program must round-trip the exact rowid you asked for and render its ListingData fieldset. A same-host 404 or 410 is canonical removal, and the first-party message 'The job listing record was not available.' is structured removal. Transport errors and generic pages are never removal evidence.

Step 5: Hydrate each posting and read removal evidence correctly
import time

NATIVE_UNAVAILABLE = "The job listing record was not available."

def hydrate(board: dict, record: str) -> dict | None:
    url = detail_url(board, record)
    resp = session.get(url, timeout=(4, 8))
    if resp.status_code in (404, 410):
        return None                       # canonical HTTP removal
    resp.raise_for_status()
    if NATIVE_UNAVAILABLE.lower() in resp.text.lower():
        return None                       # structured removal
    page = BeautifulSoup(resp.text, "html.parser")

    echoed = page.select_one("input[name='currentrecord']")
    fieldset = page.select_one("fieldset#ListingData, fieldset[name='ListingData']")
    if echoed is None or fieldset is None or echoed.get("value", "").lower() != record.lower():
        # A generic page or a redirect is a failure, not an absence.
        raise RuntimeError("detail page did not round-trip the requested rowid")

    return {"record_id": record, "listing_url": url,
            "description_html": fieldset.decode_contents()}

for row in listings[:3]:
    print(bool(hydrate(board, row["record_id"])))
    time.sleep(0.25)
Common issues
criticalWhy does lowercasing the WService value break every request?
The WService token is case-sensitive on the WebSpeed broker: wsFin resolves and wsfin does not. If your pipeline normalises identifiers to lowercase, store the service bytes encoded and decode them for every request rather than letting normalisation reach the URL.
highWhy do so many district hosts time out?
FastTrack is frequently self-hosted on district infrastructure with regional firewall rules. In one 80-URL sweep, 62 rows timed out from the audit region. Bound both connect and read (four and eight seconds are workable), and classify a timeout as unreachable — never as a removed job.
highWhy is the job list missing from the page HTML?
There is no rendered job table to parse. The complete result set lives in the page's own zD[] column arrays and zR rowid array. Extract those with a regex over the script text and parse them as JSON, then align them against the declared row count.
mediumHow do I know the browse page is not truncated?
Cross-check the hidden numRows input against the rendered dRecCount value and confirm every zD array has exactly that many entries. When they disagree, downgrade the snapshot to incomplete instead of letting a partial page expire jobs that are still open.
mediumWhy does an HTTP 200 response prove nothing about the tenant?
A district host can answer 200 with unrelated content. Require Skyward's version marker, the WebSpeed generation marker, the expected program name, and either the exact active rowid or the native unavailable message before accepting a custom host as a real FastTrack deployment.
Best practices
  1. 1Preserve WService case exactly; encode it if your pipeline lowercases identifiers
  2. 2Keep the port in the tenant authority — some districts serve FastTrack on 444
  3. 3Parse jobs from the embedded zD/zR arrays, not from rendered table markup
  4. 4Require numRows and dRecCount to agree before treating a snapshot as complete
  5. 5Bound connect and read timeouts separately; self-hosted districts are often slow
  6. 6Accept removal only from a same-host 404/410 or the native unavailable message
Or skip the complexity

One endpoint. All Skyward FastTrack jobs. No scraping, no sessions, no maintenance.

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