# Domain to Verified Work Email (`zinin/domain-to-verified-work-email`) Actor

Find the work email a named person publishes on their own company website, confirmed by a same-document schema.org Person assertion. No pattern guessing, no SMTP probing. $0.01 only when a published match is found; no-match, role mailbox, ambiguous and partial outcomes are free.

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

## Pricing

from $8.50 / 1,000 published person matches

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

## Domain to Verified Work Email: Published-Evidence Person Matching for Company Websites

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

Get one work email per person only when the company's own public website publishes that literal
address, names the person in the same visible context, and separately asserts the exact
name-to-address pairing in machine-readable schema.org Person markup on the same page.

This is not a guessing service. It does not synthesize `first.last@domain` patterns, does not call
MX or SMTP servers, does not run a catch-all probe, and does not treat "the mailbox accepted the
message" as proof that a specific human owns it. Every non-free row in the Dataset is a **published
person match** — the single billable outcome this product sells, and the only noun used for pricing,
listing terminology and Dataset semantics throughout this page. Everything else the run finds is
free: no-match, role-mailbox-only, ambiguous, unlinked, robots-blocked, partial or budget-stopped.

### What you get

For each `{fullName, domain}` pair you submit, the Actor visits only the buyer-selected company's
own website, reading at most four fixed pages in a fixed relevance order — `/team`, `/about`,
`/contact`, then `/` — and returns exactly one of two outcomes:

- **A published person match.** The literal email address exists as visible text or an explicit
  `mailto:` link on an allowed page. The requested full name appears in the same bounded visible
  context. The local part of the address follows a deterministic personal-name form (first, last,
  first.last, first+last, first-initial+last, or last+first-initial). The address is not a role
  mailbox, placeholder, image filename or third-party domain. And — the condition that makes this
  product different from a contact-page scraper — the same HTML document also carries valid
  bounded JSON-LD with an exact `schema.org` context, `@type` of `Person` (or the full schema.org
  Person URL), whose normalized `name` and literal `email` exactly match the requested person and
  the candidate address. Ordinary prose next to an address is never treated as this assertion; only
  a genuine machine-readable Person claim on the same page counts.
- **An honest free decision.** No literal address at all, an address tied to a role or a different
  person, two candidates that both clear the bar (an explicit tie), a page the runtime could not
  read, a page `robots.txt` disallowed, or a budget stop before or after the source read completed.
  Every free decision still returns a full row with checked pages, unreachable pages, robots
  exclusions and a `partialReason` or plain no-match reason — never a bare empty result.

The Actor never claims mailbox ownership, consent to be contacted, present-day deliverability or
willingness to receive outreach. Those four claims are outside what a published web page can prove,
so every row — paid or free — carries `safeToAutomate:false` and `safeForOutreach:false` as
non-negotiable machine flags, not just prose caveats. A published person match is evidence to
route to a human for a compliance-checked next step, not a green light for automatic email sending.

### Who uses it

- **Sales and partnerships teams** building an enrichment step for a CRM record where an
  unverified guessed address already causes bounce-rate and deliverability damage, and who need an
  auditable "here is the exact page and the exact machine-readable claim" record before a human
  approves outreach.
- **Recruiting and sourcing teams** confirming a public work address for a named candidate before
  a manual outreach step, without scraping a personal inbox or a paid people-search database — only
  what the target company itself already publishes.
- **Compliance and data-quality reviewers** who need a defensible reason to accept or reject an
  address already sitting in a CRM or spreadsheet — this Actor's row is the reason, with source URL,
  bounded-context hash and observation time attached.
- **Developers and workflow builders** who want a strict, flat, machine-checkable contract instead
  of parsing prose — an agent, an n8n node or an MCP client can branch on `verificationStatus` and
  `found` without guessing at free text.
- **Internal automation agents** operating under human review — the Actor is explicitly designed so
  an LLM-driven workflow can consume its output safely: every non-terminal state is machine-typed,
  and the two safety flags default to false so an agent cannot silently escalate a free or ambiguous
  row into an outreach action.

This is a one-person, one-domain, on-demand utility. It is not a monitor, not a bulk company-wide
crawler, not a mailbox verifier and not a people-search database. It does not accept a list of
guessed addresses to "check" — it only accepts a name and a domain and only ever returns an address
it found published, never one it invented.

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

### How to run

1. Open the Actor's Input form (Console, API or an MCP client) and supply one to fifty
   `{fullName, domain}` objects, each with an optional `companyName` hint and an optional
   `inputRef` you control for CRM correlation.
2. Optionally cap `maxPagesPerPerson` (1–4, default 3) and `maxConcurrency` (1–5, default 1;
   platform-billed work is always processed sequentially regardless of this setting — it only
   affects local, non-monetized batching).
3. Start the run from the Console **Start**, the Apify API `runs` endpoint with your token, an
   Apify Task on a schedule, or an MCP tool call using the same strict input.
4. Read the default Dataset for one row per submitted person — every row is present even for
   duplicates, ambiguous pages or source failures, so row count against input count is itself a
   completeness check.
5. Read the key-value store record `OUTPUT` for one run-level summary: input rows, unique people
   after de-duplication, duplicates skipped, matched count, free-decision count, partial count,
   budget-stopped count, whether the run stopped early, and why.
6. Route every `published_person_match` row to a human-reviewed next step. Do not wire the paid
   outcome directly into an autosend integration; the safety flags exist specifically to prevent
   that shortcut.

No login, browser session, proxy or CAPTCHA-solving step is ever required to run this Actor,
because none of those techniques are part of its source-access design (see Sources and rights,
below). A default-input smoke run does not require any buyer secret.

### Pricing

**$0.01 per published person match, plus a $0.005 Actor start fee.** Every other outcome — no-match,
role-address-only, unlinked personal address, ambiguous tie, robots-blocked page, unreachable page,
partial coverage, or a budget stop before or after a source read — costs nothing beyond the flat
start fee. The default Dataset item event itself is priced at exactly zero, so reading rows back
never re-bills you.

The only data source is the company's own public website that you selected — there is no
third-party data license or purchased list behind this Actor, so there is nothing to resell and
no separate data bill on top of the price above.

The billable unit is always the same **published person match** — never a page read, never a
"lookup," never a flat per-input charge. A run of fifty people where only three clear the full
evidence bar bills for three published person matches ($0.03) plus the one start fee, not fifty.

### Input contract

`people` is required: one to fifty objects. Each object requires `fullName` (a real first-plus-last
name, 3–160 characters, must contain at least two whitespace-separated tokens) and `domain` (a bare
public hostname, 4–253 characters — schemes, paths, credentials and IP literals are all rejected by
pattern before any network call). `companyName` is an optional page-evidence hint, never used to
invent an address. `inputRef` is an optional opaque string, up to 200 characters, copied back to
the result row exactly as given — including leading, trailing, repeated or tab whitespace — for
CRM/workflow correlation; it is validated but never normalized or interpreted.

You cannot supply a path, a full URL or a page list. The runtime alone selects the fixed,
relevance-ordered page set — `/team`, `/about`, `/contact`, `/` — and `maxPagesPerPerson` only
narrows how many of those four the run is willing to read for a given person, not which ones.
`maxConcurrency` only affects local batching; it never parallelizes billed source work.

Unicode names and internationalized domain names are normalized through the current Public Suffix
List, including private-boundary entries. Bare public suffixes, URLs, embedded credentials and IP
literals are rejected before any source call. Duplicate `{normalized full name, registrable domain}`
pairs are collapsed once, before any page is fetched, so re-submitting the same person against the
same company twice in one run never double-charges you.

```json
{
  "people": [
    {
      "fullName": "King Lee",
      "domain": "visionwide.co",
      "companyName": "Visionwide",
      "inputRef": "crm-lead-40217"
    },
    {
      "fullName": "Dana Osei",
      "domain": "examplecorp.com",
      "companyName": "Example Corp",
      "inputRef": "crm-lead-40218"
    }
  ],
  "maxPagesPerPerson": 3,
  "maxConcurrency": 1
}
```

The runtime independently re-validates every field and rejects unknown properties and wrong JSON
types even where the platform schema would already have caught them, so a caller bypassing the
Console form through the raw API gets the same guarantees as one using it.

### Example output

Every row shares one flat 31-field schema regardless of outcome, so a database, dataframe or
workflow condition never needs to branch on record shape — only on field values. The three rows
below are illustrative examples built directly from `dataset_schema.json` and the matching
contract, showing the exact field shape and values this Actor emits for each outcome class.

#### Published person match

```json
{
  "recordType": "person_email_decision",
  "schemaVersion": "1.0",
  "entityId": "5c64f059712997f402463d54",
  "inputRef": "crm-lead-40217",
  "fullName": "King Lee",
  "companyName": "Visionwide",
  "domain": "visionwide.co",
  "submittedDomain": "visionwide.co",
  "email": "king.lee@visionwide.co",
  "verificationStatus": "published_person_match",
  "verificationLevel": "published_person_to_address_match",
  "matchScore": 100,
  "matchReasons": [
    "literal_email_published",
    "same_registrable_company_domain",
    "full_name_on_page",
    "personal_local_part_matches_name",
    "schema_org_person_name_email_assertion",
    "full_name_in_same_bounded_dom_context",
    "company_name_on_page"
  ],
  "sourceUrl": "https://visionwide.co/team",
  "sourcePath": "/team",
  "sourceEvidence": {
    "literalEmailPublished": true,
    "fullNameOnPage": true,
    "personalLocalPart": true,
    "publicationChannel": "visible_text",
    "boundedContextSha256": "8b60d44f3fd265e726822263b87b1cf471d25ff506ed91690068718154d27246",
    "fullNameInSameContext": true,
    "schemaOrgPersonAssertion": true,
    "disclaimer": "Visible published name-to-address evidence plus an exact schema.org Person assertion, source URL and observation time. Not mailbox ownership, consent, deliverability or outreach permission."
  },
  "checkedPages": [
    { "path": "/team", "url": "https://visionwide.co/team", "status": 200, "truncated": false },
    { "path": "/about", "url": "https://visionwide.co/about", "status": 200, "truncated": false }
  ],
  "unreachablePages": [],
  "robotsDisallowed": [],
  "confidenceScore": 100,
  "confidenceBand": "high",
  "recommendedAction": "ingest_evidence_then_apply_your_legal_outreach_policy",
  "safeToAutomate": false,
  "safeToIngestEvidence": true,
  "safeForOutreach": false,
  "partial": false,
  "partialReason": null,
  "errorCode": null,
  "error": null,
  "retryable": false,
  "observedAt": "2026-08-11T14:32:07.418Z",
  "found": true
}
```

Every key, value shape and const in this row matches the actual `dataset_schema.json` conditional
rules for `found:true` — the exact seven `matchReasons` tokens, the exact eight-key
`sourceEvidence` object, the fixed `recommendedAction` string, and `matchScore`/`confidenceScore`/
`confidenceBand` pinned to `100`/`100`/`"high"` are the only values the schema allows for a paid row.

#### Partial — coverage could not be completed

```json
{
  "recordType": "person_email_decision",
  "schemaVersion": "1.0",
  "entityId": "2ec54ae61eb6352a443897e7",
  "inputRef": "crm-lead-88031",
  "fullName": "Priya Chandran",
  "companyName": null,
  "domain": "northfieldstudio.io",
  "submittedDomain": "northfieldstudio.io",
  "email": null,
  "verificationStatus": "partial_source_coverage",
  "verificationLevel": "none",
  "matchScore": 0,
  "matchReasons": [],
  "sourceUrl": null,
  "sourcePath": null,
  "sourceEvidence": {
    "candidateCount": 0,
    "roleAddressCount": 0,
    "unlinkedPersonalAddressCount": 0,
    "thirdPartyAddressCount": 0,
    "disclaimer": "One allowed page could not be read before the page budget was exhausted; no candidate is eligible for payment from an incomplete scan."
  },
  "checkedPages": [
    { "path": "/team", "url": "https://northfieldstudio.io/team", "status": 200, "truncated": false },
    { "path": "/contact", "url": "https://northfieldstudio.io/contact", "status": null, "truncated": false }
  ],
  "unreachablePages": [
    {
      "path": "/contact",
      "status": null,
      "errorCode": "SOURCE_TIMEOUT",
      "error": "Source request timed out",
      "retryable": true
    }
  ],
  "robotsDisallowed": [],
  "confidenceScore": 0,
  "confidenceBand": "none",
  "recommendedAction": "retry_source_later",
  "safeToAutomate": false,
  "safeToIngestEvidence": false,
  "safeForOutreach": false,
  "partial": true,
  "partialReason": "source_page_unavailable",
  "errorCode": "SOURCE_TIMEOUT",
  "error": "One allowed page (/contact) timed out before the run established complete coverage; the pages that were read are still reported above.",
  "retryable": true,
  "observedAt": "2026-08-11T14:41:52.005Z",
  "found": false
}
```

Notice that `/contact` appears in both `checkedPages` (attempted, no status obtained) and
`unreachablePages` (the failure detail) — the schema requires both entries together so a consumer
never has to infer an attempt from a failure record alone. `errorCode`/`error`/`retryable` here use
the exact bounded `SOURCE_TIMEOUT` family the schema defines for a genuine timeout, not a free-text
guess.

#### Free — role mailbox found, no person match

```json
{
  "recordType": "person_email_decision",
  "schemaVersion": "1.0",
  "entityId": "f31dd870b14954a167a92211",
  "inputRef": "crm-lead-40218",
  "fullName": "Dana Osei",
  "companyName": "Example Corp",
  "domain": "examplecorp.com",
  "submittedDomain": "examplecorp.com",
  "email": null,
  "verificationStatus": "role_address_only",
  "verificationLevel": "none",
  "matchScore": 0,
  "matchReasons": [],
  "sourceUrl": null,
  "sourcePath": null,
  "sourceEvidence": {
    "candidateCount": 0,
    "roleAddressCount": 1,
    "unlinkedPersonalAddressCount": 0,
    "thirdPartyAddressCount": 0,
    "disclaimer": "Complete allowed-page coverage found only a published role mailbox; a role address never satisfies person-level evidence, however close the name appears."
  },
  "checkedPages": [
    { "path": "/team", "url": "https://examplecorp.com/team", "status": 200, "truncated": false },
    { "path": "/about", "url": "https://examplecorp.com/about", "status": 200, "truncated": false },
    { "path": "/contact", "url": "https://examplecorp.com/contact", "status": 200, "truncated": false }
  ],
  "unreachablePages": [],
  "robotsDisallowed": [],
  "confidenceScore": 0,
  "confidenceBand": "none",
  "recommendedAction": "review_or_supply_a_different_public_source",
  "safeToAutomate": false,
  "safeToIngestEvidence": false,
  "safeForOutreach": false,
  "partial": false,
  "partialReason": null,
  "errorCode": null,
  "error": null,
  "retryable": false,
  "observedAt": "2026-08-11T14:44:19.771Z",
  "found": false
}
```

Notice what stays constant across all three rows: the same 31 keys, the same nullable-not-missing
shape, and the same pair of safety flags fixed at `false`. A consumer never has to sniff which
fields exist before reading a row.

### Field dictionary

| Field | Meaning | Important boundary |
|---|---|---|
| `recordType` | Always `"person_email_decision"` | Constant discriminator; safe for schema routing |
| `schemaVersion` | Always `"1.0"` | Bump on any breaking Dataset change, never silently |
| `entityId` | Stable 24-hex-character row identity | Deduplication key, not a CRM record ID |
| `inputRef` | Your caller reference, copied exactly | Never normalized; whitespace preserved on purpose |
| `fullName` | Normalized requested name | Echo of input, not a claim the person exists at the domain |
| `companyName` | Optional company hint you supplied | Evidence signal only; never used to fabricate an address |
| `domain` | Normalized registrable company domain | PSL-normalized; IDN and Unicode handled before matching |
| `submittedDomain` | The domain string EXACTLY as your input spelled it | When it differs from `domain`, IDNA folded a confusable spelling (e.g. a fullwidth letter) onto the ASCII domain — compare the two before acting on the row |
| `email` | The literal published address | `null` on every free row — never a guessed pattern |
| `verificationStatus` | One of ten deterministic outcome codes | Only `published_person_match` is billable |
| `verificationLevel` | `published_person_to_address_match` or `none` | Describes what kind of evidence was found, not a confidence score |
| `matchScore` | 0–100 deterministic evidence score | The only matcher-reachable paid value is exactly 100 |
| `matchReasons` | Named evidence tags for a match | Empty array on every free row |
| `sourceUrl` | The exact page the match came from | `null` unless one page carries the full assertion |
| `sourcePath` | Which of the four allowed paths matched | `null`, `/team`, `/about`, `/contact` or `/` — never a caller-supplied path |
| `sourceEvidence` | Structured evidence object with a mandatory disclaimer | `disclaimer` is present on every row, paid or free |
| `checkedPages` | Ordered list of pages actually read | Unique paths only, drawn from the fixed four-path sequence |
| `unreachablePages` | Pages that could not be read, with error detail | Every entry's `errorCode`/`error`/`retryable` triple is drawn from one closed, schema-enforced family — never a free-text guess |
| `robotsDisallowed` | Allowed-set paths `robots.txt` blocked | The Actor never reads a disallowed path to "check anyway" |
| `confidenceScore` | 0–100 routing-confidence number | Not a probability-of-correctness statistic; a deterministic bucket key |
| `confidenceBand` | `high`, `ambiguous` or `none` | Coarse routing bucket for `confidenceScore` |
| `recommendedAction` | One of a small closed set of queue labels, pinned by `verificationStatus`/`retryable` | A routing tag for a human queue, never an authorization to act — the exact string per status is schema-enforced, not freely chosen at write time |
| `safeToAutomate` | Always `false` | Fixed by the Dataset schema; no future row can flip this without a schema version bump |
| `safeToIngestEvidence` | Whether the row's evidence is complete enough to store downstream | Distinct from `safeForOutreach` — evidence can be ingestible without being outreach-ready |
| `safeForOutreach` | Always `false` | This product never certifies a message is safe to send |
| `partial` | Whether complete allowed-page coverage was established | A `true` row can never carry `verificationStatus:"published_person_match"` |
| `partialReason` | Why coverage was incomplete | `null` unless `partial:true`; otherwise exactly one of `source_page_unavailable`, `source_page_truncated`, `unsupported_content_type`, `robots_disallowed_page`, `robots_unavailable_fail_closed`, `matcher_work_limit_exceeded`, `budget_stopped_before_source_read`, `budget_stopped_after_source_read`, `person_processing_error` |
| `errorCode` | Bounded machine error code | `null` on a clean decision; present on every unreachable/failure row |
| `error` | Short bounded human-readable error text | Never contains request headers, cookies or page bodies |
| `retryable` | Whether a bounded retry is appropriate | Does not override the run's own budget and deadline caps |
| `observedAt` | When this Actor observed the source | Distinct from any timestamp the source page itself displays |
| `found` | Whether this row is the one billable outcome class | The only field a workflow strictly needs to gate payment logic |

### Evidence and boundaries

**What "verified" does and does not mean here.** In this product, `verified` means *visible
published person-to-address evidence plus an exact schema.org Person name-email assertion, with
source URL and observation time*. It does not mean mailbox ownership, consent, present
deliverability or willingness to receive outreach. That sentence is not marketing language — it is
copied from the product contract and repeated in every accepted row's `disclaimer` field so the
boundary travels with the data, not just this page.

**Why the schema.org requirement exists.** A literal address next to a name in ordinary prose is
weak evidence — company pages routinely list people near addresses that belong to someone else,
near role mailboxes, or near stale contact blocks. Requiring the *same page* to independently assert
the exact pairing through machine-readable `schema.org/Person` JSON-LD removes an entire class of
false positives that a text-proximity-only extractor would happily bill for. If a page has the
name, has the address, and even has *a* JSON-LD block — but that block does not name-and-email match
the exact requested candidate, uses an aliased or missing `@context`, or is embedded as an object
rather than the expected shape — the row is free, not paid.

**Why hidden content is excluded before matching.** Real company pages hide old staff blocks,
template placeholders and legacy contact cards with `display:none`, zero-opacity layers, off-screen
positioning, closed `<details>` elements and CSS techniques ranging from simple inline styles to
`!important` cascades and CSS-escape obfuscation. The Actor deterministically removes script,
template, hidden-input and datalist/option nodes, fetches the stylesheets the page links (from any
HTTPS host whose own `robots.txt` permits it — a fonts service, a CDN, an asset subdomain), and
evaluates the bounded CSS cascade for hides, foreground/background contrast and tiny-box clipping.

**How uncertainty is scoped: to the element, not the page.** A CSS rule the engine cannot evaluate
exactly — an `@media`/`@supports` block, a complex selector, a `var()` value — marks the elements
that rule could touch as visibility-unknown. A candidate address whose chain touches a marked
element is never billed: the row comes back as a free `partial_source_coverage` with
`partialReason: matcher_work_limit_exceeded` and the exact reason in `error`. A competitor on a
marked element still counts as evidence and still withholds a sale. Only CSS whose reach cannot be
bounded at all — an `@import`, an unknown at-rule, a stylesheet that could not be fetched, or a page
past the published work ceilings (64 fetched sheets of up to 600 KB each, 8M CSS characters, 65,536
visibility-relevant rules, 131,072 selectors, 1,048,576 selector-target applications) — makes
the whole page unknowable, again as a free partial. Measured on live sites the day this shipped:
tailscale.com (Tailwind v4, cross-host sheets, 17,500 selectors per page) completes cleanly, and
visionwide.co (WordPress + bootstrap, 33 sheets) evaluates fully and correctly refuses to bill an
address that its theme shows only inside a click-to-open popup. The honest residue: an address whose
visibility genuinely depends on viewport or unevaluable values stays unbilled — a withheld row costs
a sale, a wrong row costs your trust and your money.

**Why role addresses and weak local parts are rejected.** `contact-us`, `customer-care`, `editorial`,
`recruiters`, `human-resources`, `admissions` and a defined family of similar role-token variants
never become paid rows, even if a name happens to sit nearby. Weak local-part forms (first-name-only
or last-name-only) additionally require the requested full name in the immediate bounded evidence
context and fail closed if that context contains a conflicting person name. This is a deliberate
asymmetry: the bar for treating something as a real personal address is higher than the bar for
treating it as merely present text.

**Why the source path is narrow by design.** The Actor reads only the buyer-selected company's own
public website, bounded to the same registrable domain, at most four fixed pages
(`/team`, `/about`, `/contact`, `/`), over HTTPS on the default port only. The single exception is
rendering context: stylesheets those pages link are fetched from whatever HTTPS host serves them,
each under its own `robots.txt`, and never contribute evidence — only visibility. Every hostname is
resolved once per run and pinned to globally routable addresses before any connection; private,
loopback, metadata, documentation, transition and mixed private/public resolutions are all blocked
before a socket opens. Redirects are followed manually, only within the same public-suffix
registrable-domain boundary, and every hop re-verifies scheme, domain and address. There is no
browser, no JavaScript execution, no login, no CAPTCHA-solving and no proxy — this is a static-HTML
reader with a hard run-wide cap of 300 actual HTTP requests.

**Why `robots.txt` is honored strictly, not politely.** The policy file is fetched fail-closed: if it
is missing, ambiguous, wrongly typed, redirected through a login page or disallows the target path,
that path is simply not read — not read-with-a-warning. The compiled policy respects exact
user-agent groups over wildcard groups, closes ambiguous or malformed directive groups before any
page request, and matches wildcard/end-anchor rules with literal-segment matching rather than
compiling caller-influenced text as a regular expression.

**Why two identical evidence rows are not each other's proof of correctness.** If two candidate
addresses on the same domain both carry a complete, valid schema.org Person assertion for the
requested name, the run does not guess a winner — it returns an explicit free tie. Certainty, not
volume of near-misses, is what this product sells.

### Decision routing

Every row's `verificationStatus` maps to exactly one of the following ten outcomes. A downstream
workflow should switch on this field directly rather than re-deriving it from `email`, `found` or
prose.

| `verificationStatus` | Billable? | What it means | `recommendedAction` you will see |
|---|---|---|---|
| `published_person_match` | Yes | Complete, unambiguous, schema.org-backed evidence | `ingest_evidence_then_apply_your_legal_outreach_policy` |
| `ambiguous_multiple_matches` | No | Two or more candidates each cleared the evidence bar | `retry_source_later` if `retryable:true`, else `review_or_supply_a_different_public_source` |
| `partial_match_not_billable` | No | Some but not all conditions were met | `retry_source_later` if `retryable:true`, else `review_or_supply_a_different_public_source` |
| `partial_source_coverage` | No | Not every allowed page could be read | `retry_source_later` if `retryable:true`, else `review_or_supply_a_different_public_source` |
| `role_address_only` | No | Only a role mailbox was found | `review_or_supply_a_different_public_source` |
| `unlinked_personal_address_only` | No | A personal-shaped address exists but lacks the schema.org assertion or name-context link | `review_or_supply_a_different_public_source` |
| `no_published_person_match` | No | Complete coverage, genuinely nothing found | `review_or_supply_a_different_public_source` |
| `budget_stopped_before_source_read` | No | The run's cap was reached before this person was processed | `raise_max_total_charge_or_reduce_input` |
| `budget_stopped_after_source_read` | No | The cap was reached after reading pages but before a result could be delivered | `raise_max_total_charge_or_reduce_input` |
| `partial_processing_error` | No | An internal processing limit or error stopped this person's evaluation | `retry_source_later` |

Every value in that last column is a schema-enforced constant, not free text — the Dataset schema
pins the exact string per status (and, for the three retry-eligible statuses, per `retryable` value
too), so a downstream switch statement can match on it exactly rather than pattern-matching prose.

A workflow implementation only needs a handful of rules to stay correct:

- Only continue an automated pipeline step on `found === true` **and** `partial === false`.
- Never branch on the presence of `email` alone — `email` is `null` on every free row by design, and
  a non-null `email` can never appear outside `published_person_match` under the enforced schema, so
  treat any mismatch as a hard integration bug, not a valid state.
- Treat `ambiguous_multiple_matches` and `unlinked_personal_address_only` as "evidence exists but is
  not sufficient," not as a weaker version of a match — they are just as unbillable as a clean
  no-match, and neither is eligible for a lower-confidence autosend tier.
- Retry only rows where `retryable === true`, and only after the specific delay/backoff your
  workflow already applies to other bounded-source jobs — this Actor's own run-level cap does not
  retry automatically.
- Deduplicate downstream on `entityId`, not on `{fullName, domain}`, since the entity ID is stable
  across identical resubmission while your own upstream normalization might differ slightly.

### Commercial playbooks

#### CRM email-quality gate

Before a guessed or purchased address is allowed into an active outreach sequence, run it — or the
name/domain pair it came from — through this Actor. A `published_person_match` becomes supporting
evidence for a human reviewer's decision to proceed; anything else becomes a reason to hold the
record for manual verification rather than let a guessed pattern degrade sender reputation. This
playbook explicitly does not wire the result into an autosend step; the Actor's own safety flags
are designed to stop that shortcut, and the human review step is not optional.

#### Recruiting candidate-contact confirmation

When a sourcing workflow already has a candidate's public profile and target company, this Actor
checks whether that same company's own site independently publishes a matching, schema-asserted
address for that exact name. A match gives a recruiter a defensible, source-linked reason to reach
out through the confirmed channel instead of a purchased database guess; a free result is a signal
to fall back to the platform's native messaging instead of fabricating an email.

#### Partner and vendor identity verification

Before onboarding a named contact from a partner or vendor company into a billing, support or
security-notification list, this Actor confirms that the company's own website — not a third-party
aggregator — currently publishes that person at that address with a machine-readable assertion.
This is a lightweight identity-hygiene check, not a KYC or sanctions-screening substitute.

#### CRM data-quality audit sweep

Run a batch of existing CRM contacts (name + company domain, using `inputRef` to carry the CRM
record ID) to find stale or fabricated-looking addresses. Rows with `no_published_person_match` or
`role_address_only` for a contact your CRM currently lists as a personal, verified address are a
worklist for data-hygiene review — not an automatic field overwrite, since the source page not
currently publishing an address does not prove the address itself is wrong.

#### Support and escalation contact confirmation

For B2B accounts where an internal contact list has gone stale, this Actor can re-confirm whether a
named account contact still has a personal address published on the vendor's own site before an
account manager escalates an issue directly instead of through a shared support alias.

### Integration recipes

The Actor is built as a machine-first microservice: a strict, closed Input schema; a uniform,
strictly typed Dataset; and a separately schema-validated `OUTPUT` summary record. Callers can drive
it through the Apify Console, the Apify API, a scheduled Apify Task, or an MCP client using the same
Input contract described above.

#### Scheduled CRM enrichment pattern

1. A buyer-owned Apify Task stores the strict Input — no secrets are required by this Actor's own
   contract, since it reads only public pages.
2. A schedule starts a bounded run against a queue of `{fullName, domain, inputRef}` triples pulled
   from CRM records that currently lack a verified address.
3. The workflow waits for terminal success and reads KVS `OUTPUT` for `matched`, `freeDecisions`,
   `partial` and `budgetStopped` counts before touching Dataset rows.
4. It reads Dataset rows and routes every `published_person_match` into a human review queue keyed
   by `inputRef`.
5. It logs every free `verificationStatus` value back to the CRM record as a "checked, not verified"
   marker rather than leaving the field silently untouched.

#### Event-driven single-lookup pattern

1. A new-lead webhook or form submission triggers a single-person run with one `people` entry.
2. The caller polls run status or awaits a webhook/finish callback, then reads the one Dataset row.
3. If `found === true`, the lead record gets a "company-verified address available for review"
   flag; the address itself is still routed to a human before it enters an outreach sequence.
4. If `partial === true` and `retryable === true`, the caller requeues once after its own standard
   backoff window rather than treating the gap as a permanent no-match.

#### Agent/MCP pattern

1. The agent supplies exactly the fields the Input schema allows — it cannot inject a path, a
   credential or an alternate protocol even if it tries, because the schema and the runtime
   validator both reject anything outside the closed contract.
2. It reads `found`, `partial`, `verificationStatus`, `safeToAutomate` and `safeForOutreach` before
   deciding on any next step.
3. It never converts a `role_address_only` or `unlinked_personal_address_only` row into an
   affirmative claim just because an address string exists in the row's evidence object.
4. It always routes a `published_person_match` to a human queue rather than an autonomous send
   action, honoring `safeForOutreach:false` as a hard stop, not a suggestion.
5. It cites `entityId`, `sourceUrl` and `observedAt` in any work item or ticket it creates, so a
   human reviewer can re-check the original page directly.

#### Data-warehouse pattern

Append Dataset rows keyed by `entityId`. Keep `observedAt` (this Actor's observation time) distinct
from any timestamp your own pipeline assigns on ingestion. Preserve `null` fields as null rather than
coercing them to empty strings, since a null `email` is a structurally different fact from an empty
string one. Treat a future `schemaVersion` bump as an explicit migration, not a silently compatible
row shape.

### Operating guide

**Choosing `maxPagesPerPerson`.** The default of 3 reads the three highest-relevance pages before
the homepage fallback. Raising it to 4 adds the homepage as a fourth attempt for companies whose
staff information lives only there; lowering it to 1 or 2 trades completeness for a tighter page
budget when you already have strong reason to believe the address lives on `/team` specifically.

**Reading the `OUTPUT` summary before Dataset rows.** The run-level summary tells you, in one
object, whether the whole batch behaved as expected — `inputRows`, `uniquePeople`,
`duplicateInputsSkipped`, `matched`, `freeDecisions`, `partial`, `budgetStopped`, `stopped` and
`stopReason` — before you spend time iterating individual rows. A `stopped:true` run with a non-null
`stopReason` means the batch did not fully complete and some people at the tail of your input list
may never have been processed; check `inputRows` against the actual Dataset row count to confirm.

**Understanding replay safety.** Every paid delivery is journaled in two phases before it ever
reaches you: `source_reserved` before any page is read, then `delivery_pending` with the exact row
hash before the atomic Dataset write, and only `delivered` after a confirmed platform receipt. If a
worker process is interrupted between the atomic write and receiving its receipt, the next process
reconciles a bounded read of the Dataset before doing any new source work, recognizes a row whose
hash matches the pending entry as already delivered, and skips it rather than re-billing you. An
unresolved mismatch between the journal and the Dataset becomes a fatal, non-retried state rather
than a silent duplicate charge — this Actor would rather stop than guess.

**Understanding the budget-stop states.** If your run's result cap is reached before a person is
processed at all, that row is `budget_stopped_before_source_read` and no page was ever fetched for
it. If the cap is reached after pages were already read for that person but before a decision could
be delivered, the row is `budget_stopped_after_source_read` and preserves every checked/unreachable
page fact it gathered — the evidence of what was actually read is never discarded just because the
budget ran out at the wrong moment.

**Understanding concurrency.** `maxConcurrency` only governs local, non-monetized batching of work
before it reaches the platform boundary. On-platform source reads and paid deliveries are always
sequential: another source read only starts while at least one more possible result still fits under
your run's cap, so you cannot be charged for parallel work that raced past your intended limit.

**Interpreting `checkedPages` and `unreachablePages` together.** These two arrays are drawn from the
same fixed four-path universe and are designed to be read together: every checked page that returned
a non-200 status has a matching `unreachablePages` entry, and every `unreachablePages` entry that
carries a status has a matching `checkedPages` witness. A path never appears as both fully checked
with a 200 status and unreachable — treat any apparent contradiction in your own tooling as a bug in
how you're reading the row, not an ambiguity the Actor itself would ever produce.

### FAQ

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

No. It never synthesizes `first.last@domain` or any other pattern. Every address it returns is one
it found as literal published text or an explicit `mailto:` link on the company's own site.

#### Does it verify deliverability, mailbox ownership or consent to be contacted?

No, and this is the single most important boundary of the product. "Verified" here means published,
page-linked, schema.org-asserted evidence — not that the mailbox exists in a working state, not
that the named person controls it, and not that they have agreed to receive anything. `safeForOutreach`
is fixed to `false` on every row for exactly this reason.

#### Can I automatically send outreach based on a `published_person_match` row?

The Dataset schema is deliberately built to make that hard to do by accident. `safeToAutomate` and
`safeForOutreach` are both fixed `false`, and `recommendedAction` labels are queue tags for a human,
not authorization strings for an automated send integration. Treat every paid row as "ready for a
human to review," not "ready to message."

#### What happens if a person has two published addresses that both look valid?

The run returns an explicit `ambiguous_multiple_matches` free decision rather than guessing between
them. Certainty is a condition of billing, not a tiebreaker applied after the fact.

#### Does it read pages I don't ask for?

No. The runtime alone selects the exact fixed set — `/team`, `/about`, `/contact`, `/` — in that
relevance order, bounded by `maxPagesPerPerson`. You cannot supply an arbitrary path or URL, and the
PAGE reads never leave the registrable domain even across redirects; stylesheets those pages link
are the one cross-host fetch — rendering context only, each host consulted through its own
`robots.txt`, never a source of evidence.

#### What happens if `robots.txt` disallows a page?

That page is never read, and it appears in `robotsDisallowed` rather than `checkedPages`. This
applies even if a schema.org-asserted address would very likely exist there — the source policy is
honored strictly, not selectively.

#### Why does a "no match" row still cost the start fee?

The $0.005 start fee covers the run/session overhead itself, independent of outcome; the $0.01
per-person charge is what is scoped to the published person match outcome specifically. Every free
decision still costs the flat start fee and nothing per result.

#### Is browser automation, a proxy or a login step ever used?

No. The Actor is a static-HTML reader over HTTPS with SSRF-safe DNS pinning. There is no browser
engine, no JavaScript execution, no CAPTCHA-solving and no proxy configuration surface in the Input
schema at all.

#### What is `entityId` and can I use it as a permanent database key?

It is a stable 24-character hex identity generated for the row within this Actor's own delivery and
replay-safety journal. It is safe to use for deduplication and idempotency inside your own pipeline;
it is not a CRM contact ID and carries no meaning outside this Actor's context.

#### How is this different from a plain "find contact page and extract emails" scraper?

A plain extractor bills for any literal address it can find near a name in prose. This Actor adds a
strictly higher, machine-checkable bar: the same page must also carry a valid schema.org Person
assertion that exactly names the pairing. That is a materially narrower, more defensible claim, and
it is why a much larger share of runs against real company sites are expected to return a free
decision rather than a paid one — completeness of the public web's schema.org adoption is a real
limiting factor, not a defect in this Actor.

#### Can an AI agent safely call this Actor unattended?

Yes, for the lookup step itself — the strict input, flat output and conservative safety flags are
designed for exactly that. The one hard rule an agent must keep is routing `published_person_match`
results to a human before any outreach action, because the Actor's own flags will not silently
authorize that step for it.

### Sources and rights

The only data source is the buyer-selected company's own public website, bounded to the same
registrable domain, over HTTPS on the default port, limited to four fixed relevance-ordered paths
per person. `robots.txt` is honored fail-closed on every hostname before any page is read. No
private, internal, metadata or non-public network destination can ever be reached — every hostname
is resolved once and every resolved address is checked against a blocklist covering private,
loopback, metadata, documentation, transition and deprecated/retired address ranges before a
connection is attempted, and every redirect hop repeats that check rather than trusting the first
one. The Actor does not use a proxy, browser engine, login flow or CAPTCHA-solving step, and it
never reads a page path outside the fixed four-path set regardless of what a caller supplies.

The Dataset stores a SHA-256 hash of the bounded evidence context rather than the surrounding page
text, and it never stores unrelated data incidentally observed on the page — no phone numbers, no
other people's addresses, no page content beyond what the matching contract itself requires as
evidence. Buyers are responsible for using the returned evidence in a manner consistent with the
target company's own terms of use and applicable data-protection law in their jurisdiction; this
Actor's narrow read scope and strict robots.txt compliance reduce but do not eliminate that
responsibility, and the human-review requirement built into every safety flag exists specifically to
keep a person in the loop before any contact action is taken.

### Limits

- One to fifty people per run; one company domain per person, never a company-wide crawl.
- At most four fixed, Actor-selected pages per person — never a caller-supplied path.
- Static HTML only: no browser, no JavaScript execution, no login, no CAPTCHA-solving, no proxy.
- A single run-wide cap of 300 actual HTTP requests (250 logical robots/page reads before redirects).
- No pattern-guessed addresses, no MX/SMTP/catch-all probing, no deliverability claim of any kind.
- No claim of mailbox ownership, consent or outreach permission — ever, on any row.
- `safeToAutomate` and `safeForOutreach` are fixed `false` for every row the Dataset schema allows.
- No bulk company-wide extraction mode; this is a per-person, per-domain utility, not a monitor.

### Support boundary

This Actor is independently reviewed before release. Support covers deterministic input validation,
the source-access and matching-contract behavior described in this page, Dataset/Output/KVS schemas,
and delivery/replay evidence. It cannot decide whether a specific outreach action is appropriate for
your jurisdiction or industry, cannot restore access to a target company's website if it changes its
own `robots.txt` policy or page structure, cannot guarantee that a company continues to publish an
address it published at observation time, and cannot provide legal advice on contact or
data-protection obligations. When reporting a problem, include the Actor run ID, the redacted
`entityId`, `verificationStatus`, `errorCode` and approximate `observedAt` time. Never send full page
HTML, unredacted addresses beyond what you already hold, or any credential — this Actor's own
contract never requires one, and support will never ask for one either.

# Actor input Schema

## `people` (type: `array`):

One to 50 people. Each domain must be a bare public company hostname, not a URL.

## `maxPagesPerPerson` (type: `integer`):

Number of pages selected from the Actor-owned fixed order: /team, /about, /contact, then /.

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

People processed concurrently only in local non-monetized runs. On-platform source work and paid delivery are sequential.

## Actor input object example

```json
{
  "people": [
    {
      "fullName": "King Lee",
      "domain": "visionwide.co",
      "companyName": "Visionwide"
    }
  ],
  "maxPagesPerPerson": 3,
  "maxConcurrency": 1
}
```

# Actor output Schema

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

API URL for uniform person\_email\_decision rows in the default dataset.

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

Validated OUTPUT record with input, result, partial and budget-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 = {
    "people": [
        {
            "fullName": "King Lee",
            "domain": "visionwide.co",
            "companyName": "Visionwide"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("zinin/domain-to-verified-work-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 = { "people": [{
            "fullName": "King Lee",
            "domain": "visionwide.co",
            "companyName": "Visionwide",
        }] }

# Run the Actor and wait for it to finish
run = client.actor("zinin/domain-to-verified-work-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 '{
  "people": [
    {
      "fullName": "King Lee",
      "domain": "visionwide.co",
      "companyName": "Visionwide"
    }
  ]
}' |
apify call zinin/domain-to-verified-work-email --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,zinin/domain-to-verified-work-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/U8tZBCbhyzbcyyxmf/builds/OeRpkL4x1eyzcbUxG/openapi.json
