HigherMe Jobs API.

Read hourly and shift-work vacancies from HigherMe brand boards through the same anonymous JSON API the careers page itself calls, with per-location addresses attached to every posting.

Get API access

What's in every response.

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

Data fields

  • Full Job Descriptions
  • Requirement Bullet Lists
  • Per-Location Addresses
  • Brand & Company Names
  • Full-Time / Part-Time Flags
  • Posted Dates

Use cases

  1. 01Hourly & Shift Work Aggregation
  2. 02Restaurant and Retail Job Boards
  3. 03Multi-Location Franchise Tracking
  4. 04Local Labour Market Research
DIY GUIDE

How to scrape HigherMe.

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

API type
REST
Difficulty
intermediate
Rate limit
No published limit; ~100ms between calls and at most 3 concurrent detail requests, back off on 403/429
Authentication
No auth

Read the brand id out of the board URL

A HigherMe careers page lives at app.higherme.com/careers/{brandId}, where the brand id is a 13-character lowercase hex string. That id is the only tenant key the API accepts, so validate its shape before building any request; anything else is not a HigherMe board.

Step 1: Read the brand id out of the board URL
import re
from urllib.parse import urlparse

PUBLIC_HOST = "app.higherme.com"
API_BASE = "https://api.higherme.com/jobs"
NATIVE_ID = re.compile(r"^[0-9a-f]{13}$", re.IGNORECASE)

def brand_id_from_url(url: str) -> str | None:
    parsed = urlparse(url)
    if parsed.netloc.lower() != PUBLIC_HOST:
        return None
    parts = parsed.path.strip("/").split("/")
    # /careers/{brandId} and /careers/{brandId}/{jobId} both name the brand.
    if len(parts) not in (2, 3) or parts[0].lower() != "careers":
        return None
    return parts[1].lower() if NATIVE_ID.match(parts[1]) else None

board = "https://app.higherme.com/careers/5cb536bea8829"
brand = brand_id_from_url(board)
print(f"Brand id: {brand}")  # "5cb536bea8829"

Page the brand-filtered jobs collection

The public board reads from api.higherme.com/jobs with a brand.id filter and an includes list that joins the location, its company, and its brand into every row. The response carries a meta block with current_page, last_page, per_page, and total — page until current_page reaches last_page rather than guessing.

Step 2: Page the brand-filtered jobs collection
import time
import requests

PAGE_SIZE = 100
INCLUDES = "location,location.company,location.brand,location.externalServiceReferences"

def fetch_brand_jobs(session: requests.Session, brand_id: str) -> list[dict]:
    jobs, page = [], 1
    while True:
        response = session.get(
            API_BASE,
            params={
                "page": page,
                "limit": PAGE_SIZE,
                "includes": INCLUDES,
                "filters[brand.id]": brand_id,
            },
            headers={
                "Accept": "application/json",
                "Referer": f"https://{PUBLIC_HOST}/careers/{brand_id}",
            },
            timeout=30,
        )
        response.raise_for_status()
        payload = response.json()

        meta = payload.get("meta") or {}
        # The meta block is authoritative. A page whose current_page disagrees
        # with the request is a schema change, not an empty board.
        if meta.get("current_page") != page:
            raise RuntimeError("HigherMe returned contradictory pagination metadata")

        jobs.extend(payload.get("data") or [])
        if page >= int(meta.get("last_page", 1)):
            return jobs
        page += 1
        time.sleep(0.1)

session = requests.Session()
rows = fetch_brand_jobs(session, "5cb536bea8829")
print(f"Collected {len(rows)} rows")

Verify the brand and keep only published rows

Each row nests its brand under relations.location.relations.brand.id. Confirm that id equals the brand you asked for before trusting the row, and drop anything whose attributes.status is not 'published' — HigherMe returns unpublished records through the same collection.

Step 3: Verify the brand and keep only published rows
def accepted(job: dict, brand_id: str) -> bool:
    relations = (job.get("relations") or {}).get("location") or {}
    native_brand = ((relations.get("relations") or {}).get("brand") or {}).get("id")
    attributes = job.get("attributes") or {}
    return (
        bool(NATIVE_ID.match(job.get("id") or ""))
        and bool(native_brand)
        and native_brand.lower() == brand_id
        and (attributes.get("status") or "").lower() == "published"
        and bool((attributes.get("title") or "").strip())
    )

published = [job for job in rows if accepted(job, "5cb536bea8829")]
print(f"{len(published)} published of {len(rows)} returned")

Assemble the description and the location

HigherMe splits the advert across attributes.summary, an attributes.requirements string array, and attributes.about — none of them is the whole posting on its own. The address lives on the joined location record, where 'formatted' is the readable line and the street/city/state/zipcode fields are the structured fallback.

Step 4: Assemble the description and the location
import html

def build_description(attributes: dict) -> str:
    parts = []
    if (attributes.get("summary") or "").strip():
        parts.append(attributes["summary"].strip())
    bullets = [r.strip() for r in (attributes.get("requirements") or []) if r and r.strip()]
    if bullets:
        items = "".join(f"<li>{html.escape(b)}</li>" for b in bullets)
        parts.append(f"<h2>Requirements</h2><ul>{items}</ul>")
    if (attributes.get("about") or "").strip():
        parts.append(f"<h2>About</h2>{attributes['about'].strip()}")
    return "\n".join(parts)

def build_location(job: dict) -> dict:
    location = ((job.get("relations") or {}).get("location") or {})
    attrs = location.get("attributes") or {}
    state = attrs.get("state") or {}
    country = attrs.get("country") or {}
    text = attrs.get("formatted") or ", ".join(
        v for v in [attrs.get("street"), attrs.get("city"),
                    state.get("short") or state.get("name"), attrs.get("zipcode")]
        if v
    )
    return {
        "text": text or None,
        "city": attrs.get("city"),
        "state": state.get("short") or state.get("name"),
        "country": country.get("short") or country.get("name"),
        "postal_code": attrs.get("zipcode"),
    }

for job in published[:3]:
    attributes = job["attributes"]
    print({
        "id": job["id"],
        "title": attributes["title"].strip(),
        "url": f"https://{PUBLIC_HOST}/jobs/{job['id']}",
        "full_time": attributes.get("full_time"),
        "part_time": attributes.get("part_time"),
        "posted_at": attributes.get("date_posted") or attributes.get("updated_at"),
        "location": build_location(job),
        "description_html": build_description(attributes)[:200],
    })

Resolve a tenantless /jobs/{id} link back to its brand

Shared HigherMe links use app.higherme.com/jobs/{jobId} and name no brand at all. Fetch the single-record endpoint with the same includes list and read the brand id off the joined location — that is the only safe way to attribute an orphan link to an employer.

Step 5: Resolve a tenantless /jobs/{id} link back to its brand
def resolve_brand_for_job(session: requests.Session, job_id: str) -> str | None:
    response = session.get(
        f"{API_BASE}/{job_id}",
        params={"includes": INCLUDES},
        headers={"Accept": "application/json"},
        timeout=30,
    )
    if response.status_code in (404, 410):
        return None  # canonical removal: the job no longer exists
    response.raise_for_status()

    job = (response.json() or {}).get("data")
    if not job or (job.get("id") or "").lower() != job_id.lower():
        return None
    location = (job.get("relations") or {}).get("location") or {}
    brand = ((location.get("relations") or {}).get("brand") or {}).get("id")
    return brand.lower() if brand and NATIVE_ID.match(brand) else None

print(resolve_brand_for_job(session, "6a2716a5df85a"))
Common issues
highA /jobs/{id} link carries no employer at all
Shared HigherMe URLs use the tenantless /jobs/{jobId} shape, which names a posting but never a brand. Fetch api.higherme.com/jobs/{jobId} with the location.brand include and take the brand id from the joined record; never guess the employer from the page title or the slug.
highUnpublished jobs come back in the same collection
The brand-filtered collection returns rows whose attributes.status is not 'published'. Filter on that status explicitly rather than assuming everything returned is live, otherwise closed roles resurface as active postings on every run.
mediumThe description looks truncated or empty
No single field holds the whole advert. Concatenate attributes.summary, the attributes.requirements string array, and attributes.about; a row that has only requirements will otherwise arrive with an apparently blank description.
mediumPagination stops early or loops
Drive the loop from the meta block, not from the row count. Stop when current_page reaches last_page, and treat a response whose current_page disagrees with the page you requested, or whose per_page exceeds the limit you sent, as a schema change rather than as the end of the board.
Best practices
  1. 1Validate the 13-character hex brand id before issuing a request — nothing else is a HigherMe tenant
  2. 2Always send the location,location.company,location.brand includes so every row carries its address and employer
  3. 3Re-check relations.location.relations.brand.id on every row instead of trusting the filter you sent
  4. 4Keep only rows whose attributes.status is 'published'
  5. 5Page from the meta block's last_page and fail loudly when current_page disagrees
  6. 6Space requests about 100ms apart and cap detail fetches at three concurrent to stay clear of 403/429
Or skip the complexity

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

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