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.

Get API access

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

  1. 01Public-Sector Job Aggregation
  2. 02Municipal Hiring Trackers
  3. 03Civic Data Research
  4. 04Legacy ATS Migration Audits

Trusted by

  • Albemarle Regional Health Services
  • City of Auburn
  • City of Tulsa
DIY GUIDE

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.

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

Resolve the tenant, the surface prefix and the job tuple

Boards live on the hosted munisselfservice.com fleet or on a customer-owned host, at the root or under /ess or /mss. A job is identified by the exact req, sreq and form tuple; the desc query value is mutable display text and must be ignored.

Step 1: Resolve the tenant, the surface prefix and the job tuple
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"))

Send the UseCookies cookie on the very first request

This is the step everything else depends on. Without a UseCookies=1 cookie the application can answer with an upgrade redirect while embedding a misleading response body, so your parser sees plausible HTML that is not the board.

Step 2: Send the UseCookies cookie on the very first request
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))

Read the authoritative openings count

The initial response declares how many openings exist and normally renders only ten cards. Read the openings count label before parsing anything else — it is the number every later step has to reconcile against.

Step 3: Read the authoritative openings count
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")

Post the page's own ViewState to load every row

There is no cursor and no page parameter. When more rows exist than are rendered, replay the page's hidden WebForms state with the rows-per-page selector set to All and the native Next control, then require the returned card count to equal the unchanged openings count.

Step 4: Post the page's own ViewState to load every row
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")

Hydrate each JobDetail and read Tyler's unavailable page

Detail pages render the description inside a job detail card, with labelled paragraphs for salary and grade. A 404 or 410 is canonical removal, and Tyler's own 'this Employment Opportunity is currently unavailable' page is structured removal; a redirect or unproved HTML is neither.

Step 5: Hydrate each JobDetail and read Tyler's unavailable page
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)
Common issues
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.
Best practices
  1. 1Send UseCookies=1 on the first request and keep it on the whole session
  2. 2Key each job on the req, sreq and form tuple and ignore the mutable desc value
  3. 3Read the openings count first and reconcile every later page against it
  4. 4Post the page's own ViewState with rows-per-page set to All instead of guessing a page parameter
  5. 5Require Tyler-specific proof before accepting a customer-owned host as a real board
  6. 6Accept removal only from 404/410 or Tyler's explicit unavailable page
Or skip the complexity

One endpoint. All Tyler Munis Self Service jobs. No scraping, no sessions, no maintenance.

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=tyler munis self service" \
  -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 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.

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