- mediumWhy does a direct /Positions/{id} request return HTTP 401?
- The per-position resource is not part of the public surface. It is also unnecessary — every record in the /Positions collection already carries the full HTML description, dates, salary and classification, so a detail request adds nothing but a failed call.
- highHow do I know a Portico host really belongs to that agency?
- Call /tess/citizen/api/AppConfig first and require its telemetry global context to name the same tenant as the subdomain, with division 'erp' and product 'employee-access'. Anything less and the host has not proved ownership of the board.
- mediumWhy do some positions have an unusable description?
- A minority of tenants paste descriptions as embedded base64 images. Detect an image-only description and count it as a rejection that makes the snapshot incomplete, rather than storing an empty or image-only body as if it were text.
- mediumDoes a job disappearing from Positions mean it was deleted?
- Absence from a complete collection is the removal signal, and that is the only one you should use. Do not treat an HTML redirect on a stale job-list URL as evidence — in one audit 25 of 37 historical job IDs were cleanly absent while every tenant stayed fully live.
Tyler Portico Jobs API.
Pull a city, county, or school district's entire vacancy list from Tyler Portico with two JSON calls: one that proves which tenant you are talking to, and one that returns every open position in full.
What's in every response.
Data fields, real-world applications, and the companies already running on Tyler Portico.
Data fields
- Complete Unpaged Collection
- Full HTML Descriptions
- Salary Ranges
- Bargaining Unit Fields
- Job Family & Class
- Posting Start & End Dates
Use cases
- 01Public-Sector Job Aggregation
- 02Municipal Hiring Trackers
- 03School District Careers Feeds
- 04Civic Data Research
Trusted by
- Bozeman School District
- City of Helena
- City of Rapid City
How to scrape Tyler Portico.
Step-by-step guide to extracting jobs from Tyler Portico-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
ROOT_DOMAIN = "tylerportico.com"
API_BASE = "/tess/citizen/api"
BOARD_PATH = "/tess/citizen/jobs/job-list"
def parse_portico(url: str) -> dict:
parsed = urlparse(url)
host = parsed.netloc.lower()
if not host.endswith("." + ROOT_DOMAIN):
raise ValueError("not a Tyler Portico host")
if not parsed.path.startswith(BOARD_PATH):
raise ValueError("not a Portico job-list route")
tenant = host[: -len("." + ROOT_DOMAIN)]
return {"tenant": tenant, "origin": f"https://{host}"}
board = parse_portico("https://cityofhelenamt.tylerportico.com/tess/citizen/jobs/job-list")
print(board)
# {'tenant': 'cityofhelenamt', 'origin': 'https://cityofhelenamt.tylerportico.com'}import requests
session = requests.Session()
session.headers["Accept"] = "application/json"
def verify_tenant(board: dict) -> dict:
resp = session.get(f"{board['origin']}{API_BASE}/AppConfig", timeout=30)
resp.raise_for_status()
config = resp.json()
context = ((config.get("datadogRum") or {}).get("config") or {}).get("globalContext") or {}
if (context.get("tenantName") != board["tenant"]
or (context.get("division") or "").lower() != "erp"
or (context.get("product") or "").lower() != "employee-access"):
raise RuntimeError("AppConfig omitted or contradicted its first-party tenant proof")
return {
"tenant": board["tenant"],
"agency_name": config.get("agencyName"),
"portal_title": config.get("portalAgencyTitle"),
}
print(verify_tenant(board))def fetch_positions(board: dict) -> list[dict]:
resp = session.get(f"{board['origin']}{API_BASE}/Positions", timeout=60)
resp.raise_for_status()
payload = resp.json()
# The collection is returned whole; there is no paging envelope to follow.
positions = payload if isinstance(payload, list) else payload.get("positions") or []
if not isinstance(positions, list):
raise RuntimeError("Positions did not return a collection")
return positions
positions = fetch_positions(board)
print(f"{len(positions)} open positions")import re
GUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
REQ_PAIR = re.compile(r"^\d+\|\d+$")
def to_job(board: dict, record: dict) -> dict:
native_id = (record.get("id") or "").strip()
if not (GUID.match(native_id) or REQ_PAIR.match(native_id)):
raise ValueError(f"unrecognised Portico native id: {native_id!r}")
return {
"id": native_id,
"title": record.get("title"),
"description_html": record.get("description"),
"code": record.get("code"),
"job_family": record.get("jobFamily"),
"job_class": record.get("jobClass"),
"job_type": record.get("jobType"),
"bargaining_unit": record.get("groupBargainingUnit"),
"salary_range": record.get("salaryRange"),
"location": record.get("locationDescription"),
"posted_at": record.get("postingStartDate"),
"closes_at": record.get("postingEndDate"),
"listing_url": f"{board['origin']}{BOARD_PATH}/{native_id}",
}
for record in positions[:3]:
job = to_job(board, record)
print(job["title"], "|", job["salary_range"])IMAGE_ONLY = re.compile(r"^\s*(<[^>]+>\s*)*<img[^>]+src=[\"']data:image", re.IGNORECASE)
def collect(board: dict, positions: list[dict]) -> tuple[list[dict], int]:
jobs, rejected = [], 0
for record in positions:
description = record.get("description") or ""
if not description.strip() or IMAGE_ONLY.match(description):
rejected += 1
continue
jobs.append(to_job(board, record))
return jobs, rejected
jobs, rejected = collect(board, positions)
if rejected:
print(f"snapshot incomplete: {rejected} image-only descriptions rejected")
print(f"{len(jobs)} usable jobs")- 1Verify AppConfig tenant, division and product before reading any positions
- 2Treat the Positions collection as complete — it is unpaged by design
- 3Accept both native ID shapes: the numeric req|sreq pair and the lowercase GUID
- 4Skip per-position requests; /Positions/{id} returns 401 and adds nothing
- 5Reject image-only descriptions and report the snapshot as incomplete
- 6Derive removals from absence in the next complete snapshot, never from a redirect
One endpoint. All Tyler Portico jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=tyler portico" \
-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 Portico
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.