- 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.
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.
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
- 01K-12 Education Job Boards
- 02School District Hiring Trackers
- 03Legacy ATS Migration Audits
- 04Regional Education Feeds
Trusted by
- Baker County Schools
- Cedar Hill ISD
- Peoria Public Schools
How to scrape Skyward FastTrack.
Step-by-step guide to extracting jobs from Skyward FastTrack-powered career pages—endpoints, authentication, and working code.
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"))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")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")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])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)- 1Preserve WService case exactly; encode it if your pipeline lowercases identifiers
- 2Keep the port in the tenant authority — some districts serve FastTrack on 444
- 3Parse jobs from the embedded zD/zR arrays, not from rendered table markup
- 4Require numRows and dRecCount to agree before treating a snapshot as complete
- 5Bound connect and read timeouts separately; self-hosted districts are often slow
- 6Accept removal only from a same-host 404/410 or the native unavailable message
One endpoint. All Skyward FastTrack jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=skyward fasttrack" \
-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 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.