# Job Posting to Hiring Manager Email (`zinin/job-posting-to-hiring-manager-email`) Actor

Turn a Greenhouse or Lever vacancy into the named hiring owner behind it, with a provider-verified work address from your own BYOK enrichment key. You pay $0.02 only for a verified hiring contact; ambiguous company matches, unsupported boards and empty results are free.

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

## Pricing

from $17.00 / 1,000 verified hiring contacts

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

## Job Posting to Hiring Manager Email: Deterministic Hiring-Owner Contact Resolution for Open Roles

![What this Actor does: input, evidence, outcome](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e200ae9e85a70951ef266530fe47539e033b73e2/signalops10/job-posting-to-hiring-manager-email--readme-hero.webp)

Get one relevant hiring-owner email per job posting or company-and-role pair you submit — the single
recruiter, HR leader or department owner whose title makes them the deterministically correct person
to contact about that vacancy, with the address itself confirmed by your own contact-data provider
account rather than guessed from a name-pattern formula.

The billable outcome this Actor is built around is a **provider-verified hiring-owner match**: one
job or company-and-role input resolves to exactly one person, selected by deterministic role
relevance to the vacancy, whose work email your own contact-data provider account has itself reported
under a positive, non-guessed verification status. That is the only noun this page uses for pricing,
listing terminology and Dataset semantics. Everything else the Actor's design can produce — an
unresolved applicant-tracking board, a vacancy the board does not carry, an employer identity that
cannot be corroborated, no provider key supplied, a provider result marked guessed or unconfirmed, a
tie between two equally plausible candidates, the same contact already delivered for another vacancy
in the same run, or a run limit reached before coverage completed — is free, never a partial paid
result.

### What you get

- **Resolve one vacancy to one hiring-owner contact.** Submit a direct posting link or a company
  domain plus the exact job title, and the Actor selects the single most role-relevant recruiter, HR
  leader or department owner — never a list of "possible" people to sort through yourself.
- **Read two public job boards without any key of ours.** Greenhouse and Lever both publish board data
  over an unauthenticated JSON feed, and both boards' own `robots.txt` allow automated reading. No
  login, browser, proxy or CAPTCHA-solving step exists in the job/company half. A third board, Ashby,
  is excluded by decision — see Evidence and boundaries.
- **Never guess an email pattern.** Every billable address is one your own provider account itself
  reports as identity-matched and positively verified — never pattern synthesis, never an MX/SMTP
  probe, never a catch-all guess.
- **Tell you exactly why this person, not another.** `matchReason` names the deterministic rule that
  selected the candidate, and `ownerProfileEvidence` carries whatever profile evidence your provider
  returned for them.
- **Get an honest reason when there is no billable match**, not silence. Every free row explains
  exactly which of the eighteen structural reasons applies, with a stable machine error code.
- **Never receive an implicit green light to auto-send outreach.** Every row, paid or free, carries
  `safeToAutomate:false` and `safeForOutreach:false` as fixed flags — evidence for human review, never
  permission for an automated send.

This is a one-job, one-owner, on-demand resolution utility. It is not a bulk company-wide contact
harvester, not a people-search database and not a monitor — it accepts a specific vacancy or
company-and-role pair and returns at most one contact for it, never a list of everyone who works
there.

### Who uses it

- **Recruiting and staffing agencies** who already have a target vacancy and need the one person at
  that company who actually owns hiring for it, instead of guessing between a generic `careers@` inbox
  and a cold LinkedIn message.
- **Sales and partnership teams** using a company's own hiring activity as a buying signal, turned into
  a named, provider-verified point of contact for a human-reviewed outreach step.
- **ATS and recruiting-tooling vendors** who want a per-posting resolution step, keeping the provider
  licensing relationship on the buyer's own account rather than baked into vendor infrastructure.
- **Compliance and data-quality reviewers** who need a defensible reason to accept or reject a
  hiring-manager contact already sitting in a CRM, with provider evidence and observation time
  attached.
- **Developers and workflow builders** who want a strict, flat, machine-checkable contract an agent or
  n8n node can branch on without parsing free text.

![Where the Actor sits in your pipeline: trigger, run, action](https://raw.githubusercontent.com/TimmyZinin/apify-actor-assets/e200ae9e85a70951ef266530fe47539e033b73e2/signalops10/job-posting-to-hiring-manager-email--readme-workflow.webp)

### How to run

1. Supply one to one hundred job entries: each a direct posting URL (`jobUrl`) or a company domain
   plus the exact job title (`companyDomain` + `jobTitle`). `location`/`department` are optional
   disambiguation hints; `inputRef` is an optional opaque correlation string.
2. Supply your own contact-data provider's name and API key as two secret fields (`providerName`,
   `providerApiKey`) — required together. Omitting both is valid: every job returns the free
   `provider_key_missing` outcome rather than an error.
3. Optionally cap `maxConcurrency` (1–5, default 1) — local batching only; on a monetized run source
   work and delivery stay sequential regardless, and Lever's own one-request-per-second floor is
   enforced independently.
4. Start the run from the Console, the Apify API, a scheduled Task, or an MCP client.
5. Read the Dataset for one row per submitted job — present even for a duplicate or a failure, so row
   count against input count is itself a completeness check.
6. Read `OUTPUT` for the run-level summary: submitted, duplicates skipped, delivered, billed, free by
   category, and whether the run stopped early. The Dataset answers "what did I get"; `OUTPUT` answers
   "what did this run charge for" — reconcile the two before acting on any single row.
7. Route every `owner_match` row to a human-reviewed next step; `safeToAutomate`/`safeForOutreach` are
   fixed `false` specifically to keep an automated send out of that path.

A default-input smoke run reads a real, live Greenhouse board and does not require any buyer secret to
execute — it simply returns the free `provider_key_missing` outcome for the vacancy it resolves,
because no provider key is supplied by default.

### Pricing

Pay only for a delivered result. **$0.02 per provider-verified hiring-owner match**, plus a flat
**$0.005 Actor start** fee per run. Every other outcome — an unresolved board, a vacancy the board
does not carry, an employer identity that cannot be corroborated, a missing or rejected provider key,
an unconfirmed or absent provider result, a candidate that fails this Actor's own evidence bar, an
ambiguous tie between two candidates, the same contact already delivered for another vacancy in the
same run, and any run limit reached before coverage completed — is free. A run of one hundred jobs
where only twelve resolve to a provider-verified match bills $0.005 + 12 × $0.02 = $0.245, not $2.00.

This price pays for the vacancy-to-owner resolution only. The person-verification step itself runs on
**your own contact-data provider account (BYOK)** — Hunter, Apollo, Prospeo, Snov.io or RocketReach —
called on your own key against your own quota. Your provider bills you directly for that usage, on
whatever plan you already hold with them; this Actor never resells contact data, never marks up a
provider's fee, and never stores, logs or reuses your provider key beyond the single run you supply it
for.

### Input contract

`jobs` is required: one to one hundred objects. Each requires either `jobUrl` — a direct posting link,
12–2048 characters, matched at runtime against a fixed list of supported board hosts before any
request is made, so a link to any other host returns the free `ats_not_supported` outcome without a
network call — or both `companyDomain` (a bare public hostname, 4–253 characters; schemes, paths,
credentials and IP literals rejected by pattern) and `jobTitle` (2–200 characters, matched exactly
after normalization, never fuzzy). `location` and `department` are optional disambiguation strings, up
to 200 characters, never used to invent a match. `inputRef` is an optional opaque string up to 200
characters, copied back exactly, including whitespace.

`providerName` is an optional enum — `hunter`, `apollo`, `prospeo`, `snov` or `rocketreach` — and
`providerApiKey` is a secret string field, 8–512 characters, marked `isSecret:true`. The two fields
are mutually required: supplying one without the other is rejected before the run starts. **Only
Hunter's call pattern is implemented.** The other four are accepted as ToS-cleared BYOK candidates, but
selecting one today returns the free `provider_not_implemented` outcome for the whole run rather than
a plausible-looking empty result.

`maxConcurrency` is an integer, 1–5, default 1: local, non-monetized batching only. Runs deduplicate on
the vacancy's own identity before any network call, so submitting the same vacancy twice in one run,
in slightly different casing, is skipped rather than charged twice; `OUTPUT` counts these as
`duplicatesSkipped`.

```json
{
  "jobs": [
    {
      "companyDomain": "meridianlabs.example",
      "jobTitle": "Senior Backend Engineer",
      "location": "Remote - EU",
      "department": "Engineering",
      "inputRef": "ats-req-77219"
    },
    {
      "jobUrl": "https://jobs.lever.co/meridianlabs/8f2a1c40-91e3-4b7a-8a2f-119d3c88a071",
      "companyDomain": "meridianlabs-hq.example",
      "inputRef": "ats-req-77223"
    }
  ],
  "providerName": "hunter",
  "providerApiKey": "REDACTED-BUYER-SUPPLIED-KEY",
  "maxConcurrency": 1
}
```

*(`meridianlabs.example` and `meridianlabs-hq.example` use the `.example` reserved domain suffix
precisely so neither can be mistaken for a real company; the two example output rows below show why
supplying a domain alongside a Lever posting link, as the second job does here, does not always
resolve the way a buyer might expect.)*

### Example output

Every row shares one flat, 36-field schema regardless of outcome. The three examples below are
illustrative — every company, person and address is invented — built field-by-field to match exactly
what `.actor/dataset_schema.json` and the runtime actually produce.

#### Happy path — a provider-verified line-owner match on a Greenhouse board

```json
{
  "recordType": "hiring_owner_decision",
  "schemaVersion": "1.0",
  "entityId": "4a7c91e0f3b6d825ae19c047",
  "inputRef": "ats-req-77219",
  "atsProvider": "greenhouse",
  "atsToken": "meridianlabs",
  "jobId": "6142098",
  "jobTitle": "Senior Backend Engineer",
  "department": "Engineering",
  "location": "Remote - EU",
  "jobUrl": "https://boards.greenhouse.io/meridianlabs/jobs/6142098",
  "companyName": "Meridian Labs",
  "companyDomain": "meridianlabs.example",
  "resolutionPath": "byok",
  "ownerName": "R. Alvarez",
  "ownerTitle": "VP Engineering",
  "ownerProfileEvidence": { "linkedin": "https://www.linkedin.com/in/r-alvarez-example" },
  "email": "r.alvarez@meridianlabs.example",
  "matchReason": "line_owner:line_owner_same_function",
  "verificationStatus": "owner_match",
  "verificationLevel": "provider_verified",
  "providerName": "hunter",
  "sourceUrl": null,
  "sourceEvidence": {
    "vacancy": "https://boards.greenhouse.io/meridianlabs/jobs/6142098",
    "board": "greenhouse:meridianlabs",
    "providerConfidence": 92,
    "disclaimer": "The vacancy is published by the board named above. The address carries the verification level stated on this row and nothing beyond it: not mailbox ownership, not consent to be contacted, not current hiring authority."
  },
  "confidenceScore": 6,
  "confidenceBand": "medium",
  "recommendedAction": "human_review_before_outreach",
  "safeToAutomate": false,
  "safeForOutreach": false,
  "partial": false,
  "partialReason": null,
  "errorCode": null,
  "error": null,
  "retryable": false,
  "observedAt": "2026-08-12T21:14:03.000Z",
  "found": true
}
```

Every field here was worked out by the same deterministic rule the runtime itself runs: "VP
Engineering" leads with a curated leadership word, carries no rank-down qualifier in front of it, and
its function (`engineering`) matches the vacancy's own department and title — exactly
`line_owner_same_function`, scored at a VP-tier line-owner band. The schema also permits
`confidenceBand: "none"`, which the current code never produces — reserved so it can start being
returned without a breaking change, the same arrangement as `sourceUrl`. `confidenceBand` is a fixed
routing bucket per seniority tier, not a statement of certainty about the person (see Field
dictionary). `recommendedAction` is set to the one fixed string this build ever writes, and only on
this row.

#### Free path — a Lever posting whose board never publishes an employer name

```json
{
  "recordType": "hiring_owner_decision",
  "schemaVersion": "1.0",
  "entityId": "1b6e84f0a35c9d7218e4b096",
  "inputRef": "ats-req-77223",
  "atsProvider": "lever",
  "atsToken": "meridianlabs",
  "jobId": "8f2a1c40-91e3-4b7a-8a2f-119d3c88a071",
  "jobTitle": "Data Platform Lead",
  "department": null,
  "location": null,
  "jobUrl": "https://jobs.lever.co/meridianlabs/8f2a1c40-91e3-4b7a-8a2f-119d3c88a071",
  "companyName": null,
  "companyDomain": "meridianlabs-hq.example",
  "resolutionPath": null,
  "ownerName": null,
  "ownerTitle": null,
  "ownerProfileEvidence": null,
  "email": null,
  "matchReason": null,
  "verificationStatus": "ambiguous_company_match",
  "verificationLevel": null,
  "providerName": null,
  "sourceUrl": null,
  "sourceEvidence": null,
  "confidenceScore": null,
  "confidenceBand": null,
  "recommendedAction": null,
  "safeToAutomate": false,
  "safeForOutreach": false,
  "partial": false,
  "partialReason": null,
  "errorCode": "ambiguous_company_match",
  "error": "The buyer supplied meridianlabs-hq.example, but the board publishes no employer name for this vacancy, so meridianlabs-hq.example cannot be corroborated; the employer cannot be resolved without agreement.",
  "retryable": false,
  "observedAt": "2026-08-12T21:14:04.000Z",
  "found": false
}
```

This is not a hypothetical edge case — it is the ordinary, structural outcome for essentially every
Lever posting this Actor reads. Lever's own feed never publishes a `company_name` field at all, so a
buyer-supplied `companyDomain` can never be corroborated, and a Lever link's own `hostedUrl` always
points back at the board itself, never the employer. **Lever postings are read and searched normally,
but they cannot currently produce a billable row** — a deliberate, disclosed limitation, not a bug.

#### Partial path — a board response exceeded this run's size cap

```json
{
  "recordType": "hiring_owner_decision",
  "schemaVersion": "1.0",
  "entityId": "9d2f47b1c8e0a5936f7d1284",
  "inputRef": "ats-req-77225",
  "atsProvider": "greenhouse",
  "atsToken": "cascadepoint",
  "jobId": null,
  "jobTitle": null,
  "department": null,
  "location": null,
  "jobUrl": null,
  "companyName": null,
  "companyDomain": "cascadepoint-systems.example",
  "resolutionPath": null,
  "ownerName": null,
  "ownerTitle": null,
  "ownerProfileEvidence": null,
  "email": null,
  "matchReason": null,
  "verificationStatus": "partial_source_coverage",
  "verificationLevel": null,
  "providerName": null,
  "sourceUrl": null,
  "sourceEvidence": null,
  "confidenceScore": null,
  "confidenceBand": null,
  "recommendedAction": null,
  "safeToAutomate": false,
  "safeForOutreach": false,
  "partial": true,
  "partialReason": "board_response_truncated",
  "errorCode": "partial_source_coverage",
  "error": "Board response exceeded 12,000,000 bytes and was truncated; no row is billed from a partial feed",
  "retryable": false,
  "observedAt": "2026-08-12T21:14:05.000Z",
  "found": false
}
```

The `12,000,000`-byte figure is the actual cap this build enforces, set well above the size of any
employer board observed during development, so an ordinary large employer board is not truncated on a
normal day — this row shows the rarer day one still is. `partial_source_coverage` covers four
structurally different truncation causes, distinguished only by `partialReason`; see Decision routing.

### Field dictionary

| Field | Meaning | Important boundary |
|---|---|---|
| `recordType` | Always `"hiring_owner_decision"` | Constant discriminator for schema routing |
| `schemaVersion` | Always `"1.0"` | Bumped on any breaking Dataset change, never silently |
| `entityId` | Deterministic 24-hex-character row identity, derived from the resolved job and candidate | The same input resolves to the same ID across runs, which is what makes replay safety possible |
| `inputRef` | Your caller reference, copied exactly | Never normalized; whitespace preserved on purpose; `null` when you did not supply one |
| `atsProvider` | `greenhouse`, `lever`, or `null` | Never `"ashby"`, even when `errorCode` is `ats_not_supported` — Ashby is excluded by decision, not read at all |
| `atsToken` | The board token or company slug the vacancy was read from | `null` whenever no board could be resolved, for example on `ambiguous_company_match` |
| `jobId` | The vacancy's own ID on its board, always a string regardless of the source's native type | `null` when the specific job was never resolved, for example on `board_not_found` |
| `jobTitle` | The vacancy title as the board publishes it | `null` when the job itself was never resolved |
| `department` | The vacancy's department, when the board publishes one or you supplied one | On a free row before a job is resolved, this echoes only what you supplied, not the board's own value |
| `location` | The vacancy's location, same rule as `department` | Same input-vs-board distinction as `department` |
| `jobUrl` | Canonical link to the live vacancy on its board | `null` when no specific job was resolved |
| `companyName` | The employer's display name as the board publishes it | `null` on every Lever row without exception — Lever's own feed never publishes this field |
| `companyDomain` | The employer's registrable domain, either your own input or one recovered from the board | PSL-normalized; present even on some free rows, so you can see exactly what was evaluated |
| `submittedCompanyDomain` | Your companyDomain string EXACTLY as you spelled it; `null` on jobUrl-only vacancies | When it differs from `companyDomain`, IDNA folded a confusable spelling (e.g. a fullwidth letter) onto the ASCII domain — compare the two before acting |
| `resolutionPath` | Always `"byok"` on a billable row, `null` on every free row | This field has no other value it can carry today |
| `ownerName` | Full name of the resolved hiring owner | `null` on every free outcome that never reached a resolved candidate |
| `ownerTitle` | The resolved owner's title, from your provider or the board | `null` when no owner was resolved |
| `ownerProfileEvidence` | Supporting profile evidence for the resolved person | Always an object on a billable row, with at least an explanatory note; `null` on every free row |
| `email` | The resolved hiring owner's work email | Never a guessed or pattern-generated address; `null` on every free row |
| `matchReason` | `<role>:<reason>` — which family the person belongs to and why they were selected | `null` when no owner was resolved; the two families are `recruiting` and `line_owner`, never mixed |
| `verificationStatus` | The row's outcome; mirrors `errorCode` on every free row | Only `owner_match` is billable |
| `verificationLevel` | Always `"provider_verified"` on a billable row, `null` on every free row | No other verification level exists in this build |
| `providerName` | Which of your provider accounts produced this row | `null` on every free row that never resolved an owner |
| `sourceUrl` | Reserved for a first-party evidence tier not built in this version | Always `null` today; kept in the schema so it can return without a breaking change if that tier is ever built |
| `sourceEvidence` | Evidence bundle behind a billable match: the vacancy link, the board identity and your provider's own confidence, plus a fixed disclaimer | `null` on every free row |
| `confidenceScore` | A small deterministic integer, not a percentage | Set only on the billable row in this build; every free row carries `null`, even ones with a rich explanation elsewhere on the row |
| `confidenceBand` | `high`, `medium`, `low` or `none`, bucketed from `confidenceScore` | Same rule as `confidenceScore`: `null` on every free row in this build |
| `recommendedAction` | A routing tag for a human queue | This build writes exactly one non-null value, `human_review_before_outreach`, and only on the billable row — every free row carries `null` here even though `errorCode` and `error` explain the outcome fully |
| `safeToAutomate` | Always `false` | Fixed by the schema; this product finds a contact, it never certifies a right to contact them |
| `safeForOutreach` | Always `false` | Every row requires mandatory human review before any outreach, regardless of resolution path |
| `partial` | Whether a run limit truncated coverage of this item | A `true` row can never carry `verificationStatus:"owner_match"` |
| `partialReason` | Which specific limit truncated coverage | `null` unless `partial:true`; four distinct values share the single `errorCode:"partial_source_coverage"`, and a fifth (`job_processing_error`) pairs with `errorCode:"source_error"` instead, when an unrelated vacancy's failure left this one unreachable — see Decision routing |
| `errorCode` | Bounded machine error code, one of eighteen values | `null` only on the billable `owner_match` row; every free row carries exactly one |
| `error` | Human-readable message accompanying `errorCode` | `null` only on the billable row; never contains a raw provider response, a request header or your API key |
| `retryable` | Whether the caller can expect a different result from retrying this exact item unchanged | `true` only for `rate_limited`; for `source_error` it depends on the specific upstream failure; `false` for every other code, including the billable row, which has nothing to retry |
| `observedAt` | UTC timestamp when this row's outcome was produced | Shared by every row in one run |
| `found` | Whether this row is the one billable outcome class | `true` if and only if `verificationStatus` is `owner_match` — the row's billing boundary |

### Evidence and boundaries

**What "provider-verified" means here, and what it does not.** In this product, `verified` means *your
own contact-data provider account's own positive, non-guessed status for this specific person's
address, for the company domain this Actor independently resolved and corroborated*. It does not mean
mailbox ownership, consent, present-day deliverability or willingness to receive outreach — those are
outside what a licensed provider record can prove, and this Actor never asserts them beyond what the
provider itself reports. That boundary is why `safeToAutomate` and `safeForOutreach` are fixed `false`
on every row regardless of provider confidence, and why the paid row's own `sourceEvidence.disclaimer`
repeats the boundary on the data itself, not just on this page.

**Why only Hunter is implemented today.** All five accepted provider names publish BYOK-compatible
terms in principle, but only Hunter's exact call pattern is built and tested against a live account.
Selecting Apollo, Prospeo, Snov or RocketReach returns the free `provider_not_implemented` outcome —
an honest "not built yet," not a claim that nobody at the company has an email.

**Why a buyer-declared employer domain is never trusted on your word alone.** A wrong domain assertion
is the one mistake in this product that would sell a real buyer a real stranger's contact under a real
company's name. The rule this build enforces: a domain you supply must be *corroborated* by the
resolved vacancy's own published employer name — the whole name spelled out (with or without the
suffix carrying its last word, as in `sierra.ai` for "Sierra AI"), the name's leading word or words
with nothing left over but corporate-lineage noise, or a recognized initialism of that name, never
merely a text prefix of it. **An industry word is not "nothing left over."** `apple.com` does not
corroborate "Apple Bank", `united.com` does not corroborate "United Health", and `bank.com`
corroborates no company whatsoever — those are separate businesses and generic domains, and selling
you one under the other's name is the mistake this paragraph exists to prevent. A domain that merely
*starts with* the same letters as the resolved company label — the way five unrelated real companies
were once found to pass on nothing but a coincidence of spelling with an unrelated brand — is never
accepted either; only the domain-is-a-shortening-of-the-name direction is.

**What this costs you, plainly.** An employer named `<Brand> <ordinary word>` — "Ramp Business
Corporation", "Snowflake Computing", "Cresta Intelligence", "Scale AI" — reads exactly like "Apple
Bank" to any rule working from text, so your domain alone will not corroborate it. Such a vacancy
sells when a second, independent party agrees: the board's own posting link resolving to the domain
you supplied. Where the domain came from that link in the first place and you supplied none, the link
cannot second anything, and the published name has to carry it alone. You will see
`ambiguous_company_match` or `company_domain_unresolved` in that case — a free row, and an honest one,
rather than a paid row asserting a company we cannot stand behind. On top of corroboration, a domain is
rejected outright, regardless of what it corroborates, when it is a known job-board aggregator, link
shortener or file-hosting subdomain, or when any label in it is an IDN/punycode-encoded homoglyph — a
visually deceptive lookalike a person could not reliably tell apart from the real one by eye. Both
checks apply identically whether the domain came from you or from a value recovered out of the board's
own data.

**Why a hiring-owner title has to be recognized, not merely present.** The candidate-scoring logic
refuses any title containing even one word it does not recognize as seniority, recruiting or function
vocabulary — a structural choice, not a claim of vocabulary completeness. A personal name sitting after
a real title (`Marketing Manager Sarah Head`) is refused, because an unrecognized surname occupies
exactly the position an unrecognized word does. This costs genuine, unusually worded titles a sale; it
never sells a stranger's inbox.

**Why seniority is read by position, not by presence.** A title scores as senior only when a
leadership word governs the phrase in a position English actually uses for leadership — leading it
(`Director of Marketing`) or trailing it (`Marketing Director`) — and a rank-down qualifier anywhere to
its left cancels it: `Junior Marketing Manager` and `Intern Recruiter` are never sold as the hiring
owner, however senior the last word alone reads. This rank-cancellation rule applies identically to a
department-leadership title and to a recruiting-vocabulary title, so a `Trainee Talent Partner` is
refused exactly as a `Junior Marketing Manager` is.

**Why two roles are scored as two separate families.** A recruiter, HR leader or talent-acquisition
title is relevant to *any* vacancy at the company. A department leader is relevant only when their own
function actually matches the vacancy's — a Sales director is never evidence for an Engineering
opening, however senior. The two families are scored on separate rules and never confused.

**Why a tie is refused rather than resolved by picking the first result.** When two or more candidates
score equally and are not clearly the same person, this Actor returns the free `ambiguous_owner`
outcome instead of silently choosing whichever your provider listed first. "The same person" is decided
by normalized full name *and* mailbox together: two people sharing a name but returned under two
different addresses are a genuine tie, not a coincidence to average away. A genuine duplicate — same
name, same address, returned twice — is correctly recognized as one person and is not a tie.

**Why the same contact is billed once even across several vacancies.** A second owner-match at the same
company domain is deduplicated and reported free whenever it matches an already-billed contact on
EITHER identity — the normalized full name or the mailbox, not necessarily both — so one hiring manager
owning several open roles is billed once regardless of which spelling of their name or which mailbox
your provider happens to return on a later call. The one honest trade: two genuinely different
employees who share a normalized full name at the same company are treated as one contact, and the
second is never billed — a lost sale, not a wrong one.

**Why role recognition is English-only in this build.** The recognized vocabulary — recruiting words,
leadership words, department-function words — is English. A non-English title, such as `採用担当` or
`Responsable du Recrutement`, resolves to no relevant owner and returns the free
`no_relevant_owner_returned` outcome, never a wrong guess built on an unrecognized word.

**Which English titles this build still does not read.** The same vocabulary is closed, so some real
hiring owners written in English are refused too. `no_relevant_owner_returned` on one of these means
"this build did not read the title", not "this company has no hiring owner":

- **Bare function abbreviations:** `CTO`, `CFO`, `CHRO`, `CPO` on their own. The longhand resolves
  (`Chief Technology Officer` does); the abbreviation alone does not, because unlike a CEO these own
  hiring for one function, and treating three letters as whole-company authority is the wrong-charge
  direction.
- **Unit- or geography-scoped management:** `Managing Director`, `Country Manager`, `General Manager`
  — scoped to a business unit or a region rather than to a department this Actor's taxonomy aligns to.
- **Roles whose head word is not a title word at all:** `General Counsel`, `Chief Architect`, `Chief
  Scientist`, `Art Director`, `Chief Operating Officer`, and `HR Business Partner` (where `business`
  reads as the sales function before the recruiting one). These fail on the vocabulary before
  seniority is ever considered.

Every one of these is a free row. The trade throughout this product is a lost sale over a wrong one.

**Why a provider candidate that scored and won can still be refused before it is billed.** A person
your provider returns and this Actor's own scoring selects can still fail this Actor's own evidence bar
— an oversized name, a missing address, or an address on a domain other than the resolved employer's.
That candidate is refused with the free `provider_data_invalid` outcome, checked before any row is
built, rather than surfacing as a confusing "try again later" failure no retry could ever fix.

### Decision routing

Every row's `verificationStatus` maps to exactly one of the following eighteen outcomes, and mirrors
`errorCode` on every non-billable row. A downstream workflow should switch on this field directly.

| `verificationStatus` | Billable? | What it means | `retryable` |
|---|---|---|---|
| `owner_match` | Yes | A provider-verified hiring owner was resolved and delivered | `false` |
| `board_not_found` | No | The resolved board token has no board at all | `false` |
| `ats_not_supported` | No | The job's board is Ashby (excluded by decision) or any board outside Greenhouse/Lever | `false` |
| `job_not_found_on_board` | No | The board resolves, but this specific vacancy (removed, filled, mistyped) is not on it | `false` |
| `ambiguous_company_match` | No | The employer's identity for this vacancy could not be corroborated with confidence — a slug probe found no board or an uncorroborated one, a buyer-supplied domain conflicts with the board's own, a buyer-supplied domain is a known aggregator/shortener/homoglyph host, or the domain carries no probeable slug at all | `false` |
| `company_domain_unresolved` | No | The vacancy resolved, but the employer's own mail domain was not — most commonly a Lever posting with no buyer-supplied domain, since Lever's own feed never publishes one | `false` |
| `provider_key_invalid` | No | Your `providerApiKey` was rejected or unauthenticated by the provider | `false` |
| `provider_key_missing` | No | No `providerApiKey` was supplied, so no owner lookup was attempted — distinct from `no_relevant_owner_returned`, which means a lookup ran and found nobody relevant | `false` |
| `provider_no_result` | No | Your provider account returned no person at all for this company | `false` |
| `provider_not_implemented` | No | The provider name is accepted by the input contract but its call pattern is not integrated in this build — every provider except Hunter, today | `false` |
| `provider_unverified_guess` | No | Your provider's own status marked the address guessed, catch-all or otherwise unconfirmed | `false` |
| `provider_data_invalid` | No | Your provider returned a candidate that won role-relevance scoring but whose name, title or address does not meet this Actor's own evidence bar for a sellable row | `false` |
| `vacancy_data_invalid` | No | The vacancy itself carries a field this Actor cannot deliver as described — a value outside the published bounds, or a missing identifier. Reported apart from `provider_data_invalid` because the provider returned a usable person; the vacancy is what cannot be delivered | `false` |
| `no_relevant_owner_returned` | No | Your provider returned one or more people, but none holds a role relevant to hiring for this vacancy | `false` |
| `duplicate_owner_contact` | No | This company-plus-person identity was already delivered for another vacancy — earlier in this run, or in an earlier attempt at it that was interrupted and resumed | `false` |
| `ambiguous_owner` | No | Two or more candidates tied on relevance and are not confidently the same person | `false` |
| `partial_source_coverage` | No | A run limit was hit before full coverage of this item — see the four `partialReason` values sharing this code below (a fifth pairs with `source_error` instead) | `false` |
| `rate_limited` | No | Lever's own published pacing, or your provider's own rate limit, triggered a deliberate backoff | `true` |
| `source_error` | No | An upstream timeout, malformed response or unexpected failure from a board or your provider. Also carries `partial:true` for a vacancy the run never got to attempt at all, because a DIFFERENT vacancy earlier in the same run failed unrecoverably — see `partialReason` below | `true` for that specific case; otherwise depends on the specific failure |

`partial_source_coverage` carries four distinct `partialReason` values sharing one `errorCode`:
`board_response_truncated` (the board's own response exceeded this run's size cap),
`budget_stopped_before_source_read` (a spend cap was reached before this vacancy was attempted),
`budget_stopped_after_source_read` (an owner was resolved but the row could not be delivered because
the cap was reached in between), and `budget_stopped_before_reached` (the run stopped before this
vacancy was ever picked up). All four are free and non-retryable; only the second and third can occur
after your provider account has already been queried. A fifth `partialReason` value,
`job_processing_error`, pairs with `errorCode:"source_error"` instead — it marks a vacancy the run
never reached because a different vacancy earlier in the same batch failed unrecoverably, distinct from
a genuine spend-cap stop; unlike the other four, this one is `retryable:true`, since a later run may not
hit the same unrelated failure.

A workflow only needs a few rules to stay correct: only continue an automated step on
`found === true` **and** `partial === false`; never branch on the presence of `email` alone, since it
is `null` on every free row by design; and never assume `recommendedAction` or `confidenceScore` carry
routing information on a free row in this build — they are fixed `null` there, and `errorCode` plus
`error` are the fields that actually carry the explanation.

### Commercial playbooks

#### Recruiting agency candidate-outreach targeting

A staffing agency with a client's open requisition, or a public competitor posting, resolves the one
role-relevant hiring owner before a recruiter reaches out — replacing a guessed `first.last@` pattern
with a provider-verified contact a human still reviews before sending anything.

#### Sales signal-to-contact pipeline

A sales team tracking a target account's hiring activity resolves a role that signals budget into a
named point of contact, keeping the enrichment step separately priced from whatever detects the signal.

#### ATS-vendor per-posting resolution add-on

An ATS or recruiting-tooling vendor calls this Actor once per new customer posting, keeping the
provider licensing relationship on the customer's own BYOK key rather than the vendor's infrastructure.

#### CRM hiring-contact audit sweep

A batch of existing CRM contacts — company domain plus job title, `inputRef` carrying the CRM record ID
— gets re-checked against a real provider account. A `no_relevant_owner_returned` result for a contact
the CRM lists as verified is a worklist for review, not an automatic overwrite.

### Integration recipes

#### Scheduled ATS-feed enrichment pattern

1. A buyer-owned Apify Task watches a company's own board for new postings, through whatever monitor
   already does that job for you.
2. Each new posting triggers a single-job run of this Actor with your own provider key.
3. The workflow routes an `owner_match` row into a human-reviewed recruiting or sales queue keyed by
   `inputRef`.

#### Event-driven single-lookup pattern

A new-lead or new-posting webhook triggers a single-job run with one `jobs` entry; if `found === true`,
the record gets a "provider-verified contact available for review" flag, still routed to a human.

#### Agent/MCP pattern

An agent supplies exactly the fields the Input contract allows, reads `found`, `partial`,
`verificationStatus`, `safeToAutomate` and `safeForOutreach` before any next step, never converts a
free outcome into an affirmative claim, and always routes an `owner_match` row to a human queue,
citing `entityId`, `jobUrl` and `observedAt` in any ticket it opens.

#### Data-warehouse pattern

Append Dataset rows keyed by `entityId`, preserving `null` fields as null rather than coercing them to
empty strings, and treat a future `schemaVersion` bump as an explicit migration, not a silently
compatible row shape.

### Operating guide

**Choosing `jobUrl` versus `companyDomain` plus `jobTitle`.** Use `jobUrl` when you already have the
posting link; the board and job identity are parsed directly from it, with no probing. Use
`companyDomain` plus `jobTitle` when you only know the employer and the role; the Actor probes the
registrable-domain slug against both supported boards and uses whichever answers with a board that
genuinely corroborates the domain — a best-effort heuristic, not a directory service, so a miss returns
the free `board_not_found` or `ambiguous_company_match` outcome rather than a guess.

**Reading `atsProvider` before trusting how a row got there.** Greenhouse rows often carry the
employer's own canonical link, which this Actor can corroborate and use; Lever rows structurally
cannot, because Lever's own feed never publishes a company name. A Lever-heavy input set should expect
a materially higher free rate on `company_domain_unresolved`/`ambiguous_company_match` than a
Greenhouse-heavy one — a property of the source, not of this Actor's matching logic.

**Understanding why `recommendedAction` and `confidenceScore` are `null` on every free row today.**
This build writes exactly one `recommendedAction` value, `human_review_before_outreach`, only on the
billable row; `confidenceScore`/`confidenceBand` are likewise computed only when an owner is resolved.
A free row's explanation lives in `errorCode`, `error` and, where relevant, `partialReason`.

**Understanding deduplication.** Two different questions are answered at two different times: the
vacancy's own identity is deduplicated before any network request, and a second owner-match at the
same company domain is deduplicated again after resolution — this second check matches on EITHER the
normalized full name or the mailbox, either one being enough — reported free as
`duplicate_owner_contact` — see Evidence and boundaries for the full reasoning and its one disclosed
trade-off (two differently-employed people sharing a name are merged into one billed contact).

**Understanding replay safety, and its one real limitation.** Every billable delivery is journaled in
phases before it ever reaches you: reserved before any board or provider request, a delivery record
written before the atomic Dataset write, confirmed only once the platform receipt is in hand. A worker
interrupted between the write and its receipt leaves a journaled record, not a hole — the next process
recognizes it as already delivered and skips it. That holds when the interruption lands after the
platform write resolved. If it lands earlier — the delivery record exists but no row was ever written —
the next run does not continue quietly: it cannot tell a charge that happened from one that did not, so
it refuses to start and reports the journal entry as ambiguous. Clearing that state is a human action.
Plan unattended schedules with that in mind: the failure mode is a stopped schedule, not a silently
missing row. The same mechanism also protects a *free* budget-stop row: a re-run against the same
storage replays the same free decision rather than re-attempting the vacancy, even after you raise your
spend cap. This is deliberate, not an oversight: retrying that vacancy needs a genuinely new Task run,
because loosening the guarantee for a free outcome would touch the identical machinery that keeps a
paid charge from ever firing twice.

**Understanding the run-wide request budgets.** The job/company half of a run is capped at 300 actual
HTTP requests and 250 logical source reads (robots policy plus board reads combined) across the whole
run; the person half is capped separately at 300 actual requests to your provider. Exceeding either
returns the free `partial_source_coverage` outcome for whatever could not be reached, never a paid row
built on an incomplete read.

### FAQ

#### Does this Actor guess email address patterns?

No, under any path this build has. The billable address always comes from your own provider account's
own identity-matched, positively verified result. There is no pattern-synthesis code anywhere in this
Actor.

#### Can I automatically send outreach based on an `owner_match` row?

The Dataset schema is designed to make that hard to do by accident. `safeToAutomate` and
`safeForOutreach` are both fixed `false`, and `recommendedAction` is a queue label for a human, not an
authorization string for an automated send integration.

#### What happens if I submit a Lever posting?

It is read, searched and matched normally — but it will not produce a billable row, because Lever's
feed never publishes the employer's name and this Actor requires a corroborated employer identity
before billing the person half. See Evidence and boundaries for the full reasoning.

#### Why does the input schema accept five provider names if only one is implemented?

All five publish BYOK-compatible terms in principle; only Hunter's call pattern is built and tested.
Selecting one of the other four returns the honest free `provider_not_implemented` outcome rather than
a misleadingly empty result.

#### What happens if I supply a job title in a language other than English?

Role recognition is English-only. A non-English title returns the free `no_relevant_owner_returned`
outcome rather than a wrong guess built on an unrecognized word.

#### If a run stops early on a spend cap, does raising the cap and re-running pick up where it left off?

Not against the same Dataset and key-value store — the free budget-stop row is replay-protected the
same way a paid row is. Retrying a budget-stopped vacancy requires a genuinely new Task run.

#### Does two people sharing the same name at the same company ever cause a problem?

Yes, in one disclosed direction: two different mailboxes under one normalized name are an unresolvable
tie (`ambiguous_owner`), free; the identical mailbox surfacing twice is deduplicated as
`duplicate_owner_contact` — which also means two different employees sharing a normalized name are
merged into one billed contact. See Evidence and boundaries.

#### Is this Actor affiliated with Hunter, Apollo, Prospeo, Snov, RocketReach, Greenhouse, Lever or Ashby?

No. Those names appear on this page because they are the real market options and public boards this
Actor's own code actually checked or integrates with — not because any of them has approved, endorsed
or contracted with this Actor.

### Sources and rights

**Job/company half — public boards, read without a key of ours.** Greenhouse's board API
(`boards-api.greenhouse.io`) publishes a `robots.txt` that returns HTTP 200 and disallows only
`/embed/`; live reads return full job listings with `company_name` present. Lever's postings API
(`api.lever.co`) publishes a `robots.txt` that returns HTTP 200 with `Crawl-delay: 1`, which this Actor
honors as a hard floor. Neither board's response carries an explicit redistribution license — robots
permission establishes only that automated reading is not disallowed, recorded as such rather than
overstated.

**Ashby is excluded by decision, not by omission.** `api.ashbyhq.com/robots.txt` returns HTTP 401 — the
policy itself cannot be read. This Actor's fail-closed convention treats an unreadable robots policy as
no permission, so Ashby boards are never read.

**Person/email half — your own provider account, never ours.** You supply your own key for a
contact-data provider as a secret input, called on your own account's quota; this Actor never stores,
logs, reuses across runs, or redistributes that key or the data it returns beyond the single row it
produces for your run. The publicly listed Apify Actor `canadesk/hunter-io` already operates on exactly
this buyer-supplied-key pattern. Hunter's, Apollo's and RocketReach's own published terms each restrict
an operator from reselling their data to third parties without a separate written agreement; BYOK is
outside that restriction's scope because the buyer, not this Actor, is the provider's direct
contracting party. Snov's terms were not read in comparable depth; a similar restriction is presumed by
analogy but not independently confirmed.

### Limits

- One to one hundred jobs per run; `maxConcurrency` 1–5, and source work stays sequential on a
  monetized run regardless of that setting.
- Only Greenhouse and Lever boards are read; Ashby is excluded by decision, and any other board host
  returns the free `ats_not_supported` outcome without a request being made.
- Only Hunter's call pattern is implemented; the other four accepted provider names return the free
  `provider_not_implemented` outcome.
- No email address is ever pattern-guessed, MX/SMTP-probed or catch-all-tested.
- Role recognition — who counts as a relevant recruiter, HR leader or department owner — is
  English-only.
- Lever postings are read normally but cannot currently produce a billable row, because Lever's own
  feed never publishes the employer's name.
- The same contact at one company is billed at most once per run — matched on either the normalized
  full name or the mailbox, either one being enough — which also merges two different people who share
  a normalized full name at the same company into one billed contact. That scope covers a resumed run
  as well as a single process: the contact keys are persisted in the same write that records the
  delivery, so a vacancy reached only after a restart sees contacts billed before it. This is a
  statement about scope, not a promise of exactly-once delivery — the interruption case described under
  replay safety in the Operating guide still applies.
- A free budget-stop row is replay-protected the same way a paid row is; retrying it requires a new
  Task run against fresh storage.
- Run-wide caps: 300 actual HTTP requests and 250 logical source reads for the job/company half, 300
  actual requests for the person half, and a 12,000,000-byte ceiling on any single board response.
- A domain that spells a name's diacritic out in full as a transliteration (`gruner-soehne.de` for
  "Grüner Söhne GmbH") does not yet corroborate — a known, disclosed gap on the conservative side: a
  legitimate pairing like this is refused rather than sold. A lost sale, never a wrong one — the same
  trade this whole document makes throughout.
- `safeToAutomate` and `safeForOutreach` are fixed `false` for every row this schema allows.

This build was independently reviewed multiple times before release, with every substantive finding
fixed as a general rule rather than patched as a one-off example.

### Support boundary

Actor support covers deterministic input validation, the source-access and owner-matching behavior
described on this page, and the Dataset/Output/key-value-store schemas. It cannot decide whether a
specific outreach action is appropriate for your jurisdiction, cannot restore access if Greenhouse,
Lever or your provider changes its own API or terms, cannot guarantee a company keeps publishing the
same vacancy or field, and cannot provide legal advice on outreach compliance or your provider's terms.
When reporting a problem, include the Actor build/run ID, the redacted `entityId`, `verificationStatus`,
`errorCode` and approximate `observedAt` time. Never send full provider responses or any credential —
this Actor's own code never logs or stores your provider key, and support will never ask for it outside
the Input form.

# Actor input Schema

## `jobs` (type: `array`):

One to 100 vacancies. Give either a direct ATS posting URL, or a company domain plus the exact job title.

## `providerName` (type: `string`):

Which provider your key belongs to. Only meaningful together with a provider API key.

## `providerApiKey` (type: `string`):

Your own key, used on your own quota. It is never stored, never logged, never reused across runs and never redistributed. Without it, no owner lookup is attempted and the vacancy returns free.

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

Vacancies processed concurrently only in local non-monetized runs. On-platform source work and paid delivery are sequential, and Lever pacing stays at one request per second regardless of this value.

## Actor input object example

```json
{
  "jobs": [
    {
      "companyDomain": "stripe.com",
      "jobTitle": "Account Executive, Bridge"
    }
  ],
  "maxConcurrency": 1
}
```

# Actor output Schema

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

API URL for uniform hiring\_owner\_decision rows in the default dataset.

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

Validated OUTPUT record with submitted, delivered, billed, free-by-category, partial and early-stop counts.

# 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 = {
    "jobs": [
        {
            "companyDomain": "stripe.com",
            "jobTitle": "Account Executive, Bridge"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/job-posting-to-hiring-manager-email").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 = { "jobs": [{
            "companyDomain": "stripe.com",
            "jobTitle": "Account Executive, Bridge",
        }] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/job-posting-to-hiring-manager-email").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 '{
  "jobs": [
    {
      "companyDomain": "stripe.com",
      "jobTitle": "Account Executive, Bridge"
    }
  ]
}' |
apify call zinin/job-posting-to-hiring-manager-email --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zinin/job-posting-to-hiring-manager-email"
        }
    }
}

```

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/vFciihSaoLcj8sVrd/builds/7amvIhIce8gq4D6yO/openapi.json
