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.

Get API access

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

  1. 01Higher Education Job Aggregation
  2. 02Academic & Faculty Job Boards
  3. 03Research Hiring Trends
  4. 04University Careers Feeds

Trusted by

  • Brown University
  • Carnegie Mellon University
  • Columbia University
DIY GUIDE

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.

API type
REST
Difficulty
intermediate
Rate limit
No published limit; ~100ms between calls and at most 4 concurrent position fetches
Authentication
No auth

Separate a tenant board from a bare position link

Interfolio uses two shapes on apply.interfolio.com. A board is /{tenantId}/positions, where the tenant id is the institution's numeric id. A shared link is just /{positionId} — that number is a position, never a tenant, so it cannot be turned into a board without asking the API.

Step 1: Separate a tenant board from a bare position link
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')

Page the public job board collection

The board's inventory comes from logic.interfolio.com/byc-search/{tenantId}/public_job_boards with a fixed page size of 100 and empty search and unit_name filters. The envelope returns limit, page and total_count; use total_count to compute the page count, and check that each page returns exactly the number of rows pagination requires before trusting it.

Step 2: Page the public job board collection
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))

Build apply URLs from legacy_position_id, not id

Each search row carries two numeric ids and they are not interchangeable. The misleadingly named legacy_position_id is the public position id that builds apply.interfolio.com/{positionId}; the row's own id belongs to the search index and produces a dead link if you use it. Keep the search id only as metadata.

Step 3: Build apply URLs from legacy_position_id, not id
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")

Hydrate a position and read its availability state

The dossier-api position record holds the advert plus the structured state fields is_open, is_closed and active_status. Only 'Open' is a live posting; 'Closed' and 'Expired' are structured evidence the role has ended. The endpoint also answers a stale id with HTTP 200 and an empty object, which means the same thing.

Step 4: Hydrate a position and read its availability state
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"])

Recover the institution behind a shared position link

A bare apply.interfolio.com/{positionId} link names no university. Request the same dossier-api record and take tenant_id from it, but accept the answer only when position_id and landing_page_url both agree with what you asked for; roughly one exported position in eight is stale and returns an empty object with no tenant at all.

Step 5: Recover the institution behind a shared position link
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")
Common issues
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.
Best practices
  1. 1Send limit=100 with empty search and unit_name filters, exactly as the application does
  2. 2Verify page, limit and row count against total_count on every page before trusting the snapshot
  3. 3Build apply URLs from legacy_position_id and keep the search id as metadata only
  4. 4Require position_id, tenant_id and landing_page_url to agree before accepting a position record
  5. 5Read live state from active_status plus is_open/is_closed, not from the HTTP status alone
  6. 6Space calls about 100ms apart and cap concurrent position fetches at four
Or skip the complexity

One endpoint. All Interfolio Faculty Search jobs. No scraping, no sessions, no maintenance.

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

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