- criticalWhy does the first request return an upgrade page instead of the board?
- Munis needs a UseCookies=1 cookie on the very first GET. Without it the application can answer with an upgrade redirect while still embedding a misleading response body, so the parser sees plausible HTML that is not the board. Send the cookie on every request.
- highWhy does the board only show ten jobs?
- The initial WebForms response renders ten cards regardless of how many openings exist. Replay the page's own __VIEWSTATE and __EVENTVALIDATION with the rows-per-page selector set to All, then require the returned card count to equal the unchanged openings count before accepting the snapshot.
- highWhy does the same job change URL between runs?
- The desc query parameter is mutable display text. Job identity is the exact req, sreq and form tuple; keying on the whole query string creates a new job every time the description is edited.
- highCan I trust a customer-hosted Munis board from its hostname?
- No. On-premises deployments run on the customer's own domain, so require the exact detail route, a same-host response, the MUNIS Self Services metadata, the Tyler copyright, and a matching first-party postBackUrl tuple before minting an employer from that host.
- mediumWhy are so many on-premises hosts unreachable?
- Self-hosted portals sit behind customer firewalls. In one 113-row audit, 20 customer hosts resolved in DNS but were unreachable over HTTPS and one returned 403. Classify those as unreachable rather than unsupported, and never expire jobs because a private host was offline.
Tyler Munis Self Service Jobs API.
Extract city, county, and agency vacancies from legacy Tyler Munis Self Service portals by driving the WebForms board to its complete snapshot and verifying it against the page's own openings count.
What's in every response.
Data fields, real-world applications, and the companies already running on Tyler Munis Self Service.
Data fields
- Full Job Descriptions
- Salary & Grade Details
- Verified Openings Count
- Hosted & On-Premises Boards
- Requisition Tuple Identity
- Structured Unavailable Pages
Use cases
- 01Public-Sector Job Aggregation
- 02Municipal Hiring Trackers
- 03Civic Data Research
- 04Legacy ATS Migration Audits
Trusted by
- Albemarle Regional Health Services
- City of Auburn
- City of Tulsa
How to scrape Tyler Munis Self Service.
Step-by-step guide to extracting jobs from Tyler Munis Self Service-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse, parse_qs
HOSTED_DOMAIN = "munisselfservice.com"
SURFACES = ("ess", "mss")
def parse_munis(url: str) -> dict:
parsed = urlparse(url)
host = parsed.netloc.lower()
parts = [p for p in parsed.path.strip("/").split("/") if p]
surface = parts[0].lower() if parts and parts[0].lower() in SURFACES else ""
if "EmploymentOpportunities" not in parsed.path:
raise ValueError("not an EmploymentOpportunities route")
query = {k.lower(): v[0] for k, v in parse_qs(parsed.query).items()}
job = None
if parsed.path.lower().endswith("jobdetail.aspx"):
# The mutable 'desc' parameter is deliberately ignored.
job = {"req": query.get("req"), "sreq": query.get("sreq"), "form": query.get("form")}
if not all(job.values()):
raise ValueError("JobDetail requires req, sreq and form")
prefix = f"/{surface}" if surface else ""
return {
"tenant": host.split(".")[0] if host.endswith(HOSTED_DOMAIN) else host,
"hosted": host.endswith(HOSTED_DOMAIN),
"board_url": f"https://{host}{prefix}/EmploymentOpportunities/Default.aspx",
"job": job,
}
print(parse_munis("https://arhsnc.munisselfservice.com/ess/EmploymentOpportunities"
"/JobDetail.aspx?req=2026541&sreq=1&form=GEN"))import requests
session = requests.Session()
session.headers["Accept"] = "text/html,application/xhtml+xml"
def fetch(url: str) -> requests.Response:
resp = session.get(url, timeout=30, cookies={"UseCookies": "1"})
resp.raise_for_status()
return resp
board = parse_munis("https://cityoftulsa.munisselfservice.com/EmploymentOpportunities/Default.aspx")
first = fetch(board["board_url"])
print(first.status_code, len(first.text))import re
from bs4 import BeautifulSoup
def openings_count(html: str) -> int:
soup = BeautifulSoup(html, "html.parser")
label = (soup.select_one("[id$='openingsCountLabelBottom']")
or soup.select_one("[id$='openingsCountLabelTop']"))
if label is None:
raise RuntimeError("Munis board omitted its openings count — not a board page")
digits = re.search(r"\d+", label.get_text())
if not digits:
raise RuntimeError("openings label carried no number")
return int(digits.group())
def card_count(html: str) -> int:
return len(BeautifulSoup(html, "html.parser").select("table.employmentopportunity"))
declared = openings_count(first.text)
print(f"{declared} openings declared, {card_count(first.text)} rendered")from urllib.parse import urlencode
def show_all_form(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser")
form = {field["name"]: field.get("value", "")
for field in soup.select("input[type='hidden'][name]")}
if "__VIEWSTATE" not in form or "__EVENTVALIDATION" not in form:
raise RuntimeError("board omitted its WebForms state — cannot page")
# Find the rows-per-page select and ask for every row.
for select in soup.select("select[name]"):
options = [o.get_text(strip=True).lower() for o in select.select("option")]
if "all" in options:
form[select["name"]] = "All"
return form
def complete_snapshot(board: dict, html: str) -> str:
declared = openings_count(html)
if card_count(html) >= declared:
return html
resp = session.post(
board["board_url"],
data=urlencode(show_all_form(html)),
headers={"Content-Type": "application/x-www-form-urlencoded"},
cookies={"UseCookies": "1"},
timeout=60,
)
resp.raise_for_status()
if openings_count(resp.text) != declared or card_count(resp.text) != declared:
raise RuntimeError("Munis returned a truncated board — refuse the snapshot")
return resp.text
page = complete_snapshot(board, first.text)
print(card_count(page), "cards after show-all")import time
UNAVAILABLE = re.compile(r"Sorry,\s*this Employment Opp+ortunity is currently unavailable",
re.IGNORECASE)
def hydrate(board: dict, job: dict) -> dict | None:
url = (board["board_url"].replace("Default.aspx", "JobDetail.aspx")
+ f"?req={job['req']}&sreq={job['sreq']}&form={job['form']}")
resp = session.get(url, timeout=30, cookies={"UseCookies": "1"})
if resp.status_code in (404, 410):
return None # canonical removal
resp.raise_for_status()
if UNAVAILABLE.search(resp.text):
return None # structured removal
soup = BeautifulSoup(resp.text, "html.parser")
card = soup.select_one("tcw-card.jobDetailCard") or soup.select_one(".jobDetailCard")
if card is None:
raise RuntimeError("neither a job card nor the unavailable page — parse failure")
fields = {}
for paragraph in card.select("p"):
label = paragraph.select_one("b")
if label:
key = label.get_text(strip=True).rstrip(":").strip()
fields[key] = paragraph.get_text(" ", strip=True)[len(key) + 1:].strip()
return {
"req": job["req"], "sreq": job["sreq"], "form": job["form"],
"title": (card.select_one("h2") or card).get_text(strip=True),
"description_html": card.decode_contents(),
"fields": fields,
"listing_url": url,
}
for card in BeautifulSoup(page, "html.parser").select("table.employmentopportunity")[:3]:
anchor = card.select_one("a[href*='JobDetail.aspx' i]")
if anchor:
print(bool(hydrate(board, parse_munis(anchor['href'])["job"])))
time.sleep(0.25)- 1Send UseCookies=1 on the first request and keep it on the whole session
- 2Key each job on the req, sreq and form tuple and ignore the mutable desc value
- 3Read the openings count first and reconcile every later page against it
- 4Post the page's own ViewState with rows-per-page set to All instead of guessing a page parameter
- 5Require Tyler-specific proof before accepting a customer-owned host as a real board
- 6Accept removal only from 404/410 or Tyler's explicit unavailable page
One endpoint. All Tyler Munis Self Service jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=tyler munis self service" \
-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 Tyler Munis Self Service
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.