# Business People Enrichment - Role Holders and Contacts (`leadproof/business-people-enrichment`) Actor

Find the owners, managers and other role holders inside an identified business, with the published evidence for each role and the professional contact details that are attributable to them. Not an employee directory.

- **URL**: https://apify.com/leadproof/business-people-enrichment.md
- **Developed by:** [Lead Proof](https://apify.com/leadproof) (community)
- **Categories:** Lead generation, Business
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$20.00 / 1,000 person founds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Business People Enrichment - Role Holders and Contacts

Give it businesses you have already identified and the roles you care about. It finds the people
who hold those roles, shows the published evidence for each role, and returns only the professional
contact details a source attributes to that person.

Built for a list of businesses (from Google Maps, a supplier list, a CRM export) where you need to
know who to talk to inside each one.

**This is not an employee directory.** It looks for the role holders you asked about. A business
where nobody was found is not a business with no staff, and every row says which of the two it is.

### What you get

- **People in the roles you asked for** - owner, executive, purchasing, operations and more - each
  with the title exactly as the business published it and the page that says so.
- **Only attributable contacts.** An email or phone is returned for a person only when the business
  publishes it against that person. **No guessed addresses**: nothing like `first.last@domain` is
  ever built and offered as found.
- **Business channels kept apart**: main lines and function mailboxes (`info@`, `sales@`) are listed
  as company contacts, never as somebody's direct line.
- **An explicit account of what was not seen**: pages that could not be read, limits reached,
  sources not consulted. An empty result is never presented as proof that nobody holds a role.
- **Ranking you can check**: every person carries the comparisons that placed them. There is no
  opaque score.
- **One output row per input business**, in input order, so your export lines up with your list.
- **Pay per person returned.** Businesses where nobody was found are free from this Actor (see
  Pricing: the page reads are still billed by the reader it calls).

### How to use it

1. Put your businesses in `businesses`. Each needs a website, or a name together with a city,
   region or country.
2. Choose `targetRoles`, for example `["owner", "executive", "purchasing"]`.
3. Keep `maxPeoplePerBusiness` at 5 or raise it (up to 25).
4. Set a **maximum charge** in the run options and `maxDependencyChargeUsd` for the pages and
   searches this Actor starts (see Pricing).
5. Run it on a few rows first, then read `people`, `candidates` and `coverage.gaps`.

### Input

```json
{
  "businesses": [
    {"inputId": "acct-1", "name": "Example Accountants", "website": "https://accountants.example.co.uk/", "city": "Shrewsbury", "country": "GB"},
    {"inputId": "dist-1", "name": "Example Industrial Supply", "city": "Salt Lake City", "region": "UT", "country": "US"}
  ],
  "targetRoles": ["owner", "executive", "purchasing"],
  "maxPeoplePerBusiness": 5,
  "renderMode": "http",
  "maxDependencyChargeUsd": 0.5
}
```

| Field | Meaning |
| --- | --- |
| `businesses` | One object per business: `inputId`, `name`, `website`, `city`, `region`, `country`, `mapsUrl`, `linkedinCompanyUrl`. Up to 200 per run. |
| `targetRoles` | Role families to look for. Empty means `owner`, `founder`, `executive`. |
| `maxPeoplePerBusiness` | Default 5, up to 25. Supported people past the limit are listed as candidates. |
| `renderMode` | `http` (default, cheapest), `auto` or `browser`. Use `browser` for sites whose team page is built by JavaScript. |
| `verifyEmails` | Default off. On checks the direct addresses being returned for deliverability. |
| `includeFormer` | Default off, so relationships a source describes as past are excluded. |
| `useLinkedIn` | Not available in this release. Leave it off. |
| `maxDependencyChargeUsd` | Ceiling, in dollars and cents, on what the page reads and searches this run starts may charge in total. |

Other limits you can lower: `maxPagesPerBusiness` (8), `maxSearchesPerBusiness` (3),
`maxSecondsPerBusiness` (180), and the same per run.

**A row needs enough to identify a business.** A website, a company profile URL, or a name
together with a place. A bare name is refused for that row before anything is fetched. With a
name and a place but no website, the official site is searched for; if several companies carry
that name, none is picked and the row returns them as `businessCandidates` with no people.

**Roles it understands:** `owner`, `founder`, `executive`, `partner`, `purchasing`,
`operations`, `finance`, `marketing`, `sales`, `hr`, `it`, `clinical`, `advisor`, and synonyms
such as `procurement`, `buyer`, `supply chain`, `practice manager` and `leadership`. A role name
it does not model is reported in `coverage.unrecognizedRequestedRoles`, not approximated.

### Output

One row per input business. Main fields:

| Field | Meaning |
| --- | --- |
| `status` | `succeeded`, `partial` (a limit, a timeout or an unreadable source cut it short), `failed` or `skipped`. |
| `outcome` | Why `people` looks the way it does: `people_found`, `no_supported_role_holder_published`, `sources_were_inaccessible`, `stopped_at_a_limit`, `business_not_identified` or `no_source_could_be_read`. |
| `business` | The resolved company, how sure the match is, and why. |
| `people[]` | `fullName`, `associations` (published `rawTitle`, relationship type, current or former, evidence with URL and quote), `contacts` with attribution and deliverability, `matchedRoles`, `rankingReasons`. |
| `candidates[]` | People found but not returned, each with the reason (a role you did not ask for, the per-business limit, a contested identity, another location). |
| `possibleDuplicateOf` | On a person or candidate: other records with the same name and a compatible title that may be the same person. Nothing on the pages ties them together or tells them apart, so they are neither merged nor presented as different people. |
| `businessContacts[]` | Company channels with no person attached. |
| `coverage` | `rolesFound`, `unresolvedRoles`, `limitsReached` and `gaps`, each gap with a plain sentence saying what it means. |
| `billedPeople` | How many `person-found` events this row was charged for. |

The run page offers the full JSON, an overview CSV, and a run summary with the dependency spend.

#### How people are chosen and ordered

1. The business is resolved from what you supplied. The home page is read, then the pages it
   links to about its people: team, about, leadership, contact, locations.
2. People come from structured data, from person cards and from plain text, in that order of
   support. Only for roles the site did not answer, a bounded search runs. A search snippet is
   never treated as evidence of employment.
3. Every requested role gets one person before any role gets a second; spare places then follow
   the order of your `targetRoles`.
4. Inside a role: how directly the title names the role, then the strength of the evidence, then
   the seniority the title itself states (chief executive or owner, chair, other chief officers,
   vice presidents, directors, managers), then the name. Evidence always comes before seniority.

Seniority orders people and claims nothing more. **A purchasing title is not purchase
authority**, and "Founder" is not read as owner or chief executive.

#### Contact scopes

| Scope | Meaning |
| --- | --- |
| `person_professional` | The business published this address against that person, inside their own entry. A mailbox that merely looks built from their name is never this. |
| `branch` | Published for one location. |
| `business_general` | A function mailbox or a main line. |
| `unknown` | Published, but not attributable: for example on a domain the business does not own. |

Attribution and deliverability are separate fields. A mailbox that accepts mail is not proof of
who reads it.

#### Coverage gaps

| Gap | Means |
| --- | --- |
| `source_unreadable` | A source was blocked, timed out or failed. Nobody being found in it is not evidence that nobody is in it. |
| `limit_reached` | The run stopped at one of its limits before the sources were exhausted. |
| `people_withheld_by_charge_limit` | People were found but not returned because the run's maximum charge did not cover them. They were not charged. |
| `page_needs_rendering` | A page is built by JavaScript, so its people were not in what was read. Re-run that business with `renderMode: "browser"`. |
| `page_empty_after_rendering` | A page was read in a browser, finished loading and was still empty, typically a guessed address such as `/team` that the site does not have. |
| `no_page_about_the_people` | The site was read and publishes no team or about page. |
| `names_found_without_published_titles` | People were named with no title, so they are candidates, not role holders. |
| `no_official_site_identified` | The company's own site was not found. |
| `search_coverage_reduced` | The search returned less than was asked of it. |
| `profile_sources_not_used` | No professional-profile source was consulted. |

`coverage.absenceIsBounded` is `true` when any gap could be hiding a role holder: on such a row,
an empty `people` list is not a statement that nobody holds the role.

For JavaScript sites prefer `renderMode: "browser"` over `auto`: `auto` has been seen reading such
a page over HTTP without switching to the browser.

### Pricing

#### This Actor

**$0.02 per person returned** in `people` (event `person-found`). It is the only event this Actor
charges. Two records that may be the same person (flagged `possible_duplicate`) take one place and
are charged once.

Free from this Actor: candidates, company contacts, businesses where nobody was found,
businesses that could not be identified, and rows that failed.

**Your run's maximum charge is respected at every step:**

- A business is not started unless the remaining maximum still covers at least one more person,
  so no page is read and no search is run for an answer that could not be returned.
- If a business has more people than the remaining maximum covers, the row returns only the people
  that were charged (the first places filled, one per requested role first), stays in the dataset
  as `partial` with `charge_limit` in `coverage.limitsReached`, and says how many were withheld.
  Withheld people are not charged and not listed. The run stops after that row.
- If a run is interrupted and resumed, a row is never charged twice. A row interrupted during the
  charge itself is returned without being charged.

#### Actors this Actor calls, billed to you separately

Pages and searches are read by other Actors that run on your account and bill you at their own
Store prices, **in addition to** the $0.02 per person:

| Called Actor | When | Its price |
| --- | --- | --- |
| [Search & Read](https://apify.com/leadproof/search-and-read) | Every page read and every search | $0.002 per page read over HTTP, $0.006 per page that needed a browser, $0.005 per search, plus $0.00005 per run start |
| [Bulk Email Verifier](https://apify.com/leadproof/bulk-email-verifier) | Only with `verifyEmails: true` | $0.0049 per address checked |

**A business where nobody is found can still cost money.** Its pages and searches are read and
billed by Search & Read whether or not a role holder turns up. Only this Actor's $0.02 depends on
people being found.

#### What bounds your cost

There is no typical cost to quote: it depends on how many pages each site has, whether they need
a browser, how many searches run, and how many people each business publishes. The limits give
hard ceilings instead, enforced in the code. Per business, the Actor never hands the reader more
than `maxPagesPerBusiness` pages or `maxSearchesPerBusiness` searches, each read is asked for once,
searches read no pages, and returned people never exceed `maxPeoplePerBusiness`. The reader
charges each page and each search at most once, including its own retries and restarts, and
nothing for a failed read. With the defaults, at today's prices:

| Per business | Upper bound |
| --- | --- |
| This Actor, 5 people x $0.02 | $0.10 |
| Search & Read over HTTP, 8 pages x $0.002 + 3 searches x $0.005 | $0.031 |
| Search & Read in browser mode, 8 pages x $0.006 + 3 searches x $0.005 | $0.063 |
| Run starts of the called Actors, at most one per page or search, 11 x $0.00005 | $0.00055 |

Email verification, when on, adds $0.0049 per address returned. Lower `maxPagesPerBusiness`,
`maxSearchesPerBusiness` or `maxPeoplePerBusiness` to lower these. Set `maxDependencyChargeUsd` to
cap what the called Actors may charge across the whole run; each is started with only what is left
of it. Your run's maximum charge caps this Actor's own events and does not cover the called
Actors. Start with a few rows and check the run summary.

### What it will not tell you

- **A decision maker for every business.** Many companies publish nobody. That returns
  `no_supported_role_holder_published`, with the gaps that bound it.
- **Who approves a purchase.** A purchasing title names a function, not an approver.
- **That an unknown recency is current.** A person on a live staff page is `current`; anywhere
  else recency stays `unknown`.
- **One person from two records by name alone.** Two records merge only when something outside
  the name, such as a personal mailbox or a profile address published for exactly them, ties them
  together. Records whose own contacts differ are two people. The same name and title with nothing
  either way is flagged `possible_duplicate`: listed separately, one place, charged once. "Jane Q
  Smith" and "Jane Smith" follow the same rule.
- **A branch contact from a group page.** If nothing ties a person to the location you asked
  about, `branchRelevance` stays `unknown`.

### Fair use

Only public pages are read, with no login and nothing bypassed. The output is professional
information a business chose to publish about its own staff. You are responsible for how you
contact the people in it and for following the privacy and marketing rules that apply to you,
such as GDPR and CAN-SPAM.

### Related

- [Business Enrichment](https://apify.com/leadproof/business-enrichment) - company website,
  published emails and phones from a business name.
- [Search & Read](https://apify.com/leadproof/search-and-read) - the search and page reader this
  Actor uses.
- [Bulk Email Verifier](https://apify.com/leadproof/bulk-email-verifier) - deliverability checks.
- Verified local-business lead lists: [leadproof.co](https://leadproof.co).

### Development

Python 3.13, Apify SDK 4.0.2. `python3 -m unittest discover -s test` from this folder. The
person and organization model is a shared library bundled at build time. The project records
(`HANDOFF.md`, `DEPLOYMENT.md`, `STORE_LISTING.md`) are in this folder and are not part of the
store page.

# Actor input Schema

## `businesses` (type: `array`):

One object per business: {inputId, name, website, city, region, country, mapsUrl, linkedinCompanyUrl}. A row needs enough to identify a company: a website, a company profile URL, or a name together with a city, region or country. A bare name is refused for that row, because it names no particular business. Up to 200 rows.

## `targetRoles` (type: `array`):

Role families to look for: owner, founder, executive, partner, purchasing, operations, finance, marketing, sales, hr, it, clinical, advisor. Synonyms such as procurement, buyer, practice manager or leadership are accepted. Left empty, the run looks for owner, founder and executive. A role name we do not model is reported in coverage.unrecognizedRequestedRoles rather than guessed at.

## `maxPeoplePerBusiness` (type: `integer`):

Most people returned per business. Supported people beyond the limit move to candidates with the reason, so nothing is silently dropped.

## `useLinkedIn` (type: `boolean`):

Read profile pages that were discovered on the company's own site or in search results. Profile URLs are never constructed from a person's name. Off by default: the profile reader this calls has not been verified end to end, so anything it returns or fails to return is reported as a coverage gap and never as 'nobody found'. Turn it on to pay for the attempt anyway.

## `verifyEmails` (type: `boolean`):

Check whether the direct addresses we are about to return accept mail, using Bulk Email Verifier. Off means no verifier run is started at all. A deliverable mailbox is still not proof of whose mailbox it is.

## `includeFormer` (type: `boolean`):

Off excludes people a source describes as former. People whose recency is simply unknown are still returned, marked unknown: unknown is not quietly treated as current.

## `country` (type: `string`):

ISO 3166-1 alpha-2 code used to pin web searches, e.g. KE, US, GB. Defaults to the search dependency's own default when empty.

## `language` (type: `string`):

Language tag for searches and page reads, e.g. en, sw, de.

## `renderMode` (type: `string`):

Team pages are usually served as HTML. If a row comes back with the page\_needs\_rendering gap, re-run that row with browser: it costs $0.006 per page instead of $0.002 and takes about 5.5s instead of 0.9s, and on a measured case it turned an empty row into the company's whole leadership team. Prefer browser over auto for now: auto did not upgrade that page (issue #66).

## `maxPagesPerBusiness` (type: `integer`):

Company pages read per business, including the home page.

## `maxSearchesPerBusiness` (type: `integer`):

Web searches per business, used only for roles the company's own site did not answer. 0 turns search off.

## `maxProfileReadsPerBusiness` (type: `integer`):

Discovered profiles read per business.

## `maxSecondsPerBusiness` (type: `integer`):

A business that reaches this limit returns what it has, with the limit named in coverage.limitsReached.

## `maxPagesTotal` (type: `integer`):

Pages read across the whole run. When it is reached the run stops starting new businesses, and the row it stopped names the limit in coverage.limitsReached.

## `maxSearchesTotal` (type: `integer`):

Searches run across the whole run. 0 switches searching off for every business, which is a setting rather than a limit the run reached.

## `maxProfileReadsTotal` (type: `integer`):

Profile reads across the whole run. Only used when useLinkedIn is on.

## `maxRunSeconds` (type: `integer`):

The run stops starting new businesses after this long and writes what it has. Keep it below the run timeout so the last row is written before the platform stops the run.

## `maxDependencyChargeUsd` (type: `number`):

Ceiling on what the child runs this Actor starts may charge in total, in dollars and cents (0.05 is fine). Each child is started with only what is left of it as its own maximum charge, never with the whole amount. It counts, per child run, the larger of its pay-per-event charges and the platform usage it reported. It does not cover this Actor's own charges (those are bounded by this run's own maximum charge) or this Actor's own compute. Empty means each child keeps only its own per-run cap.

## Actor input object example

```json
{
  "businesses": [
    {
      "inputId": "industrial-supply-slc",
      "name": "Industrial Supply Company",
      "website": "https://indsupply.com/",
      "city": "Salt Lake City",
      "region": "UT",
      "country": "US"
    }
  ],
  "targetRoles": [
    "executive",
    "purchasing"
  ],
  "maxPeoplePerBusiness": 3,
  "useLinkedIn": false,
  "verifyEmails": false,
  "includeFormer": false,
  "language": "en",
  "renderMode": "http",
  "maxPagesPerBusiness": 3,
  "maxSearchesPerBusiness": 3,
  "maxProfileReadsPerBusiness": 3,
  "maxSecondsPerBusiness": 180,
  "maxPagesTotal": 200,
  "maxSearchesTotal": 60,
  "maxProfileReadsTotal": 40,
  "maxRunSeconds": 3600
}
```

# Actor output Schema

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

Full rows: people with their evidence and attributable contacts, candidates with reasons, business channels, coverage and source statuses.

## `overview` (type: `string`):

One line per business: outcome, people selected, direct contacts, candidates, roles found and not found.

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

Counts by outcome, stop reason and dependency spend.

# 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 = {
    "businesses": [
        {
            "inputId": "industrial-supply-slc",
            "name": "Industrial Supply Company",
            "website": "https://indsupply.com/",
            "city": "Salt Lake City",
            "region": "UT",
            "country": "US"
        }
    ],
    "targetRoles": [
        "executive",
        "purchasing"
    ],
    "maxPeoplePerBusiness": 3,
    "maxPagesPerBusiness": 3,
    "maxSearchesPerBusiness": 0
};

// Run the Actor and wait for it to finish
const run = await client.actor("leadproof/business-people-enrichment").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 = {
    "businesses": [{
            "inputId": "industrial-supply-slc",
            "name": "Industrial Supply Company",
            "website": "https://indsupply.com/",
            "city": "Salt Lake City",
            "region": "UT",
            "country": "US",
        }],
    "targetRoles": [
        "executive",
        "purchasing",
    ],
    "maxPeoplePerBusiness": 3,
    "maxPagesPerBusiness": 3,
    "maxSearchesPerBusiness": 0,
}

# Run the Actor and wait for it to finish
run = client.actor("leadproof/business-people-enrichment").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 '{
  "businesses": [
    {
      "inputId": "industrial-supply-slc",
      "name": "Industrial Supply Company",
      "website": "https://indsupply.com/",
      "city": "Salt Lake City",
      "region": "UT",
      "country": "US"
    }
  ],
  "targetRoles": [
    "executive",
    "purchasing"
  ],
  "maxPeoplePerBusiness": 3,
  "maxPagesPerBusiness": 3,
  "maxSearchesPerBusiness": 0
}' |
apify call leadproof/business-people-enrichment --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,leadproof/business-people-enrichment"
        }
    }
}
```

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/0Gb7vnNYAaZGNaedr/builds/UNe4ZFI4pzSKEaQaM/openapi.json
