- 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.
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.
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
- 01K-12 Education Job Aggregation
- 02School District Careers Feeds
- 03Teacher Hiring Research
- 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.
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"])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")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")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
Best practices
- 1Key the employer on the numeric job board id, never on the editable slug
- 2Resolve the district id from the board endpoint before calling the roles search
- 3Filter out internal_only rows and account for them separately
- 4Require the requested board to appear in the role's jobboards array before mapping it
- 5Read active state from status and active_status rather than from the HTTP status
- 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 accesscURL
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
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