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.
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.
- Job Titles & IDs
- City, State & Country
- Salary Min, Max & Currency
- Job Type & Industry
- Full Job Descriptions
- Remote & Date-Opened Flags
- 01SMB Job Aggregation
- 02Salary Benchmarking
- 03Multi-Region Talent Sourcing
- 04Applicant Pipeline Enrichment
- 05Careers-Page Monitoring
How to scrape Zoho Recruit.
Step-by-step guide to extracting jobs from Zoho Recruit-powered career pages—endpoints, authentication, and working code.
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"))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")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")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']}")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']}")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.
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.
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.
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.
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.
Tenants live on .zohorecruit.com, .zohorecruit.in (India) and .zohorecruit.eu (Europe). Match all three subdomains - a .com-only assumption misses regional boards.
- 1Parse the embedded jobs JSON (hidden #jobs input, then the var jobs script) instead of DOM markup
- 2HTML-unescape the hidden-input value and JS-unescape the script variant before json.loads
- 3Read the free-text Salary field when Min_Salary / Max_Salary are empty
- 4Match .com, .in and .eu tenant subdomains, not just .com
- 5Keep concurrency low (~3) with ~500ms spacing and treat 403/429 as rate limits
- 6Extract the numeric job ID with jobs/Careers/(\d+) and dedupe on it
One endpoint. All Zoho Recruit jobs. No scraping, no sessions, no maintenance.
Get API accesscurl "https://connect.jobo.world/api/jobs?sources=zoho recruit" \
-H "X-Api-Key: YOUR_KEY" 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.