# Zoho Recruit Jobs API

> Query jobs from Zoho Recruit boards through one API with ?source=zohorecruit — plus how Zoho Recruit's careers page really behaves.

Source: https://hyperjobs.io/ats/zohorecruit

Zoho Recruit is the ATS in Zoho's business software suite, used by small
companies and staffing agencies that already run Zoho for CRM or books. Boards
live at `<tenant>.zohorecruit.com/jobs/Careers`. It's 3,918 postings in this
dataset — a number that moves hourly, so read it from
[`/v1/meta`](/docs/api-reference/meta) rather than trusting this sentence.

There is no public JSON API. There is a careers page with the job list stuffed
into a hidden form input, and there is a detail page with the body inside a
JavaScript string literal. It works. It is not pretty, and it is the most
expensive platform per job we have measured anywhere.

## Try it

```bash
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "source=zohorecruit" \
  --data-urlencode "time_frame=7d" \
  --data-urlencode "limit=2"
```

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "zohorecruit:486372000000512041",
      "title": "Business Development Executive",
      "company": {
        "slug": "northwind-staffing",
        "name": "Northwind Staffing",
        "domain": "northwindstaffing.com",
        "website": "https://northwindstaffing.com",
        "logo": "https://logo.hyperjobs.io/northwindstaffing.com",
        "industry": "Staffing and Recruiting",
        "employee_count": 45
      },
      "location": {
        "remote": false,
        "countries": ["India"],
        "cities": ["Chennai"],
        "regions": ["Tamil Nadu"],
        "raw": ["Chennai, Tamil Nadu, India"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "On-site",
      "seniority": null,
      "salary": {
        "min": null,
        "max": null,
        "currency": null,
        "unit": null,
        "annual_min": null,
        "annual_max": null,
        "summary": "30000"
      },
      "skills": ["business development", "lead generation"],
      "taxonomies": ["Sales"],
      "apply_url": "https://northwind-staffing.zohorecruit.com/jobs/Careers/486372000000512041",
      "url": "https://northwind-staffing.zohorecruit.com/jobs/Careers/486372000000512041",
      "source": "zohorecruit",
      "posted_at": "2026-07-11T00:00:00.000Z",
      "expires_at": null,
      "updated_at": "2026-07-14T09:02:55.000Z"
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

Look at `salary.summary`. It says `"30000"` and the structured fields are all
null, which is not us failing to parse a range — it's the whole of what Zoho
gives. See [below](#salary-is-a-bare-number).

## How Zoho Recruit boards actually work

Two requests per job, and neither is an API call:

```bash
# 1. List — the jobs are inside a hidden <input>, not the markup
curl -L "https://<tenant>.zohorecruit.com/jobs/Careers"

# 2. Detail — the body is inside a JS string literal
curl -L "https://<tenant>.zohorecruit.com/jobs/Careers/<id>/<slug>"
```

`<tenant>` is the subdomain. The slug on the detail URL is decorative:
`/jobs/Careers/<id>/anything` serves the right job.

<Warning title="The TLD varies and isn't derivable">
  Tenants live on **both** `zohorecruit.com` and `zohorecruit.in`, and nothing
  about the tenant name tells you which. You try one and fall back to the other.
</Warning>

### The list is hidden in a form input

The careers page renders its jobs client-side. The data is sitting in the HTML
the whole time, HTML-escaped into the `value` of a hidden input:

```html
<input type="hidden" value="[{&#34;id&#34;:&#34;4863720000005…" id="jobs">
```

Three separate things about that line have cost someone an afternoon:

**`value=` comes BEFORE `id=`.** The regex everyone writes first is
`id="jobs"[\s\S]*?value="…"` and it matches **nothing**, so the board reads as
empty at HTTP 200. You have to anchor on the id and walk *backwards* to the
opening `<input`.

**The escaping is numeric.** Zoho uses `&#34;`, not `&quot;`. An unescaper that
only knows the named entities leaves the payload unparseable as JSON.

**There is a decoy input.** A `moduleMeta` input holds a 67KB **field schema**,
in which `Job_Description` appears as a `"richtext"` label. Grep the page for
field names and you will find that instead of the jobs. Select by `id="jobs"`
and the problem doesn't exist.

### The detail is a JavaScript string literal

The description is only on the detail page, and only inside this:

```js
var jobs = JSON.parse('[{\x22Job_Description\x22:\x22\x3Cp\x3E…');
```

A single-quoted JS literal, `\xNN`-escaped. It needs a real left-to-right
one-pass decoder that consumes `\\` as a unit. The obvious
`.replace(/\\x22/g, '"')` is **wrong**: it rewrites the `x22` that follows an
escaped backslash, and corrupts the payload silently rather than throwing.

### The list is thin, so the N+1 is unavoidable

The list carries `id`, title, city, country, `Remote_Job` and `Job_Type`. It has
**no description, no date and no salary**. All three are detail-only. There is no
"give me the bodies too" parameter, so one request per job is the floor.

## What bites you

### The economics: ~1.75MB per job

This is the headline problem with Zoho Recruit and the reason it sits last in
our refresh order.

Page weight here is **fixed overhead, not job count**. A 4-job board is still
1.73MB. A 74-job board is roughly **130MB**. You are downloading a full styled
careers page per posting and discarding about 90% of it to reach one JSON blob.

<Note title="Why we never proxy this one">
  Because there's no lean JSON endpoint, a residential proxy would be metering
  10–22MB of mostly-CSS per board straight onto the bill. Zoho Recruit scrapes
  fine direct, so proxying it would only ever burn money. It's flagged
  `neverProxy` in our stack for exactly that reason — and if you build this
  yourself, bandwidth is the line item that will surprise you, not requests.
</Note>

The mitigation that actually works is not fetching a job twice: keep the ids you
already have and only pull detail pages for the ones you don't. That's what keeps
a steady-state Zoho rotation affordable, and it's most of why this source is
viable at all.

### Salary is a bare number

`Salary` is a string like `"30000"`. No currency. No period. Is that ₹30,000 a
month or $30,000 a year? The field does not say, and on a platform with heavy
Indian and US tenancy both readings are plausible.

So we keep it as text in `salary.summary` and let the shared parser decide rather
than assert a unit the source never gave. Building a `{min, max, currency}` out
of that number means inventing two thirds of it.

### What Zoho does give you, straight

Not everything here is a trap. `Remote_Job` is a **real boolean**, present on
both list and detail — that's better than the string-typed or absent remote flags
on plenty of larger platforms. `Work_Experience` is a usable band (`1-3 years`,
`5+ years`). `Industry` is populated. `Date_Opened` is already `YYYY-MM-DD`. And
the ids are 18-digit and globally unique, so unlike most per-tenant boards the
key needs no tenant scoping.

## Or use ours

Zoho Recruit is a hidden input with a backwards attribute order and numeric
entities, a JS literal that needs a hand-written decoder, a TLD you have to
guess, an unavoidable N+1, and 1.75MB of bandwidth for every single job you
want.

You get the rows already extracted and decoded, the detail joined to the list,
`Remote_Job` and `Work_Experience` mapped through, the bare salary kept honest as
text instead of dressed up as a range, and the whole bandwidth problem absorbed
on our side. Plus [dedup](/docs/guides/deduplication), expiry by board diffing,
and the [company join](/docs/objects/company).

Zoho Recruit sits in the heavy refresh tier rather than the fast one, and the
cost above is exactly why — see [coverage](/docs/reference/coverage).

Query it with `?source=zohorecruit`.
[Quickstart](/docs/quickstart), then [pricing](/pricing).
