Harri Jobs API.
Pull hospitality and frontline hourly openings from restaurant, hotel, and venue brands through Harri's structured brand-profile JSON, complete with shift timing, position categories, and full descriptions.
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.
What's in every response.
Data fields, real-world applications, and the companies already running on Harri.
- Full HTML job descriptions
- Structured location data
- Position category & code
- Employment type & shift timing
- Brand & parent-brand names
- Publish & closing dates
- 01Hospitality Job Tracking
- 02Hourly & Shift-Work Sourcing
- 03Restaurant & Venue Hiring Monitoring
- 04Frontline Labor Market Analysis
How to scrape Harri.
Step-by-step guide to extracting jobs from Harri-powered career pages—endpoints, authentication, and working code.
from urllib.parse import urlparse
# Reserved harri.com paths that are marketing pages, not tenant boards.
RESERVED = {
"api", "platform", "partners", "pre-hire", "post-hire", "careers",
"scheduling", "engagement", "grocery", "care-sector", "labor-compliance",
"insights-analytics", "employee-records", "request-a-demo",
}
def tenant_from_url(board_url: str) -> str:
# e.g. https://harri.com/Generator-Amsterdam---Kitchen/jobs -> "Generator-Amsterdam---Kitchen"
path = urlparse(board_url).path.strip("/")
tenant = path.split("/")[0] if path else ""
if not tenant or tenant.lower() in {"harri", "www", "harri.com"} or tenant.lower() in RESERVED:
raise ValueError(f"'{tenant}' is a Harri marketing path, not a tenant board")
return tenant # preserve exact case and separators
print(tenant_from_url("https://harri.com/HawksmoorChicago/jobs"))import requests
GATEWAY = "https://gateway.harri.com"
def fetch_profile(tenant: str) -> dict:
url = f"{GATEWAY}/core/api/v1/profile/slug/{tenant}"
resp = requests.get(url, timeout=15)
resp.raise_for_status()
profile = resp.json().get("data") or {}
if str(profile.get("type", "")).lower() != "brand":
raise ValueError(f"profile type '{profile.get('type')}' is not a scrapeable brand")
if profile.get("is_archived"):
raise ValueError("brand profile is archived")
return profile
profile = fetch_profile("cow-careers")
brand_id = profile["id"]HARRI = "https://harri.com"
def fetch_listings(tenant: str, brand_id: int) -> list[dict]:
url = f"{GATEWAY}/core-reader/api/v1/profile/brand/{brand_id}"
resp = requests.get(url, timeout=15)
resp.raise_for_status()
brand = resp.json().get("data") or {}
jobs = []
for envelope in brand.get("Jobs") or []:
job = envelope.get("Job") or {}
job_id = job.get("id")
if not job_id:
continue
position = (job.get("Position") or [{}])[0]
code = (position.get("code") or "").strip()
jobs.append({
"external_id": str(job_id),
"title": job.get("alias_position") or position.get("name"),
"company": (brand.get("name") or "").strip(),
"listing_url": f"{HARRI}/{tenant}/jobs",
"detail_url": f"{HARRI}/{tenant}/job/{job_id}-{code}",
"posted_at": job.get("publish_date") or job.get("created"),
})
return jobsimport re
def strip_html(html: str) -> str:
return re.sub(r"<[^>]+>", " ", html or "").strip()
def fetch_job_detail(job_id: str) -> dict:
url = f"{GATEWAY}/core-reader/api/v1/profile/job/{job_id}"
resp = requests.get(url, timeout=15)
resp.raise_for_status()
data = resp.json().get("data") or {}
job = data.get("job") or {}
status = str(job.get("status") or "").upper()
if job.get("deleted") or status in {"ARCHIVED", "CLOSED"}:
return {"external_id": job_id, "removed": True, "status": status}
locations = []
for loc in job.get("JobLocation") or []:
inner = loc.get("Location") or {}
state = loc.get("State") or inner.get("State") or {}
country = loc.get("Country") or inner.get("Country") or {}
locations.append({
"city": (loc.get("City") or inner.get("City") or {}).get("name"),
"state": state.get("code") or state.get("name"),
"country": country.get("code") or country.get("name"),
"address": inner.get("formatted_address"),
})
return {
"external_id": job_id,
"title": job.get("alias_position") or job.get("title"),
"description": strip_html(job.get("description")),
"company": (data.get("brand") or {}).get("name"),
"locations": locations,
"removed": False,
}import time
def scrape_brand(board_url: str, max_details: int | None = None) -> list[dict]:
tenant = tenant_from_url(board_url)
profile = fetch_profile(tenant)
listings = fetch_listings(tenant, profile["id"])
results = []
for job in listings[: max_details or len(listings)]:
detail = fetch_job_detail(job["external_id"])
if detail.get("removed"):
continue # ARCHIVED / CLOSED / deleted postings are gone
results.append({**job, **detail})
time.sleep(2) # 1 request at a time, ~2s apart
return results
for job in scrape_brand("https://harri.com/cow-careers/jobs", max_details=3):
print(job["title"], "-", job["detail_url"])Reject the reserved marketing paths and only scrape /{tenant}/jobs or /{tenant}/job/{id} URLs. Confirm the profile-slug lookup returns type 'brand' before requesting jobs.
Send the slug exactly as it appears in the URL. Harri tenants preserve mixed case and triple-hyphen separators (e.g. 'Generator-Amsterdam---Kitchen'), so never normalize them.
Use the exact casing from the API: data.Jobs, envelope.Job, job.Position, job.Location on listings, and data.job with job.JobLocation on details.
Check is_archived and require type == 'brand'. Archived or location-type profiles do not expose a scrapeable Jobs list.
Strip HTML from job.description before storing, and drop details whose status is ARCHIVED/CLOSED or whose deleted flag is true. Ignore end_date values in year 9999 — they are a 'no expiry' sentinel, not a closing date.
- 1Send the tenant slug exactly as it appears in the URL — preserve case and '---' separators.
- 2Confirm the profile lookup returns type 'brand' and is not archived before requesting jobs.
- 3Read the PascalCase envelope keys (Jobs, Job, Position, Location, JobLocation) rather than snake_case.
- 4Strip HTML from job descriptions before indexing them.
- 5Throttle to one request at a time with a ~2s delay and back off on 403/429.
- 6Drop ARCHIVED, CLOSED, and deleted jobs, and treat year-9999 end dates as no closing date.
One endpoint. All Harri jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=harri" \
-H "X-Api-Key: YOUR_KEY" Access Harri
job data today.
One API call. Structured data. No scraping infrastructure to build or maintain — start with the free tier and scale as you grow.