# Careers Page Scraper (Greenhouse, Lever, Ashby, Workday & more) (`zinin/careers-page-scraper`) Actor

Job postings by company domain: give it a bare domain and it finds that company's ATS itself across six platforms — Greenhouse, Lever, Ashby, Workable, Rippling, Workday — then returns the open roles. Says so honestly when it cannot identify one, instead of reporting a false "no jobs".

- **URL**: https://apify.com/zinin/careers-page-scraper.md
- **Developed by:** [Tim Zinin](https://apify.com/zinin) (community)
- **Categories:** Jobs, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.40 / 1,000 job posting founds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Careers Page Scraper

Turn a company **domain** into its open jobs. This is a company-domain-to-jobs lookup:
give it a bare domain and it finds the company's ATS itself — Greenhouse, Lever, Ashby,
Workable, Rippling or Workday — instead of making you already know which one to ask
(`greenhouse:figma`, `lever:spotify`). It reads the company's own careers page for an
ATS link first; when that page doesn't name one, it verifies a guessed slug against
real, non-zero postings before trusting it — plain HTTP 200 on a guess is never enough
on its own (see the empty-board trap in FAQ). Point it at `figma.com`, `datadoghq.com`,
or a domain on Workday like `bankofamerica.com`, and get that company's open roles back
— honestly capped and honestly labeled on the rare board that's deeper than the default
read.

### What you get

- **Domain in, jobs out — no ATS token guessing.** Every other jobs actor in this niche
  (ours included — `job-postings-aggregator`, `company-hiring-radar`,
  `intent-signal-aggregator`) takes `provider:slug` on input, so you have to already know
  which system a company uses. This one only needs the domain — that's job postings by
  company domain, nothing else required.
- **Six ATS platforms auto-detected, not three.** Greenhouse, Lever and Ashby, plus
  Workable, Rippling and Workday — added because all three are real, live, keyless
  applicant-tracking-system APIs the same way the original three are, each proven with
  a real non-empty board (not from documentation): a Greenhouse jobs scraper, a Lever
  postings API reader, an Ashby jobs scraper, a Workable reader, a Rippling reader and a
  Workday jobs scraper, all in the same run. Three more platforms — SmartRecruiters,
  Recruitee, Personio — are deliberately left out: their own `robots.txt` disallows
  automated access outright (`Disallow: /`, checked live). That's a line this Actor
  doesn't cross, not a gap we missed — see FAQ for the exact evidence.
- **Two-step resolution, first-party evidence preferred.** First checks the company's
  own careers page (`/careers`, `/jobs`, `/about/careers`), then its homepage as a
  fallback. If that page explicitly names a separate careers host, the Actor follows
  exactly one robots-compliant hop and checks it for an ATS link. Only falls
  back to guessing a slug from the domain when that fails, and only for the five
  platforms where a guess is even meaningful — Workday's tenant can't be guessed from a
  domain (see FAQ for why bankofamerica.com's real tenant isn't "bankofamerica" at
  all), so a Workday board is only ever found through its own careers-page link.
- **Four honest outcomes, not one "not found" bucket.** Resolved with open jobs (paid
  rows), resolved with a confirmed zero right now (free), could-not-identify-the-ATS
  (free — explicitly NOT "no jobs"), and source unreachable (free — explicitly NOT "no
  ATS"). Most tools in this space collapse all of this into a single false negative; the
  full breakdown is in **Output** below.
- **Honest about depth, not just about zero.** Greenhouse, Lever, Ashby and Workable
  hand back their whole board in one call — no limit applies. Workday and Rippling are
  paginated, so a `maxJobsPerCompany` input (default 500, up to 5,000) caps how deep
  this Actor reads per company — and when a board runs deeper than that cap, a free
  notice row says so explicitly instead of quietly stopping. Nothing here is sold to you
  as "every posting" when it isn't.
- **You don't pay for a board this Actor can't vouch for.** A domain-slug match (Method B)
  is only ever billed once this Actor checks that board's own declared owner against your
  domain. Real example: `atlas.com` resolves to a real, live Ashby board with real, open
  roles, but that board's own page declares its company website as `atlascard.com`, not
  `atlas.com` — a different company most likely owns it. Every job from it still lands in
  your dataset in full; none of it is billed. See **Pricing** and the `atlas.com` row in
  **Output** below for exactly how this works, and its one honest limitation.
- **Protects you from a garbage list, not the other way around.** If fourteen domains in
  a row come back unresolved and this run hasn't resolved a single company yet, it stops
  early instead of grinding through the rest of a list that was never going to work — one
  free `run-notice` row tells you how many domains were never even attempted. A single
  real resolve at any point permanently lifts this guard for the rest of the run, so a
  mixed list is never at risk — see FAQ for exactly when this fires and why 14.
- **Runs standalone, or as an Integration** bolted straight onto any company-list scraper
  you already run — see "Add it as an Integration" below.
- Runs on Apify: schedule it, monitor it, call it from the API or the MCP server, export
  to JSON/CSV/Excel, or push results straight into your own pipeline.

### How to run it

1. Click **Try for free** — no card needed on the free plan.
2. Paste company domains into **Company domains**, one per entry — bare domains or full
   URLs both work (`figma.com` or `https://www.figma.com/careers` normalize the same
   way).
3. Optional: raise **Max jobs per company** above the default `500` if you expect a
   Workday or Rippling board deeper than that and want the rest — see **Input** below.
4. Press **Start**. Job rows and free status rows land in the same dataset — read them in
   the UI, pull them from the API, or have a webhook push them onward.

#### Add it as an Integration

Open the scraper Actor you already run (anything that outputs a dataset of company
domains — a directory scraper, a lead-gen tool, your own company list), go to its
**Integrations** tab, click **Add integration**, and pick **Careers Page Scraper**.
Leave **Dataset ID** empty in the integration's prefilled input — this Actor reads it
automatically from the triggering run (`payload.resource.defaultDatasetId`). From then
on, every successful run of that scraper feeds straight into this one — hiring signals
by domain, generated automatically every time your upstream list updates. You can also
point it manually at any dataset through the **Dataset ID** field — picking it there
(not just pasting the raw ID) is what grants this run's token read access to it. Domains
are pulled from whichever field in each row looks like one (`companyDomain`, `domain`,
`website`, `url`, and a handful of other common names), so it doesn't need the upstream
scraper to match any fixed schema.

### Pricing

Pay-per-event: **$0.01 per run start + $0.004 per job posting found** — that's $4.00 per
1,000 postings. No monthly seat, no minimum. Both prices step down with your Apify
account tier, to $0.008 / $0.0032 on DIAMOND (up to 20% off, the standard ladder every
Actor in this fleet runs on).

**Every free row stays free** — a company whose ATS could not be identified, a confirmed
empty board, an unreachable source, an invalid domain, the notice that a board ran deeper
than your depth limit, and the notice that a hopeless list was cut short.

**One more free case, added 04.08.2026: a delivered job can be free too.** When a
company's ATS is found via the domain-slug fallback (Method B — see FAQ) rather than a
link on the company's own careers page, this Actor checks that board's own declared owner
against the domain you asked for before billing it. Confirmed match — billed, same as
always. Confirmed mismatch, or the check itself could not be run — the job still ships to
you in full, just never billed. Real example: `atlas.com` resolves to a genuine, non-empty
Ashby board, but that board's own page declares its company website as `atlascard.com` —
a different company most likely owns it, so every job from it is free. This never applies
to a company found via Method A (its own careers page already named this board — nothing
left to check), and never applies to a `resolved-zero`/`unresolved`/`source-error`/
`invalid-domain` row, which were already free before this. See **Output** below for the
real `atlas.com` row, and its field table for what `attributionConfirmed` and
`attributionNote` mean.

About that run-start fee: most Actors in this niche charge nothing to start. This one
charges a cent, and here is the honest reason — because it refuses to bill you for
domains it could not resolve, a list where *nothing* resolves is work this Actor does for
free. The cent covers that case, and the early-abort guard above keeps it from ever being
a large one. You are never charged for an answer this Actor could not find, and — as of
the case above — never for a job it delivered but could not confirm actually belongs to
the domain you asked for.

### Input

| Field | Required | What it does |
|---|---|---|
| `domains` | no | Company domains to resolve — bare domain or full URL, either works. Takes priority over `datasetId` when both are set. Up to 100 per run. |
| `datasetId` | no | Another Actor's dataset of companies to resolve instead, chosen through the dataset picker (not a plain text ID) — picking it there is what grants this run read access to it. Filled in automatically when this Actor runs as an Integration — leave it empty in that setup. |
| `maxConcurrency` | no | How many domains to resolve in parallel, 1-20. Default `5`. |
| `maxJobsPerCompany` | no | Depth limit for paginated sources only (Workday, Rippling) — how many open roles to read per company before stopping. Doesn't apply to Greenhouse, Lever, Ashby or Workable, which already return their whole board in one call. Default `500`, up to `5000`. Raising it costs real run time — Workday averages roughly 1.1 seconds per page of 20 roles, so a full read of a large tenant can take several minutes. When the cap is hit, a free `resolved-truncated` row says so and tells you to raise it. |

```json
{
    "domains": ["figma.com", "ramp.com", "datadoghq.com"]
}
```

### Output

One dataset row per open job for a resolved company, plus one free status row for every
domain that didn't produce any job rows, plus — new for the six-platform build — one
extra free notice row on a domain whose board was deeper than this run read.

A job row (Figma resolves on Greenhouse via its own careers page — real row from a real
run of the exact input above):

```json
{
    "companyDomain": "figma.com",
    "provider": "greenhouse",
    "providerSlug": "figma",
    "jobId": "5615966004",
    "title": "Enterprise Solutions Consultant (Bengaluru, India)",
    "location": "Bengaluru, India",
    "department": null,
    "isRemote": false,
    "url": "https://boards.greenhouse.io/figma/jobs/5615966004?gh_jid=5615966004",
    "publishedAt": "2025-08-12T13:15:50.000Z",
    "resolvedVia": "careers-page",
    "found": true,
    "scrapedAt": "2026-08-04T11:12:33.399Z"
}
```

A job row resolved by the domain-slug fallback, not a careers-page link (`datadoghq.com`
has no ATS link on its own site, but `datadoghq` → `datadog` is a real, live Greenhouse
board once the corporate suffix is stripped):

```json
{
    "companyDomain": "datadoghq.com",
    "provider": "greenhouse",
    "providerSlug": "datadog",
    "jobId": "7984184",
    "title": "Senior Partner Manager - Channels",
    "location": "Tokyo, Japan",
    "department": null,
    "isRemote": false,
    "url": "https://careers.datadoghq.com/detail/7984184/?gh_jid=7984184",
    "publishedAt": "2026-06-08T00:54:12.000Z",
    "resolvedVia": "slug-guess",
    "found": true,
    "scrapedAt": "2026-08-04T11:12:36.259Z"
}
```

The honest-unresolved case — this is the same run, one free row for a domain where every
attempt reached a real server but never matched (this actor's whole differentiator: it
says so instead of pretending there are no jobs):

```json
{
    "companyDomain": "deel.com",
    "provider": null,
    "providerSlug": null,
    "resolvedVia": null,
    "resolutionStatus": "unresolved",
    "jobCount": null,
    "found": false,
    "error": "could not identify this company's applicant-tracking system (checked its careers page for a Greenhouse/Lever/Ashby/Workable/Rippling/Workday link, then guessed slugs from the domain for Greenhouse/Lever/Ashby/Workable/Rippling) — this is NOT a confirmed \"no open jobs\", just \"we could not determine their ATS\"",
    "scrapedAt": "2026-08-04T13:50:00.481Z"
}
```

A confirmed-empty board looks the same shape, illustrative (not a live capture — the
distinction that matters is `resolutionStatus: "resolved-zero"` plus `jobCount: 0`,
instead of `"unresolved"` with `jobCount: null`):

```json
{
    "companyDomain": "example-hiring-freeze.com",
    "provider": "lever",
    "providerSlug": "example-hiring-freeze",
    "resolvedVia": "careers-page",
    "resolutionStatus": "resolved-zero",
    "jobCount": 0,
    "found": false,
    "error": null,
    "scrapedAt": "2026-08-04T11:20:00.000Z"
}
```

The honest-truncation case — a real row from a real run on `bankofamerica.com`, with
`maxJobsPerCompany` deliberately set low (`20`, well below the default `500`) to trigger
it on demand: the dataset held exactly 21 rows for this domain — 20 paid job rows,
billed and confirmed (`20 billed job row(s)` in the run log), plus this one free notice
row after them. Its own careers page names the real ATS tenant — `ghr`, nothing
derivable from the domain "bankofamerica" itself, which is exactly why Workday is never
guessed, only found this way. At a much higher limit the same domain keeps going — a
live check at `maxJobsPerCompany=3000` read the entire board, roughly two thousand
postings (a live board, so the exact count moves — one closed between two checks run an
hour apart), in 123.6 seconds, with no truncation row at all:

```json
{
    "companyDomain": "bankofamerica.com",
    "provider": "workday",
    "providerSlug": "ghr.wd1.myworkdayjobs.com/lateral-us",
    "resolvedVia": "careers-page",
    "resolutionStatus": "resolved-truncated",
    "jobCount": 20,
    "declaredTotal": null,
    "found": false,
    "error": "job list is incomplete: delivered 20 row(s) here, but the source does not report a trustworthy total count for this company (Workday's own \"total\" field has been proven live to be inaccurate, see SPEC.md) — there may be more open roles than the 20 delivered here. This Actor's pagination depth limit (maxJobsPerCompany=20) was reached. Raise maxJobsPerCompany on input (up to 5000) to read deeper.",
    "scrapedAt": "2026-08-04T13:23:45.173Z"
}
```

#### Confirming a board actually belongs to the domain you asked for (Method B only)

A domain-slug match (Method B) is real and non-empty the moment it's found, but that alone
doesn't prove the board belongs to the domain you asked for — three live boards do exist
under a guessed slug for a *different* company (see FAQ). So before billing a Method-B
job, this Actor checks the board's own declared owner against your domain. Confirmed —
real row, `airtable.com`, billed:

```json
{
    "companyDomain": "airtable.com",
    "provider": "greenhouse",
    "providerSlug": "airtable",
    "jobId": "8391589002",
    "title": "Account Executive, SLED",
    "location": "Austin, TX; Remote - US",
    "department": null,
    "isRemote": true,
    "url": "https://job-boards.greenhouse.io/airtable/jobs/8391589002",
    "publishedAt": "2026-01-26T22:00:52.000Z",
    "resolvedVia": "slug-guess",
    "attributionConfirmed": true,
    "attributionNote": "Greenhouse's job board is named \"Airtable\", which matches the requested domain airtable.com.",
    "found": true,
    "scrapedAt": "2026-08-04T16:47:33.360Z"
}
```

Not confirmed — real row, `atlas.com`, same run, delivered but free (`found: false` on a
row with a real title, real `jobId`, real `url` — never withheld, only unbilled):

```json
{
    "companyDomain": "atlas.com",
    "provider": "ashby",
    "providerSlug": "atlas",
    "jobId": "dae00fcb-35a5-4559-8114-7553138b3ea3",
    "title": "Founding Applied AI Engineer",
    "location": "San Francisco",
    "department": "Engineering",
    "isRemote": false,
    "url": "https://jobs.ashbyhq.com/atlas/dae00fcb-35a5-4559-8114-7553138b3ea3",
    "publishedAt": "2026-02-24T18:52:51.098Z",
    "resolvedVia": "slug-guess",
    "attributionConfirmed": false,
    "attributionNote": "Ashby's job board declares its own company website as atlascard.com, which does NOT match the requested domain atlas.com — this board most likely belongs to a different company.",
    "found": false,
    "scrapedAt": "2026-08-04T16:47:35.898Z"
}
```

A third case, just as free but a different fact: `attributionConfirmed: null` means this
Actor could not tell either way — either the check itself could not be run at all (the
provider's owner-signal request timed out, or — for Workday — this check never applies in
the first place, see FAQ), or the board's declared name is inconclusive rather than
confidently wrong. That second flavor is real and live: `chime.com` resolves to its own,
genuine Greenhouse board — the company never changed its name — but that board's own
declared name is legally "Chime Financial, Inc", not "Chime":

```json
{
    "companyDomain": "chime.com",
    "provider": "greenhouse",
    "providerSlug": "chime",
    "jobId": "8609153002",
    "title": "Analyst, Investor Relations",
    "location": "San Francisco, CA, USA",
    "department": null,
    "isRemote": false,
    "url": "https://boards.greenhouse.io/chime/jobs/8609153002?gh_jid=8609153002",
    "publishedAt": "2026-07-22T18:36:41.000Z",
    "resolvedVia": "slug-guess",
    "attributionConfirmed": null,
    "attributionNote": "Greenhouse's job board is named \"Chime Financial, Inc\", which STARTS WITH the requested domain chime.com but adds a real word this Actor does not strip (a business descriptor, not legal boilerplate) — this looks like it could be your company, but a name alone does not prove it: Greenhouse does not publish this board's owner website, and a genuinely unrelated company with a similar name would read exactly the same way.",
    "found": false,
    "scrapedAt": "2026-08-04T17:31:05.538Z"
}
```

This is deliberately `null`, not `false` — "Chime Financial, Inc" reads as a genuine match
to a human who knows the company, but this Actor cannot tell that apart from a genuinely
different company that merely shares a leading word: `pulse.com`'s own real Greenhouse
board is named "Pulse Healthcare" — the identical shape ("Pulse" + a business descriptor)
— and is a real, unrelated company (the actual Pulse this Actor's own SPEC.md example
refers to is `runpulse.com`). By name alone, these two cases cannot be told apart, so
neither is confidently confirmed and neither is confidently rejected. All three cases —
confirmed, confidently rejected, and inconclusive — are covered live and in tests; see
FAQ below for what this check can and cannot prove.

| Field | Meaning |
|---|---|
| `companyDomain` | The normalized domain (`null` on a free run-level notice row). |
| `provider` | `greenhouse`, `lever`, `ashby`, `workable`, `rippling` or `workday` once resolved; `null` otherwise. |
| `providerSlug` | The ATS's own slug for this company. Case matters for Lever, Workable and Rippling; doesn't for Greenhouse or Ashby. For Workday the slug is composite (tenant + site) and the tenant is always lowercased. |
| `resolvedVia` | `careers-page` (first-party link found on the company's own site) or `slug-guess` (fallback, only ever set together with a confirmed non-zero job match, and never for Workday — see FAQ). `null` when never resolved. |
| `attributionConfirmed` | Only meaningful on a job row. `true` on every Method A row (the company's own careers page already named this board — nothing left to check, always billed) and on a Method B row whose declared owner matches your domain (billed). `false` on a Method B row whose declared owner names someone else (free — the `atlas.com` row above). `null` on a Method B row where the check could not be run at all (free — a different fact from `false`, never conflated with it). `null` on every non-job row too, where it simply doesn't apply. |
| `attributionNote` | Human-readable explanation of the value above — what was compared to what. `null` wherever `attributionConfirmed` is `null` on a non-job row. |
| `found` | `true` marks a billed row, `false` a free one — that was always true, but it no longer implies "job row vs. status row." Before 04.08.2026, `found: true` meant "job row" and only that; as of the case above, a genuine job row (real `title`/`url`/`jobId`) can carry `found: false` when it was delivered free-by-policy — see `atlas.com` above. `resolved-truncated`, `resolved-zero`, `unresolved`, `source-error`, `invalid-domain`, `error` and `run-notice` all still carry `found: false` as before. In short: `found: true` and "this row cost me money" are still the same statement — just no longer a statement about row *shape*. |
| `scrapedAt` | When this Actor produced the row. |
| `jobId` / `title` / `location` / `department` / `isRemote` / `url` / `publishedAt` | Present only on job rows — one open posting, normalized the same way regardless of which ATS it came from. |
| `resolutionStatus` | Present only on status/notice rows: `resolved-zero` (confirmed empty board), `unresolved` (tried every path, never matched — NOT "no jobs"), `source-error` (never even reached a server — NOT "no ATS"), `invalid-domain` (input wasn't a usable domain, no network call spent on it), `resolved-truncated` (the board was read only in part — either the run hit your `maxJobsPerCompany` depth limit, in which case the row tells you to raise it, or a later page of the source's own API failed mid-read, in which case raising the limit won't help and a retry will), `error` (an unexpected exception while processing this one domain), or `run-notice` (a free, run-level explanation, `companyDomain: null` — e.g. the run's spend cap was hit, or the run stopped early on an unresolved streak, see FAQ). |
| `jobCount` | `0` on a confirmed `resolved-zero` row. On `resolved-truncated`, how many job rows this run actually delivered for that domain (not the true total). `null` everywhere else, including job rows, which don't carry this field. |
| `declaredTotal` | Only on `resolved-truncated` rows: the source's own claimed total, when it can be trusted — `null` for Workday always (its total is proven unreliable, see FAQ), a real number for Rippling. |
| `error` | Human-readable reason on a status/notice row; `null` on `resolved-zero`; absent on job rows. |
| `summary` | Present only on a `run-notice` row — what happened at the run level and what to do about it. |

### FAQ / Limitations

**Does it need an API key, a login, or a browser?** No — Greenhouse, Lever, Ashby and
Workable all publish their job boards as public, keyless JSON APIs; Rippling and Workday
are the same, just paginated. The only other requests this Actor makes are plain GETs to
the company's own careers page to look for a link. No proxies, no browser, no scraping
of JS-rendered content.

**Why did `deel.com` come back `unresolved` instead of a job list?** Deel isn't on any of
the six platforms this Actor checks, under any slug it could find or guess — checked
live. `unresolved` is this Actor's honest way of saying "we don't know", not "they have
no openings". If you know the real ATS, `job-postings-aggregator`'s `provider:slug`
input still works for a company like this.

**Why would a domain that's definitely on Workday still come back `unresolved`?** Because
finding a Workday tenant by guessing the domain doesn't work reliably enough to trust —
proven live on bankofamerica.com, whose real tenant is `ghr` (its internal HR system
name), nothing you'd ever derive from "bankofamerica". So Workday is only ever resolved
through its own careers-page link (Method A), never through a domain-slug guess (Method
B) — that's a deliberate exclusion, not a bug. A large public company can still come back
`unresolved` if its careers page happens not to link its ATS board directly, even though
the Workday board itself is live and working — this Actor would rather say "we couldn't
find the link" than fabricate a tenant guess for a system where guesses aren't safe.

**What's the empty-board trap mentioned above?** Ashby, Workable and Rippling all return
a plain HTTP 200 for a *registered but currently empty or unrelated* board (Ashby:
`api.ashbyhq.com/posting-api/job-board/deel` → `{"jobs":[]}`; the same shape shows up on
several other real, unrelated companies on Workable and Rippling) — that 200 alone does
not prove the board actually belongs to the company whose domain you guessed the slug
from; it could just as easily be a different organization that happens to share the same
short slug. This Actor only accepts a domain-slug-guess match when the response contains
real, non-zero postings. A genuine zero-job company is still findable — just via its own
careers-page link (first-party evidence), not a coincidental guess.

**How does the ownership check work, and what can't it prove?** Once Method B already has
a real, non-empty match, this Actor takes one more step before billing for it: it reads
the board's own declared owner and compares it to the domain you asked for. On Ashby
that's a real second domain (`publicWebsite`) — `atlas` declares `atlascard.com`, which is
how this Actor knows `atlas.com` most likely isn't the real owner. On Greenhouse, Lever,
Workable and Rippling, the board only publishes a company *name*, not a second domain — so
on four of these five providers, a coincidental name match (an unrelated real company that
happens to share a name with the one you asked about) could in principle still pass; there
is no stronger evidence those four APIs publish, and this Actor doesn't pretend otherwise.
A genuine rebrand can also read as a false rejection in the honest direction — a company
whose careers-page title changed but whose domain didn't will fail this name comparison
and ship free rather than risk confirming the wrong owner. This check never runs on Method
A (the company's own site already named this board — nothing left to check) and never
runs on Workday (never guessed by domain to begin with, see above).

**Why does `chime.com` come back `attributionConfirmed: null` instead of `true`, when it's
genuinely Chime's own board?** Because the board's declared name is legally "Chime
Financial, Inc" — this Actor's name comparison strips real legal suffixes (`Inc`, `Ltd`,
`GmbH`...) but deliberately does NOT strip ordinary business words like "Financial" or
"Technology" (adding more words to that list risks eventually swallowing a real brand
word too). "Chime Financial, Inc" starts with the requested domain's own label plus a real
word this Actor won't guess past, so it lands as inconclusive (`null`), never a confident
match (`true`) and never a confident rejection (`false`) either. The reason it can't just
become `true`: `pulse.com`'s own real Greenhouse board is named "Pulse Healthcare" — the
identical shape — and is a genuinely different, unrelated company. By name alone, a
"Brand + business word" board cannot be told apart from a same-shaped board that belongs
to someone else, so this Actor reports neither with more confidence than it has. You still
get every job from a `null` board in full — see Pricing above.

**Why is a Workday board slower to read than the other five?** Measured live, not
estimated: `figma.com` (Greenhouse, 177 jobs) took 3.0 seconds; `flosum.com` (Workable,
resolved by slug-guess, 151 jobs) took 3.5 seconds; `bankofamerica.com` (Workday) took
9.5 seconds at a 100-job depth and 123.6 seconds to read its entire board (roughly two
thousand postings) at a raised `maxJobsPerCompany=3000`; `target.com` (Workday, 496
jobs) took 47.1 seconds. Workday paginates — roughly 1.1 seconds per page of 20 roles —
while Greenhouse, Lever, Ashby and Workable return their whole board in a single call.
Expect a large Workday company to run noticeably slower than the other five platforms,
especially at a raised `maxJobsPerCompany`.

**Why does `declaredTotal` come back `null` on a truncated Workday board instead of the
real count?** Because Workday's own `total` field is proven unreliable — checked live: it
can stay pinned at a suspiciously round number (observed stuck at exactly 2,000 on one
real tenant) no matter how far past that point real, distinct postings keep coming back.
Printing that number to you as fact would be worse than admitting we don't know it, so
this Actor never does — Rippling's own total, by contrast, checked out consistent across
pages and is reported as-is.

**What happens when a company has more open jobs than `maxJobsPerCompany` allows?** You
still get every job row this run read, fully paid and fully usable — plus one free
`resolved-truncated` notice row for that domain saying plainly that more exist and how
many were actually delivered. Nothing is silently dropped without being disclosed; raise
`maxJobsPerCompany` on your next run to read deeper.

**Why did my run stop before reaching the end of my domain list?** This Actor protects
you from spending run time and your run-start budget grinding through a list that was
never going to resolve — one where none of the domains you gave it actually run on any
of the six platforms it checks. If fourteen domains in a row come back `unresolved`
**and** this run hasn't resolved a single company yet (no `resolved-zero`, no job found
— either counts as a resolve), it stops and delivers one free `run-notice` row stating
how many domains were never even attempted, instead of quietly working through the rest
at your expense. A single real resolve at any point — even domain #2 — permanently lifts
this guard for the rest of the run, so a mixed list is never at risk: this only fires on
a list where nothing has worked yet. Network failures, invalid domains and other errors
never move the streak counter in either direction — only a clean `unresolved` verdict
does, so a run of source outages is never mistaken for "this list has no ATS." The
threshold isn't arbitrary: assuming a pessimistic 20% of domains in a list actually have
a detectable ATS, the chance of missing fourteen in a row by pure luck should stay under
5% (`ln(0.05)/ln(0.80) ≈ 13.4`, rounded up to 14). Live-tested: 20 real `.gov` domains
with no ATS at all stopped exactly at the 14th, with 6 domains never touched; the same
list with one real company mixed in at position 5 ran all 20 through, no early stop at
all. One practical note: the counter tracks *completed* lookups, so at the default
concurrency of 5 a few domains are already in flight when the guard trips and they
finish normally — expect it to stop after roughly 14-18 domains rather than exactly 14.
Run with `maxConcurrency: 1` if you want it to stop on the nose.

**Which ATS platforms does it cover, and which does it skip on purpose?** Six: Greenhouse,
Lever, Ashby, Workable, Rippling and Workday — each proven live with a real, non-empty
board, not from documentation. Three more are deliberately excluded: SmartRecruiters,
Recruitee and Personio all publish a `robots.txt` that disallows automated access outright
(`Disallow: /`, SmartRecruiters carving out only LinkedIn's own crawler) — checked live,
not assumed. This Actor doesn't build around a source's explicit refusal, even when the
API itself would technically respond. iCIMS, BambooHR and fully custom in-house career
sites are outside this Actor's scope for a different reason — no public API to read at
all — and come back `unresolved`, never a false zero.

**How fresh is the data?** Live at request time — the same feeds that power each
company's own careers-page widget.

**Can I call it from an AI agent?** Yes — standard Apify Actor, callable from the Apify
API, the SDK, or the Apify MCP server.

**What this is NOT.** Not a full-text job-description scraper — only the fields listed
above (that's what all six ATS APIs publish as metadata). Not a guarantee that
`unresolved` means "no jobs" — it means this Actor couldn't determine the ATS, nothing
more. Not a guarantee that every job row on this Actor's page is complete for every
board — Greenhouse, Lever, Ashby and Workable always come back whole, but a very deep
Workday or Rippling board is read only up to `maxJobsPerCompany`, always disclosed when
it applies. Not a coverage guarantee beyond these six systems — a company on any other
platform, or with no public ATS at all, always comes back `unresolved`, honestly. Not a
guarantee that a billed Method-B job's board is provably yours on every provider — Ashby's
check compares a real domain, but Greenhouse, Lever, Workable and Rippling only ever
expose a company name to check against, the strongest evidence those four systems
publish (see FAQ); when even that name doesn't match, the job ships anyway, just free.

Found a wrong result, or need a check we don't run? Open an issue on this Actor's page.

***

Built by [zinin](https://apify.com/zinin). Questions? Telegram [@timzinin](https://t.me/timzinin).

# Actor input Schema

## `domains` (type: `array`):

Plain company domains — no ATS token needed, this Actor figures out the applicant-tracking system itself (Greenhouse, Lever, Ashby, Workable, Rippling or Workday) from the company's careers paths/homepage, at most one explicit robots-compliant careers-site hop, or a verified slug fallback. Accepts bare domains or full URLs ("https://www.figma.com/careers" is normalized to "figma.com"). Takes priority over 'datasetId' when both are set (by presence, even if every domain in it turns out invalid). Up to 100 domains per run.

## `datasetId` (type: `string`):

Pick another Actor's dataset of companies to resolve instead of typing domains directly (e.g. a company-list scraper's run). Ignored when 'domains' is set. Picking it here — not just pasting the ID — is what grants this run's token READ access to that dataset. When this Actor is added as an Integration on another Actor, this field is filled in automatically from the triggering run. Domains are read from whichever field in each row looks like one (companyDomain, domain, website, url, ...).

## `maxConcurrency` (type: `integer`):

How many domains to resolve in parallel, 1-20.

## `maxJobsPerCompany` (type: `integer`):

Depth limit for paginated sources (Workday, Rippling) — how many open roles to read per company before stopping. Non-paginated sources (Greenhouse, Lever, Ashby, Workable) already return their whole board in one call and are never capped by this. Raise it if a company's board is deeper than the default and you need the rest (e.g. a large Workday tenant) — every extra page costs real run time (Workday averages ~1.1s/page). When the cap is reached, or the source's own total is untrustworthy (a proven Workday quirk), a free 'resolved-truncated' row says so explicitly on the dataset — this Actor never silently drops jobs without disclosing it.

## Actor input object example

```json
{
  "domains": [
    "figma.com",
    "ramp.com",
    "datadoghq.com"
  ],
  "maxConcurrency": 5,
  "maxJobsPerCompany": 500
}
```

# Actor output Schema

## `results` (type: `string`):

API URL for the default dataset items produced by this run.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "domains": [
        "figma.com",
        "ramp.com",
        "datadoghq.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/careers-page-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "domains": [
        "figma.com",
        "ramp.com",
        "datadoghq.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/careers-page-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "domains": [
    "figma.com",
    "ramp.com",
    "datadoghq.com"
  ]
}' |
apify call zinin/careers-page-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=zinin/careers-page-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/LoucZ4pdiqTspneOd/builds/RWb63F88ke2ohR9OB/openapi.json
