Nimble Jobs API.

Read K-12 school and district hiring from Nimble job boards through the anonymous hirenimble.com JSON API, with per-school placements, salary bands and full-time equivalents on every role.

Get API access

What's in every response.

Data fields, real-world applications, and the companies already running on Nimble.

Data fields

  • Full Role Descriptions
  • Benefits Section
  • Per-School Placements
  • Salary Min and Max
  • Full-Time Equivalent
  • Application Deadlines

Use cases

  1. 01K-12 Education Job Aggregation
  2. 02School District Careers Feeds
  3. 03Teacher Hiring Research
  4. 04ATS Data Pipelines

Trusted by

  • Academy of Before and After Care
  • Adelante
  • ALA Coastal
DIY GUIDE

How to scrape Nimble.

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

API type
REST
Difficulty
intermediate
Rate limit
No published limit; ~100ms between requests and at most 3 concurrent role fetches
Authentication
No auth

Read the board slug and prove its numeric id

A Nimble board is app.hirenimble.com/jobs/{slug}, usually with a jobboard_id query parameter. The slug is convenient but the numeric board id is the durable identity, so resolve it from /api/jobboards/{slug}/, which also returns the district id the search endpoint needs.

Step 1: Read the board slug and prove its numeric id
import re
from urllib.parse import urlparse, parse_qs

import requests

HOST = "app.hirenimble.com"
SLUG = re.compile(r"^[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?$", re.IGNORECASE)

def parse_board(url: str) -> str | None:
    parsed = urlparse(url)
    if parsed.netloc.lower() != HOST:
        return None
    parts = parsed.path.strip("/").split("/")
    if len(parts) != 2 or parts[0].lower() != "jobs" or not SLUG.match(parts[1]):
        return None
    return parts[1].lower()

def resolve_board(session: requests.Session, slug: str) -> dict:
    response = session.get(
        f"https://{HOST}/api/jobboards/{slug}/",
        headers={"Accept": "application/json"},
        timeout=30,
    )
    response.raise_for_status()
    board = response.json()
    # The endpoint is keyed by the slug and does not echo it back, so carry the
    # slug yourself and treat the numeric id as the durable board identity.
    if not str(board.get("id", "")).isdigit() or not str(board.get("district_id", "")).isdigit():
        raise RuntimeError("Nimble board endpoint did not prove a numeric board identity")
    return {"id": str(board["id"]), "district_id": str(board["district_id"]),
            "title": board.get("title"), "slug": slug}

session = requests.Session()
slug = parse_board("https://app.hirenimble.com/jobs/adelante?jobboard_id=196")
board = resolve_board(session, slug)
print(board["id"], board["district_id"], board["title"])

Search the board's roles

The complete board comes from /api/search/roles/ filtered by district and jobboard — one request, no cursor. Drop any row whose internal_only flag is true: those are staff-only postings that the public board does not advertise, and counting them inflates the board.

Step 2: Search the board's roles
def fetch_roles(session: requests.Session, board: dict) -> list[dict]:
    response = session.get(
        f"https://{HOST}/api/search/roles/",
        params={"district": board["district_id"], "jobboard": board["id"]},
        headers={"Accept": "application/json"},
        timeout=60,
    )
    response.raise_for_status()
    rows = response.json()
    if not isinstance(rows, list):
        raise RuntimeError("Nimble listings endpoint did not return an array")
    return rows

rows = fetch_roles(session, board)
public_rows = [r for r in rows if not r.get("internal_only")]
print(f"{len(public_rows)} public roles of {len(rows)} returned")

Map each role and its schools

Roles are identified by a numeric id and always link to /jobview/{id}, which is the same URL a candidate sees. Locations come from the row's schools array, so one district role posted at several campuses yields several placements rather than a single vague district-level location.

Step 3: Map each role and its schools
def map_row(row: dict, board: dict) -> dict | None:
    job_id = str(row.get("id") or "")
    title = (row.get("title") or "").strip()
    if not job_id.isdigit() or not title:
        return None

    schools = [
        (s.get("name") or "").strip()
        for s in (row.get("schools") or [])
        if (s.get("name") or "").strip()
    ]
    url = f"https://{HOST}/jobview/{job_id}"
    return {
        "id": job_id,
        "title": title,
        "company": board["title"],
        "listing_url": url,
        "apply_url": url,
        "locations": sorted(set(schools)),
        "closes_at": row.get("deadline"),
        "salary_min": row.get("salary_min"),
        "salary_max": row.get("salary_max"),
        "full_time_equivalent": row.get("fulltime"),
        "jobboard_id": board["id"],
    }

listings = [m for m in (map_row(r, board) for r in public_rows) if m]
print(f"{len(listings)} roles mapped")

Hydrate the role and check it is still active

The per-role endpoint /api/role/{jobId}/?jobview=true returns the description, an optional benefits block, and the boards the role belongs to. Confirm the requested board is in its jobboards array, and read liveness from status and active_status — a role that is not status 20 with active_status 1 has come off the board.

Step 4: Hydrate the role and check it is still active
def fetch_role(session: requests.Session, listing: dict, board: dict) -> dict | None:
    response = session.get(
        f"https://{HOST}/api/role/{listing['id']}/",
        params={"jobview": "true"},
        headers={"Accept": "application/json"},
        timeout=30,
    )
    if response.status_code in (404, 410):
        return None  # canonical removal
    response.raise_for_status()

    role = response.json()
    boards = {str(b.get("id")) for b in (role.get("jobboards") or [])}
    if str(role.get("id")) != listing["id"] or board["id"] not in boards:
        raise RuntimeError("Nimble role did not prove the requested board and job identity")

    # Nimble's own active state: anything else means the role left the board.
    if role.get("status") != 20 or role.get("active_status") != 1:
        return None

    description = (role.get("description") or "").strip()
    benefits = (role.get("benefits") or "").strip()
    if not description:
        raise RuntimeError("Nimble role omitted its description")
    if benefits:
        description = f"{description}\n<h2>Benefits</h2>{benefits}"

    placements = [
        ((r.get("school") or {}).get("location") or (r.get("school") or {}).get("name") or "").strip()
        for r in (role.get("schoolroles") or [])
    ]
    return {
        **listing,
        "title": (role.get("title") or listing["title"]).strip(),
        "description_html": description,
        "posted_at": role.get("date_posted") or role.get("created"),
        "closes_at": role.get("deadline") or listing["closes_at"],
        "locations": sorted({p for p in placements if p}) or listing["locations"],
    }

for listing in listings[:3]:
    job = fetch_role(session, listing, board)
    print(job["title"] if job else f"{listing['id']} is no longer active")
Common issues
highThe board slug is used as the employer identity
Slugs are editable and a /jobview/{id} link carries no slug at all. Resolve /api/jobboards/{slug}/ once and key the employer on the numeric board id it returns, keeping the slug only for building readable board URLs.
highInternal-only roles appear on the public board
The search endpoint returns rows flagged internal_only that the public board does not advertise. Exclude them explicitly and count them separately, so the board total reflects what candidates can actually see.
highA closed role still returns HTTP 200
Nimble keeps serving a role record after it leaves the board. Read liveness from the structured fields — status must be 20 and active_status must be 1 — rather than assuming a successful response means the posting is open.
mediumDistrict roles lose their campus locations
A single role can be posted at several schools. Take locations from the listing row's schools array and from the detail record's schoolroles entries, deduplicating by name, instead of collapsing everything to one district-level location.
Best practices
  1. 1Key the employer on the numeric job board id, never on the editable slug
  2. 2Resolve the district id from the board endpoint before calling the roles search
  3. 3Filter out internal_only rows and account for them separately
  4. 4Require the requested board to appear in the role's jobboards array before mapping it
  5. 5Read active state from status and active_status rather than from the HTTP status
  6. 6Collect locations from schools and schoolroles so multi-campus roles keep every placement
Or skip the complexity

One endpoint. All Nimble jobs. No scraping, no sessions, no maintenance.

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