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.

Get API access

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

  1. 01Public-Sector Job Aggregation
  2. 02Municipal Hiring Trackers
  3. 03School District Careers Feeds
  4. 04Civic Data Research

Trusted by

  • Bozeman School District
  • City of Helena
  • City of Rapid City
DIY GUIDE

How to scrape Tyler Portico.

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

API type
REST
Difficulty
beginner
Rate limit
No published limit; two requests per tenant, ~250ms apart
Authentication
No auth

Read the tenant from the subdomain

Every Portico board is https://{tenant}.tylerportico.com/tess/citizen/jobs/job-list. The subdomain is the tenant name, and both API calls live under the same origin at /tess/citizen/api.

Step 1: Read the tenant from the subdomain
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'}

Prove the tenant through AppConfig

Call /tess/citizen/api/AppConfig first. Its telemetry global context names the tenant, the division and the product, and all three must match before you trust anything the origin returns. This is what stops a lookalike host from minting an employer.

Step 2: Prove the tenant through AppConfig
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))

Fetch the complete Positions collection

/tess/citizen/api/Positions is the authoritative, unpaged vacancy collection. Every record is already complete — native ID, title, full HTML description, dates, location, classification, salary and bargaining unit — so there is no cursor and no per-job request.

Step 3: Fetch the complete Positions collection
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")

Map records and accept both native ID shapes

Native IDs come in two shapes: a numeric requisition pair rendered as req|sreq, and a lowercase GUID. Accept both, and build the canonical job URL from the board path. Probing /Positions/{id} directly returns 401 and is unnecessary.

Step 4: Map records and accept both native ID shapes
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"])

Reject image-only descriptions instead of storing empty text

A few tenants publish descriptions as embedded base64 images rather than text. Those rows carry no usable description, so count them as rejections and mark the snapshot incomplete rather than emitting a job whose description is an image tag.

Step 5: Reject image-only descriptions instead of storing empty text
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")
Common issues
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.
Best practices
  1. 1Verify AppConfig tenant, division and product before reading any positions
  2. 2Treat the Positions collection as complete — it is unpaged by design
  3. 3Accept both native ID shapes: the numeric req|sreq pair and the lowercase GUID
  4. 4Skip per-position requests; /Positions/{id} returns 401 and adds nothing
  5. 5Reject image-only descriptions and report the snapshot as incomplete
  6. 6Derive removals from absence in the next complete snapshot, never from a redirect
Or skip the complexity

One endpoint. All Tyler Portico jobs. No scraping, no sessions, no maintenance.

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

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