- criticalApply URLs built from the search row's id 404
- The search collection exposes both id and legacy_position_id, and only the latter is the public application id. Build apply.interfolio.com/{legacy_position_id} and keep the row's own id purely as metadata; swapping them produces links that never resolve.
- highA shared position link names no institution
- apply.interfolio.com/{positionId} carries a position, not a tenant. Fetch logic.interfolio.com/dossier-api/positions/{positionId} and take tenant_id from the record, but only after position_id and landing_page_url both match the request — otherwise leave the job unattributed.
- highA stale position returns HTTP 200 with an empty object
- Interfolio answers a position it no longer publishes with a 200 and {}. Treat an empty object, and an active_status of Closed or Expired, as structured evidence the posting has ended rather than as a transport failure to retry.
- mediumA page returns fewer rows than pagination implies
- Compute the expected row count from total_count and the fixed page size of 100 and compare it with what arrived. A short page means the snapshot is incomplete, so it must not be used to conclude that missing positions have been withdrawn.
Interfolio Faculty Search Jobs API.
Collect academic and faculty vacancies from university Interfolio boards through the same anonymous JSON APIs the apply.interfolio.com application calls, with qualifications and deadlines already structured.
What's in every response.
Data fields, real-world applications, and the companies already running on Interfolio Faculty Search.
Data fields
- Full Position Descriptions
- Qualifications & Application Instructions
- Unit and Department Names
- Open, Close and Deadline Dates
- Explicit Open/Closed/Expired State
- Institution Names
Use cases
- 01Higher Education Job Aggregation
- 02Academic & Faculty Job Boards
- 03Research Hiring Trends
- 04University Careers Feeds
Trusted by
- Brown University
- Carnegie Mellon University
- Columbia University
How to scrape Interfolio Faculty Search.
Step-by-step guide to extracting jobs from Interfolio Faculty Search-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
APPLY_HOST = "apply.interfolio.com"
LOGIC_HOST = "logic.interfolio.com"
def classify(url: str) -> tuple[str, str] | None:
parsed = urlparse(url)
if parsed.scheme != "https" or parsed.netloc.lower() != APPLY_HOST:
return None
parts = parsed.path.strip("/").split("/")
if len(parts) == 2 and parts[0].isdigit() and parts[1] == "positions":
return ("board", parts[0])
if len(parts) == 1 and parts[0].isdigit():
return ("position", parts[0]) # tenant unknown until the API answers
return None
print(classify("https://apply.interfolio.com/10128/positions")) # ('board', '10128')
print(classify("https://apply.interfolio.com/187328")) # ('position', '187328')import math
import requests
PAGE_SIZE = 100
def listings_url(tenant_id: str, page: int) -> str:
return (
f"https://{LOGIC_HOST}/byc-search/{tenant_id}/public_job_boards"
f"?limit={PAGE_SIZE}&page={page}&search=&unit_name=&sort_order=asc&sort_by=name"
)
def fetch_page(session: requests.Session, tenant_id: str, page: int) -> dict:
response = session.get(
listings_url(tenant_id, page),
headers={
"Accept": "application/json",
"Origin": f"https://{APPLY_HOST}",
"Referer": f"https://{APPLY_HOST}/{tenant_id}/positions",
},
timeout=30,
)
response.raise_for_status()
payload = response.json()
total = int(payload.get("total_count", 0))
rows = payload.get("results") or []
if int(payload.get("page", 0)) != page or int(payload.get("limit", 0)) != PAGE_SIZE:
raise RuntimeError("Interfolio contradicted its pagination envelope")
expected = min(PAGE_SIZE, max(total - (page - 1) * PAGE_SIZE, 0))
if len(rows) != expected:
raise RuntimeError(f"Interfolio returned {len(rows)} rows where {expected} were required")
return payload
def fetch_board(session: requests.Session, tenant_id: str) -> tuple[str, list[dict]]:
first = fetch_page(session, tenant_id, 1)
total = int(first.get("total_count", 0))
rows = list(first.get("results") or [])
for page in range(2, math.ceil(total / PAGE_SIZE) + 1):
rows.extend(fetch_page(session, tenant_id, page).get("results") or [])
return first.get("title"), rows
session = requests.Session()
board_title, rows = fetch_board(session, "10128")
print(board_title, len(rows))def map_row(row: dict, tenant_id: str, board_title: str) -> dict | None:
public_id = row.get("legacy_position_id") # the PUBLIC application id
search_id = row.get("id") # search-index id — metadata only
title = (row.get("name") or "").strip()
if not public_id or not search_id or not title:
return None
canonical = f"https://{APPLY_HOST}/{public_id}"
return {
"id": str(public_id),
"search_id": str(search_id),
"title": title,
"listing_url": canonical,
"apply_url": canonical,
"company": board_title,
"unit": row.get("unit_name"),
"location": row.get("location"),
"opened_at": row.get("open_date_raw"),
"closes_at": row.get("close_date_raw"),
"deadline": row.get("deadline"),
"tenant_id": tenant_id,
}
listings = [m for m in (map_row(r, "10128", board_title) for r in rows) if m]
print(f"{len(listings)} positions mapped")def compose(*sections) -> str:
headings = [None, "Qualifications", "Application instructions", "Equal opportunity"]
parts = []
for heading, body in zip(headings, sections):
if not body or not body.strip():
continue
if heading:
parts.append(f"<h2>{heading}</h2>")
parts.append(body.strip())
return "\n".join(parts)
def fetch_position(session: requests.Session, listing: dict) -> dict | None:
response = session.get(
f"https://{LOGIC_HOST}/dossier-api/positions/{listing['id']}",
headers={"Accept": "application/json", "Origin": f"https://{APPLY_HOST}"},
timeout=30,
)
if response.status_code in (404, 410):
return None # canonical removal
response.raise_for_status()
position = response.json() or {}
if not position:
return None # HTTP 200 {} — Interfolio no longer publishes this position
# Prove identity before believing anything else in the record.
if str(position.get("position_id")) != listing["id"] \
or position.get("landing_page_url") != listing["listing_url"] \
or str(position.get("tenant_id")) != listing["tenant_id"]:
raise RuntimeError("Interfolio position contradicted its own identity")
status = (position.get("active_status") or "").strip()
if status in {"Closed", "Expired"} or not position.get("is_open") or position.get("is_closed"):
return None # structured unavailable, not an error
return {
**listing,
"title": position.get("position_name") or listing["title"],
"description_html": compose(
position.get("landing_page_description"),
position.get("qualifications"),
position.get("application_instructions"),
position.get("eeo_statement"),
),
"institution": position.get("institution_condensed") or position.get("institution"),
"salary_text": position.get("salary"),
"job_req_number": position.get("job_req_number"),
"active_status": status,
}
for listing in listings[:3]:
job = fetch_position(session, listing)
if job:
print(job["title"], "-", job["institution"])def resolve_tenant(session: requests.Session, position_id: str) -> str | None:
endpoint = f"https://{LOGIC_HOST}/dossier-api/positions/{position_id}"
response = session.get(endpoint, headers={"Accept": "application/json"}, timeout=30)
if not response.ok:
return None
position = response.json() or {}
canonical = f"https://{APPLY_HOST}/{position_id}"
proved = (
str(position.get("position_id")) == position_id
and position.get("landing_page_url") == canonical
and str(position.get("tenant_id", "")).isdigit()
)
return str(position["tenant_id"]) if proved else None
tenant = resolve_tenant(session, "187328")
print(f"https://{APPLY_HOST}/{tenant}/positions" if tenant else "unresolved")- 1Send limit=100 with empty search and unit_name filters, exactly as the application does
- 2Verify page, limit and row count against total_count on every page before trusting the snapshot
- 3Build apply URLs from legacy_position_id and keep the search id as metadata only
- 4Require position_id, tenant_id and landing_page_url to agree before accepting a position record
- 5Read live state from active_status plus is_open/is_closed, not from the HTTP status alone
- 6Space calls about 100ms apart and cap concurrent position fetches at four
One endpoint. All Interfolio Faculty Search jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=interfolio faculty search" \
-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 Interfolio Faculty Search
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.