All platforms

Zoho Recruit Jobs API.

Pull structured openings from the Zoho business-suite ATS, where every job ships as JSON embedded in the careers-page HTML - no login, no brittle DOM scraping. Get titles, locations, salary ranges and full descriptions in a handful of requests.

Get API access
Zoho Recruit
Live
50K+jobs indexed monthly
<3haverage discovery time
1hrefresh interval
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 Zoho Recruit.

Data fields
  • Job Titles & IDs
  • City, State & Country
  • Salary Min, Max & Currency
  • Job Type & Industry
  • Full Job Descriptions
  • Remote & Date-Opened Flags
Use cases
  1. 01SMB Job Aggregation
  2. 02Salary Benchmarking
  3. 03Multi-Region Talent Sourcing
  4. 04Applicant Pipeline Enrichment
  5. 05Careers-Page Monitoring
DIY GUIDE

How to scrape Zoho Recruit.

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

HTMLintermediateUndocumented; default to ~500ms between requests, low concurrencyNo auth

Build the careers board URL and job-ID matcher

Every Zoho Recruit tenant is a subdomain across three regional TLDs (.com, .in for India, .eu for Europe), and the board always lives at /jobs/Careers. Detail URLs carry the numeric job ID right after that path, which you extract with a single regex.

Step 1: Build the careers board URL and job-ID matcher
import re

# Zoho Recruit boards live on three regional TLDs; the tenant is the subdomain.
JOB_ID_RE = re.compile(r"jobs/Careers/(\d+)", re.I)

def board_url(tenant: str, tld: str = "com") -> str:
    """Careers board URL for a tenant. tld is one of: com | in | eu."""
    return f"https://{tenant}.zohorecruit.{tld}/jobs/Careers"

def extract_job_id(url: str) -> str | None:
    """Job IDs are the numeric segment after /jobs/Careers/."""
    m = JOB_ID_RE.search(url)
    return m.group(1) if m else None

# Usage
print(board_url("flintex"))                 # .com tenant
print(board_url("overturerede", "in"))      # India tenant
print(extract_job_id("https://flintex.zohorecruit.com/jobs/Careers/123456/Senior-Engineer"))

Fetch the board HTML with rate-limit handling

Both listings and details are plain server-rendered HTML pages. Send a browser User-Agent and treat 403/429 as throttling signals (back off), 404 as a removed tenant or job.

Step 2: Fetch the board HTML with rate-limit handling
import requests

HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; JoboBot/1.0)"}

def fetch_html(url: str) -> str:
    """Fetch a Zoho Recruit page. 403/429 mean throttling; back off and retry."""
    resp = requests.get(url, headers=HEADERS, timeout=15)
    if resp.status_code in (403, 429):
        raise RuntimeError(f"Rate limited ({resp.status_code}) - slow down and retry")
    resp.raise_for_status()  # 404 => tenant or job no longer exists
    return resp.text

html = fetch_html(board_url("flintex"))
print(f"Fetched {len(html)} bytes")

Decode the embedded jobs JSON

Zoho does not render openings as list markup - it ships the whole job array as JSON. The authoritative source is the hidden <input id="jobs"> value (HTML-entity encoded); a `var jobs = JSON.parse('...')` inline script is the fallback (JS-string escaped). Decode the right way for each before json.loads.

Step 3: Decode the embedded jobs JSON
import html as html_lib
import json
import re

def _hidden_jobs_json(page: str) -> str | None:
    """Value of <input id="jobs" ...>, which holds HTML-entity-encoded JSON."""
    idx = page.find('id="jobs"')
    if idx == -1:
        idx = page.find("id='jobs'")
    if idx == -1:
        return None
    # Isolate the surrounding <input> tag so we never regex the whole 2 MB page.
    start = page.rfind("<input", 0, idx)
    end = page.find(">", idx)
    if start == -1 or end == -1:
        return None
    tag = page[start:end + 1]
    m = re.search(r'value\s*=\s*"([^"]*)"', tag, re.S)
    return html_lib.unescape(m.group(1)) if m else None

def _script_jobs_json(page: str) -> str | None:
    """Fallback: the var jobs = JSON.parse('...') inline script (JS-escaped)."""
    start_marker, end_marker = "var jobs = JSON.parse('", "');"
    i = page.find(start_marker)
    if i == -1:
        return None
    i += len(start_marker)
    j = page.find(end_marker, i)
    if j == -1:
        return None
    raw = page[i:j].replace('\\/', '/').replace('\\"', '"')
    return re.sub(r'\\u([0-9a-fA-F]{4})', lambda m: chr(int(m.group(1), 16)), raw)

def extract_jobs(page: str) -> list[dict]:
    """Try the hidden input first, then the inline script fallback."""
    for candidate in (_hidden_jobs_json(page), _script_jobs_json(page)):
        if not candidate:
            continue
        try:
            data = json.loads(candidate)
            if data:
                return data
        except json.JSONDecodeError:
            continue
    return []

jobs = extract_jobs(html)
print(f"Found {len(jobs)} openings")

Map job objects and build detail URLs

Each object exposes id, Job_Opening_Name / Posting_Title, City / State / Country, Job_Type, Industry, Date_Opened, and salary fields. Titles fall back across two keys; the detail URL is the board URL plus the job ID and a sanitized title slug.

Step 4: Map job objects and build detail URLs
import re

_TITLE_STRIP = re.compile(r"[^A-Za-z0-9\s-]")

def slugify_title(title: str) -> str:
    """Zoho detail URLs append a sanitized title slug after the job ID."""
    s = _TITLE_STRIP.sub("", title)
    s = re.sub(r"\s+", "-", s)
    s = re.sub(r"-{2,}", "-", s).strip("-")
    return s

def map_job(job: dict, board: str) -> dict:
    job_id = (job.get("id") or "").strip()
    title = (job.get("Job_Opening_Name") or job.get("Posting_Title") or "").strip()
    parts = [job.get(k, "").strip() for k in ("City", "State", "Country") if job.get(k)]
    location = ", ".join(p for p in parts if p) or ("Remote" if job.get("Remote_Job") else None)
    # Free-text "Salary" is the fallback when Min/Max are empty.
    salary = job.get("Min_Salary") or job.get("Max_Salary") or job.get("Salary")
    return {
        "id": job_id,
        "title": title,
        "location": location,
        "job_type": job.get("Job_Type"),
        "industry": job.get("Industry"),
        "date_opened": job.get("Date_Opened"),
        "currency": job.get("Currency"),
        "salary": salary,
        "url": f"{board.rstrip('/')}/{job_id}/{slugify_title(title)}",
    }

board = board_url("flintex")
listings = [map_job(j, board) for j in jobs]
for job in listings[:3]:
    print(f"  - {job['title']} ({job['location']}) -> {job['url']}")

Fetch full descriptions from the detail page

The detail page re-embeds the same jobs JSON; match by ID for the richest record including Job_Description. When a tenant's template omits it, fall back to the on-page description containers the scraper targets.

Step 5: Fetch full descriptions from the detail page
from bs4 import BeautifulSoup

def fetch_job_detail(detail_url: str) -> dict:
    """Prefer the embedded JSON (matched by ID); fall back to on-page selectors."""
    page = fetch_html(detail_url)
    job_id = extract_job_id(detail_url)
    board = detail_url.rsplit("/", 2)[0]

    for job in extract_jobs(page):
        if job.get("id") == job_id:
            return map_job(job, board) | {"description": job.get("Job_Description")}

    # Fallback selectors used when the embedded JSON is absent.
    soup = BeautifulSoup(page, "html.parser")
    title_el = soup.select_one("h1") or soup.select_one("title")
    desc_el = soup.select_one("div.cw-jobdesc, div.job-description, div.cw-editor-content")
    return {
        "url": detail_url,
        "title": title_el.get_text(strip=True) if title_el else None,
        "description": desc_el.get_text("\n", strip=True) if desc_el else None,
    }

details = fetch_job_detail(listings[0]["url"])
print(f"Title: {details['title']}")
Common issues
highJobs are embedded as JSON, not as list markup

Scraping the DOM for job rows returns nothing - Zoho ships the whole opening array as a JSON string inside a hidden <input id="jobs"> value, with a `var jobs = JSON.parse('...')` inline script as the fallback. Read that JSON blob instead of parsing list elements.

mediumThe embedded payload is double-encoded

The hidden-input value is HTML-entity encoded (decode with html.unescape) and the script variant is JS-string escaped (undo \/, \" and \uXXXX). Skip this and json.loads raises before you see a single job.

mediumCompensation hides in a free-text Salary field

Many tenants leave Min_Salary / Max_Salary null and put pay in a free-text Salary string (e.g. "$2.80 + incentives"). Read Salary as a fallback or you silently drop all compensation data.

mediumRequests get blocked with 403 or 429

Zoho throttles aggressive crawling. Cap concurrency (the production scraper uses ~3 concurrent detail fetches with ~500 ms spacing), treat 403 and 429 as rate-limit signals, and back off before retrying.

lowRemote roles carry no City / State / Country

Remote-only postings leave the structured location fields empty. Fall back to the Remote_Job boolean and label the job "Remote" so you do not emit an empty location.

lowBoards span multiple regional TLDs

Tenants live on .zohorecruit.com, .zohorecruit.in (India) and .zohorecruit.eu (Europe). Match all three subdomains - a .com-only assumption misses regional boards.

Best practices
  1. 1Parse the embedded jobs JSON (hidden #jobs input, then the var jobs script) instead of DOM markup
  2. 2HTML-unescape the hidden-input value and JS-unescape the script variant before json.loads
  3. 3Read the free-text Salary field when Min_Salary / Max_Salary are empty
  4. 4Match .com, .in and .eu tenant subdomains, not just .com
  5. 5Keep concurrency low (~3) with ~500ms spacing and treat 403/429 as rate limits
  6. 6Extract the numeric job ID with jobs/Careers/(\d+) and dedupe on it
Or skip the complexity

One endpoint. All Zoho Recruit jobs. No scraping, no sessions, no maintenance.

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

Access Zoho Recruit
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