# Company Domain to Job Postings: Greenhouse, Lever, Ashby (`fayoussef/company-domain-to-job-postings`) Actor

Paste company domains, get their live job postings. Finds each company's ATS board across Greenhouse, Lever, Ashby, Recruitee, SmartRecruiters and Personio, verifies it really belongs to them, and returns every open role in one schema with salary, seniority and remote type. No slug hunting.

- **URL**: https://apify.com/fayoussef/company-domain-to-job-postings.md
- **Developed by:** [youssef farhan](https://apify.com/fayoussef) (community)
- **Categories:** Jobs, Automation, Developer tools
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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

## Company Domain to Job Postings: Greenhouse, Lever, Ashby

**Paste company domains, get their live job postings.** Finds each company's ATS board across Greenhouse, Lever, Ashby, Recruitee, SmartRecruiters and Personio, verifies it really belongs to them, and returns every open role in one schema with salary, seniority and remote type. No slug hunting.

Every other multi-ATS job scraper makes you supply a board slug first: `greenhouse:stripe`, `lever:spotify`. Your CRM export does not contain those. It contains `stripe.com`.

This Actor closes that gap. Give it a domain and it finds the company's careers page, works out which applicant tracking system they use, **verifies the board actually belongs to that company**, and returns every open role in one unified schema.

```
stripe.com      ->  greenhouse:stripe      ->  578 jobs
channable.com   ->  recruitee:channable    ->   13 jobs
personio.com    ->  personio:personio      ->    1 job
monzo.com       ->  greenhouse:monzo       ->  110 jobs
example.com     ->  no board found         ->  reported, not guessed
```

No login. No API key. No proxy. No browser.

***

### Why verification matters

Guessing a board slug from a domain is easy and wrong often enough to poison a dataset. During development, a plain guess resolved:

- `personio.com` to an unrelated Recruitee demo board named **"FD Sandbox"**
- `example.com`, IANA's reserved domain, to a Greenhouse board belonging to **"Democorp"**
- `neon.tech` to a **different company also called Neon**, on Lever

All three looked like clean successes. All three would have been billed and shipped into your dataset.

So this Actor accepts a board only when it can prove ownership, by either of two independent routes:

1. **The board mentions the company's own domain**, in apply URLs, company fields or description text. Measured across the known cases, correct boards mentioned the domain between 32 and 840 times, and wrong boards mentioned it zero times.
2. **The board's company name contains the domain label.** This catches boards with no description text at all, where route 1 has nothing to read.

A board the company links to from its own careers page is accepted as a second pass even without either signal, and flagged in `resolved_via` so you can tell the difference.

**A domain that cannot be resolved is reported, never guessed.** Unresolved domains appear in the `COMPANY_REPORT` record with their status, and they are not charged.

***

### Supported ATS platforms

| ATS | Source | Descriptions | Structured salary |
|---|---|---|---|
| **Greenhouse** | `boards-api.greenhouse.io` | Yes | Parsed from description |
| **Lever** | `api.lever.co` | Yes | Parsed from description |
| **Ashby** | `api.ashbyhq.com` | Yes | **Yes**, native compensation tiers |
| **Recruitee** | `{company}.recruitee.com` | Yes | **Yes**, native salary object |
| **SmartRecruiters** | `api.smartrecruiters.com` | Yes, one extra request per job | Parsed from description |
| **Personio** | `{company}.jobs.personio.de` | Yes | Parsed from description |

Adding a seventh platform means adding one file. If one you need is missing, [suggest it here](https://automationbyexperts.com/apify).

***

### Quick start

#### Example input

```json
{
  "companies": [
    "stripe.com",
    "monzo.com",
    "channable.com",
    "https://www.airtable.com"
  ],
  "keywords": ["engineer", "data"],
  "remoteOnly": true,
  "includeDescription": true,
  "descriptionFormat": "markdown",
  "incremental": false,
  "maxItems": 0
}
```

You can mix in boards you already know, and they skip the lookup entirely:

| Format | Example | Behaviour |
|---|---|---|
| Company domain | `stripe.com` | Resolved and verified |
| Company website URL | `https://www.airtable.com` | Resolved and verified |
| Board slug | `stripe` | Probed across all six platforms |
| Qualified slug | `lever:spotify` | Used directly, no lookup |
| ATS board URL | `https://jobs.ashbyhq.com/ramp` | Used directly, no lookup |

***

### How resolution works

Candidates are collected cheaply, then verified against the real board APIs. The `resolved_via` field on every row records which route won.

| `resolved_via` | Meaning |
|---|---|
| `homepage` | An ATS link was in the site's own homepage markup |
| `careers-page` | Found on a careers page the homepage linked to |
| `common-path` | Found at `/careers` or `/jobs` |
| `careers-subdomain` | Implied by a careers subdomain the company publishes, e.g. `jobs.channable.com` |
| `slug-guess` | Guessed from the domain label, then corroborated |
| `slug-probe` | You supplied a bare slug and it was probed across platforms |
| `given` | You named the board directly |
| `...-redirect` | Found in a redirect target rather than page body |
| `...-unconfirmed` | The company linked to this board, but the board never mentions the company |

***

### Unified output schema

Every record carries every field, on every platform. A field the source does not publish comes back `null` rather than missing, so downstream code never branches on which ATS a row came from.

| Field | Type | Description |
|---|---|---|
| `job_id` | string | Stable id, `ats:slug:id`. Unique across platforms, steady between runs |
| `ats` | string | `greenhouse`, `lever`, `ashby`, `recruitee`, `smartrecruiters` or `personio` |
| `company` | string | Company name as published |
| `company_slug` | string | The board slug it was scraped from |
| `source_domain` | string | The domain this row was resolved from, for joining back onto your input list |
| `title` | string | Job title as posted |
| `department` | string | Department or job family |
| `team` | string | Sub-team or job function |
| `location` | string | Primary location string |
| `locations` | array | Every location on the posting |
| `country` | string | From the platform field, or inferred from the location text |
| `is_remote` | boolean | True for remote roles. Hybrid is false |
| `remote_type` | string | `remote`, `hybrid` or `onsite` |
| `employment_type` | string | `full_time`, `part_time`, `contract`, `temporary`, `internship`, `volunteer` |
| `seniority` | string | `internship`, `entry`, `mid`, `senior`, `staff`, `principal`, `lead`, `manager`, `director`, `executive` |
| `salary_min` | number | Lower bound of the pay range |
| `salary_max` | number | Upper bound of the pay range |
| `salary_currency` | string | ISO 4217 code, e.g. `USD`, `EUR`, `GBP` |
| `salary_period` | string | `hour`, `day`, `week`, `month` or `year` |
| `salary_raw` | string | The pay string as published, so a parsed range can be checked |
| `description_html` | string | Full body as HTML |
| `description_markdown` | string | Full body converted to Markdown |
| `description_text` | string | Full body as plain text |
| `apply_url` | string | Direct link to the application form |
| `job_url` | string | Public link to the posting |
| `posted_at` | string | ISO 8601 first publication |
| `updated_at` | string | ISO 8601 last edit reported by the platform |
| `scraped_at` | string | ISO 8601 timestamp of this run |
| `change_status` | string | `new` or `updated` in incremental mode |
| `resolved_via` | string | How the board was found, per the table above |

Only the description field you asked for is filled. The other two stay `null`, so a record never carries the same body three times.

#### Example output

```json
{
  "job_id": "recruitee:channable:2697907",
  "ats": "recruitee",
  "company": "Channable",
  "company_slug": "channable",
  "source_domain": "channable.com",
  "title": "Technical Customer Support DACH - German speaking",
  "department": "Support",
  "team": "customer_service",
  "location": "Utrecht, Utrecht, Netherlands",
  "locations": ["Utrecht, Utrecht, Netherlands"],
  "country": "Netherlands",
  "is_remote": false,
  "remote_type": "hybrid",
  "employment_type": "full_time",
  "seniority": "entry",
  "salary_min": 2850.0,
  "salary_max": 2950.0,
  "salary_currency": "EUR",
  "salary_period": "month",
  "salary_raw": null,
  "description_html": null,
  "description_markdown": "Are you fluent in German and passionate about solving complex problems...",
  "description_text": null,
  "apply_url": "https://jobs.channable.com/o/technical-customer-support-dach-german-speaking-2/c/new",
  "job_url": "https://jobs.channable.com/o/technical-customer-support-dach-german-speaking-2",
  "posted_at": "2026-08-03T15:40:43+00:00",
  "updated_at": "2026-08-04T07:14:55+00:00",
  "scraped_at": "2026-08-16T14:06:44.989721+00:00",
  "change_status": null,
  "resolved_via": "slug-guess"
}
```

#### The company report

Alongside the dataset, every run writes a `COMPANY_REPORT` record with one row per input, so you always know what happened to each domain:

```json
[
  {"input": "channable.com", "domain": "channable.com", "ats": "recruitee",
   "slug": "channable", "resolvedVia": "slug-guess", "jobsFound": 13,
   "jobsKept": 13, "status": "resolved"},
  {"input": "example.com", "domain": "example.com", "ats": null, "slug": null,
   "resolvedVia": null, "jobsFound": 0, "jobsKept": 0, "status": "unresolved"}
]
```

***

### Use cases

**Enrich a CRM or prospect list.** Your sheet has domains. Feed them in, get back every open role per company, joined on `source_domain`. No manual slug hunting, no dead ends left unexplained.

**Score hiring intent for sales.** A company that just opened five RevOps roles has budget. Filter by `keywords`, run on a schedule, and watch `change_status: new`.

**Track a VC portfolio or competitor set.** You know the domains, not the ATS vendors. Resolution handles the difference, and re-checks it on every run in case a company migrates platform.

**Build a niche job board.** Stable `job_id` makes upserts trivial. Salary and remote type are already normalized across all six platforms.

**Run compensation benchmarking.** `salary_min`, `salary_max`, `salary_currency` and `salary_period` are normalized everywhere, so you can aggregate pay bands by title, seniority and country with no cleaning step.

**Feed an AI recruiting agent.** Markdown descriptions and a flat schema drop straight into a RAG pipeline with no HTML stripping.

***

### Filters and modes

| Input | What it does |
|---|---|
| `keywords` | Keep only titles containing one of these words. Case-insensitive |
| `locationFilter` | Keep only jobs whose location mentions one of these |
| `remoteOnly` | Keep only roles classified as remote. Hybrid does not count |
| `includeDescription` | Turn off to skip description fetching. Makes SmartRecruiters boards dramatically faster |
| `descriptionFormat` | `markdown`, `html` or `text` |
| `incremental` | Emit only new or changed jobs since the last run |
| `maxItems` | Stop after N jobs. `0` means no limit |
| `preset` | Add a curated company list: `yc_companies`, `ai_labs` or `unicorns` |

#### How incremental mode works

Each job is fingerprinted over the fields that matter (title, location, department, employment type, salary, apply URL, updated date and description). Fingerprints live in a named key-value store that survives between runs.

- **First run:** everything is `new`, because nothing has been seen yet.
- **Later runs:** only jobs whose fingerprint changed come back, tagged `updated`, plus genuinely new postings tagged `new`.

`scraped_at` is excluded from the fingerprint, since it changes on every run by definition.

***

### Frequently asked questions

**What if a company does not use one of the six platforms?**
It is reported as unresolved in `COMPANY_REPORT` and costs you nothing. It is never guessed at and never silently dropped.

**How accurate is the resolution?**
Across a 13-domain test set spanning all six platforms, 10 resolved and every one was correct. The three that did not resolve genuinely had no supported board: IANA's reserved `example.com`, a company acquired and folded into another's careers site, and one using an unsupported ATS. Zero false positives is the design goal, and it is why boards must prove ownership before they are accepted.

**Why did my domain not resolve when the company clearly has a Greenhouse board?**
The most common cause is a fully JavaScript-rendered careers page that never puts the board URL in the HTML. This Actor is HTTP-only by design, which is what makes it fast and cheap. If you know the slug, pass `greenhouse:theirslug` directly and it will be used with no lookup.

**Do I need an API key for any of these platforms?**
No. All six endpoints are the public job-board APIs those platforms serve to render company career pages.

**Does this need a proxy?**
No. These are public APIs with no anti-bot layer, so there is no proxy configuration at all.

**What happens if an input is wrong?**
It is logged and skipped, and the run continues. A typo can never end a run or empty a dataset.

**Why is `salary_min` empty on some jobs?**
Most companies do not publish pay. Ashby and Recruitee expose structured compensation, read directly. For the other four the Actor parses ranges out of the description, accepting a match only when it sits next to a pay-related word and falls in a plausible band, so unrelated figures in the prose are not misread.

**Why is `country` sometimes empty?**
Because guessing wrong is worse than leaving it blank. Two-letter codes that are both an ISO country and a US state (`CA`, `DE`, `IL`, `IN`, `AR`, `CO`, `ID`) are never resolved from the code alone. `Chicago, IL` resolves through the city; `Boise, ID` stays empty.

**Can I run this on a schedule?**
Yes. Pair a schedule with `incremental: true` and each run returns only that period's movement.

***

### Pricing

**$1.00 per 1,000 jobs. $6.00 per 1,000 companies discovered, once each.**

Pay per event, and **platform usage is free**: no compute units on top, no monthly fee beyond your Apify plan.

| Event | What triggers it | Free | Starter | Scale | Business |
|---|---|---|---|---|---|
| New company board discovered | A domain is matched to a **verified** board | $6.00 / 1,000 | $6.00 / 1,000 | $5.50 / 1,000 | $5.00 / 1,000 |
| Company board read | A board is read from a slug you gave or already discovered | $2.00 / 1,000 | $2.00 / 1,000 | $1.90 / 1,000 | $1.80 / 1,000 |
| Job posting | A job is written to the dataset | $1.00 / 1,000 | $1.00 / 1,000 | $0.95 / 1,000 | $0.90 / 1,000 |
| Actor start | A run starts | $0.00005 | $0.00005 | $0.00005 | $0.00005 |

Higher plan tiers carry better rates: Starter gets the Bronze discount, Scale the Silver, Business the Gold. Starting a run is effectively free at $0.00005, so scheduling this every hour costs nothing in fixed fees.

Three things make it cheaper than it looks:

- **Discovery is charged once per company, not once per run.** Boards that are found get remembered between runs, so the second run over the same domain list bills the $2.00 / 1,000 read rate instead of the $6.00 / 1,000 discovery rate. A remembered board is still fetched live every run, so a company that switches ATS is re-discovered automatically.
- **The two company events never both fire.** Discovery already covers reading the board it found.
- **Unresolved domains cost nothing.** Discovery bills only on success, after the board has been verified against the live API.

| Scenario | Cost |
|---|---|
| Trial: 3 new domains, 150 jobs | **$0.17** |
| CRM enrichment, first run: 50 domains (40 resolve), 4,000 jobs | **$4.24** |
| Same list, every run after: 40 remembered boards, 4,000 jobs | **$4.08** |
| Known slugs, no discovery: 20 boards, 2,000 jobs | **$2.04** |
| Hourly incremental watch: 40 remembered boards, 40 changed jobs | **$0.12** per run, about $86 a month |

Free-plan runs are limited to 3 companies and 50 jobs. [Subscribe to an Apify plan](https://apify.com/pricing?fpr=youssef) to remove the caps.

***

### Notes and limits

- Resolution is HTTP-only. A careers page that renders its board purely in JavaScript will not resolve; pass the slug directly in that case.
- SmartRecruiters answers an unknown company with an empty list rather than a 404, so a bare slug is never auto-detected as SmartRecruiters. Use `smartrecruiters:Slug` for those boards. The slug is case-sensitive there.
- Personio boards are served in the language the company publishes. The Actor does not force a language, because requesting one the board does not publish returns postings with empty descriptions.
- Runs are migration-safe. Progress is checkpointed after every company, so a run moved to another host resumes instead of restarting.

***

💼 Need a custom solution? Reach out at youssefarhan24@gmail.com
🌐 Got a site in mind? [Suggest it here](https://automationbyexperts.com/apify)

# Actor input Schema

## `companies` (type: `array`):

One company per line. Paste domains straight from your CRM, portfolio list or prospect sheet:

- a company domain, e.g. `stripe.com` (the Actor finds the job board itself)
- a company website URL, e.g. `https://www.airtable.com`

If you already know the board, you can skip the lookup and name it directly:

- a board slug, e.g. `stripe`
- a slug with the platform, e.g. `lever:spotify` or `ashby:ramp`
- an ATS board URL, e.g. `https://jobs.ashbyhq.com/ramp`

Supported platforms: Greenhouse, Lever, Ashby, Recruitee, SmartRecruiters, Personio. Anything that resolves to no board is logged and skipped, never fatal, and every input appears in the COMPANY\_REPORT record with its outcome.

## `preset` (type: `string`):

Add a curated list of well-known company boards on top of whatever you typed above. Use this to try the Actor without hunting for slugs. Leave on None to scrape only your own list.

## `keywords` (type: `array`):

Keep only jobs whose title contains at least one of these words. Case-insensitive, matched anywhere in the title, so `engineer` also matches `Senior Engineering Manager`. Leave empty to keep every title.

## `locationFilter` (type: `array`):

Keep only jobs whose location mentions one of these. Case-insensitive substring match against every location on the posting, so `berlin`, `germany` and `remote` all work. Leave empty to keep every location.

## `remoteOnly` (type: `boolean`):

Keep only jobs the Actor classifies as remote. Remote status comes from the platform's own flag where one exists, and from the title and location text otherwise. Hybrid roles are not counted as remote.

## `includeDescription` (type: `boolean`):

Fetch the full posting body. Turning this off makes SmartRecruiters boards dramatically faster, because their descriptions need one extra request per job, and it also disables the salary parsing that reads pay out of the description text.

## `descriptionFormat` (type: `string`):

Which description field to fill. Markdown is the readable default and works well in spreadsheets and LLM pipelines. HTML keeps the original markup. Text strips all formatting. The other two fields stay empty so records do not carry the same body three times.

## `incremental` (type: `boolean`):

Remember every job seen in previous runs and emit only the ones that are new or have changed since. Each record gets a `change_status` of `new` or `updated`. Ideal on a schedule: run it hourly and the dataset holds just the day's movement. The first run emits everything, because nothing has been seen yet.

## `maxItems` (type: `integer`):

Stop after this many jobs across all companies. Leave at 0 for no limit.

## Actor input object example

```json
{
  "companies": [
    "stripe.com",
    "monzo.com",
    "channable.com"
  ],
  "preset": "none",
  "keywords": [],
  "locationFilter": [],
  "remoteOnly": false,
  "includeDescription": true,
  "descriptionFormat": "markdown",
  "incremental": false,
  "maxItems": 0
}
```

# Actor output Schema

## `jobs` (type: `string`):

All scraped job postings. Switch between the Overview, Salary, Resolution and Changes views in the table.

## `companyReport` (type: `string`):

One row per input: which ATS and board slug it resolved to, how it was found, and how many jobs it returned. Unresolved domains are listed here too.

## `summary` (type: `string`):

Counts, charged events and any skipped company slugs from 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 = {
    "companies": [
        "stripe.com",
        "monzo.com",
        "channable.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("fayoussef/company-domain-to-job-postings").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 = { "companies": [
        "stripe.com",
        "monzo.com",
        "channable.com",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("fayoussef/company-domain-to-job-postings").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "companies": [
    "stripe.com",
    "monzo.com",
    "channable.com"
  ]
}' |
apify call fayoussef/company-domain-to-job-postings --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,fayoussef/company-domain-to-job-postings"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/onaa4nFJMg4z70fwu/builds/1ecHTqeIpF1XD1Mgx/openapi.json
