All platforms

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.

Get API access
Harri
Live
<3haverage discovery time
1hrefresh interval
Companies using Harri
HawksmoorGeneratorRosa MexicanoThe Landmark LondonKFC UK
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.

What's in every response.

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

Data fields
  • Full HTML job descriptions
  • Structured location data
  • Position category & code
  • Employment type & shift timing
  • Brand & parent-brand names
  • Publish & closing dates
Use cases
  1. 01Hospitality Job Tracking
  2. 02Hourly & Shift-Work Sourcing
  3. 03Restaurant & Venue Hiring Monitoring
  4. 04Frontline Labor Market Analysis
Trusted by
HawksmoorGeneratorRosa MexicanoThe Landmark LondonKFC UK
DIY GUIDE

How to scrape Harri.

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

RESTintermediateNo published limit; keep to ~1 request every 2s (403/429 on abuse)No auth

Resolve the tenant slug from a board URL

Harri boards live at harri.com/{tenant}/jobs, where the tenant is the first path segment. Send it verbatim — Harri slugs keep their original casing and '---' separators, and many first-level paths are marketing pages rather than tenants.

Step 1: Resolve the tenant slug from a board URL
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"))

Look up the brand profile

Resolve the tenant to a numeric brand id via the profile-slug endpoint. Only profiles of type 'brand' carry a jobs list; skip anything archived or of another type.

Step 2: Look up the brand profile
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"]

Fetch the brand's open jobs

The brand reader endpoint returns the current openings under data.Jobs, each wrapped in a { "Job": {...} } envelope. Note the PascalCase keys — Jobs, Job, Position, Location — they are not snake_case. Build the canonical harri.com URLs from the tenant, job id, and position code.

Step 3: Fetch the brand's open jobs
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 jobs

Fetch full job details

The job reader endpoint returns the full HTML description plus structured locations under data.job. Strip the HTML before indexing, and drop postings whose status is ARCHIVED or CLOSED or whose deleted flag is set — those are stale.

Step 4: Fetch full job details
import 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,
    }

Chain the calls and throttle

Stitch profile, listings, and details into one pass. Harri throttles hard, so keep to one request at a time with a ~2s delay and back off on 403/429. Treat a year-9999 end_date as 'no closing date' rather than a real expiry.

Step 5: Chain the calls and throttle
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"])
Common issues
highA bare first-level harri.com path is treated as a tenant, but it is really a marketing page (/platform, /api, /pre-hire, /request-a-demo, etc.).

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.

highThe tenant slug is sent lowercased or with '---' collapsed to '-', producing a 404 from the profile endpoint.

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.

mediumReading job['jobs'] or job['position'] returns nothing because the envelope uses PascalCase keys.

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.

mediumThe profile lookup succeeds but has no jobs, because it resolves to an archived brand or a non-brand profile type.

Check is_archived and require type == 'brand'. Archived or location-type profiles do not expose a scrapeable Jobs list.

mediumDescriptions arrive as raw HTML and stale jobs keep appearing in results.

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.

Best practices
  1. 1Send the tenant slug exactly as it appears in the URL — preserve case and '---' separators.
  2. 2Confirm the profile lookup returns type 'brand' and is not archived before requesting jobs.
  3. 3Read the PascalCase envelope keys (Jobs, Job, Position, Location, JobLocation) rather than snake_case.
  4. 4Strip HTML from job descriptions before indexing them.
  5. 5Throttle to one request at a time with a ~2s delay and back off on 403/429.
  6. 6Drop ARCHIVED, CLOSED, and deleted jobs, and treat year-9999 end dates as no closing date.
Or skip the complexity

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

Get API access
cURL
curl "https://connect.jobo.world/api/jobs?sources=harri" \
  -H "X-Api-Key: YOUR_KEY"
Ready to integrate

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.

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