- 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.
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.
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
- 01Hourly & Shift Work Aggregation
- 02Restaurant and Retail Job Boards
- 03Multi-Location Franchise Tracking
- 04Local Labour Market Research
How to scrape HigherMe.
Step-by-step guide to extracting jobs from HigherMe-powered career pages—endpoints, authentication, and working code.
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"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")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")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],
})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"))- 1Validate the 13-character hex brand id before issuing a request — nothing else is a HigherMe tenant
- 2Always send the location,location.company,location.brand includes so every row carries its address and employer
- 3Re-check relations.location.relations.brand.id on every row instead of trusting the filter you sent
- 4Keep only rows whose attributes.status is 'published'
- 5Page from the meta block's last_page and fail loudly when current_page disagrees
- 6Space requests about 100ms apart and cap detail fetches at three concurrent to stay clear of 403/429
One endpoint. All HigherMe jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=higherme" \
-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 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.