# Prospect Engine: B2B Company and Contact Enrichment (`mambalabs/b2b-prospect-engine`) Actor

Turn a domain or a hiring keyword into enriched company records and verified contacts. Resolves the real employer behind a job posting, scores ICP fit, and returns one flat Clay-ready row per entity with per-field provenance. Bring your own vendor keys.

- **URL**: https://apify.com/mambalabs/b2b-prospect-engine.md
- **Developed by:** [Mamba Labs](https://apify.com/mambalabs) (community)
- **Categories:** Lead generation, Automation, AI
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.50 / 1,000 contact verifieds

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

## Prospect Engine: B2B Company and Contact Enrichment

Find the companies hiring for a role, work out who is actually employing them,
enrich the company, and find the people to talk to. One actor, one run, flat
Clay-ready rows.

> ## Bring your own keys
>
> **This actor ships no vendor keys and marks up no vendor cost.** You supply
> your own provider keys and each vendor bills you directly. What you pay here
> is the Apify per-event charge and nothing else.
>
> **You can try it with no keys at all.** `enrich_companies` runs on the
> company's own website and public sources, so the prefilled input returns a
> real enriched record without you configuring anything. Keys unlock job
> discovery and email finding.
>
> A missing key is never silently reported as an empty result. The run returns a
> named error saying which key is absent and how to supply it. See
> [Vendor keys](#vendor-keys).

The thing this does that a job scraper does not: it reads the job description to
decide whether the company named on the posting is the real employer or just the
board that posted it. A job board, an industry association or a staffing agency
posting on someone's behalf gets reported as the company by every SERP, and that
attribution is wrong in a way that produces no error. This actor resolves the
real employer and tells you how sure it is.

### Four modes

Set `mode` to pick one.

**enrich\_companies** takes domains or company names and returns one merged
company record each: identity, firmographics, LinkedIn URL, social profiles, and
per-field provenance. Needs no vendor key.

**discover\_jobs** takes keywords and returns one row per posting: the real
employer resolved from the job description, a signal type, an ICP score, dedupe
and delta detection. Needs a SerpAPI key.

**find\_contacts** takes domains and returns people, with an optional verified
email. Needs an email finder key for the email step.

**full** chains all three. The originating job signal travels on every contact
row, so you can write copy anchored to the posting without joining anything back
together.

#### Inputs per mode

Everything not listed is optional and has a default. Numeric and boolean fields
are supplied as strings, which is what Clay sends.

| Mode | Required | The ones worth setting |
|---|---|---|
| `enrich_companies` | `domains[]` or `company_names[]` | `score_icp`, `icp_preset`, `include_social` |
| `discover_jobs` | `keywords[]`, `serpapi_key` | `country`, `lookback_days`, `max_results`, `max_pages`, `remote_only`, `new_postings_only`, `previous_run_date`, `exclude_staffing` |
| `find_contacts` | `domains[]` or `company_names[]` | `target_contacts`, `job_titles[]`, `seniority[]`, `departments[]`, `include_email`, `verify_email` |
| `full` | `keywords[]`, `serpapi_key` | everything above, plus `min_icp_score` (default 45) |

`skip_cache` forces a fresh enrichment. `signal_taxonomy` replaces the built-in
signal set. `extra_exclude_names[]` adds to the ICP hard-exclude list.

### Five things to try

**1. Enrich a company, no keys needed.** The prefilled input. Returns identity,
firmographics, LinkedIn, socials and provenance.

```json
{ "mode": "enrich_companies", "domains": ["stripe.com"] }
```

**2. Enrich a list and score it against your ICP.**

```json
{ "mode": "enrich_companies", "domains": ["stripe.com", "figma.com", "linear.app"],
  "score_icp": "true", "icp_preset": "proofed_editorial" }
```

**3. Find who is hiring editors this week.** Needs a SerpAPI key.

```json
{ "mode": "discover_jobs", "keywords": ["copy editor", "proofreader"],
  "country": "us", "lookback_days": "7", "max_results": "25",
  "serpapi_key": "YOUR_SERPAPI_KEY" }
```

**4. Find contacts at a company, with verified emails.** Needs an email finder
key and a verifier key.

```json
{ "mode": "find_contacts", "domains": ["stripe.com"], "target_contacts": "3",
  "departments": ["marketing"], "seniority": ["director", "vp"],
  "include_email": "true", "verify_email": "true",
  "prospeo_api_key": "YOUR_KEY", "reoon_api_key": "YOUR_KEY" }
```

**5. The whole pipeline in one run.** Discovery to enrichment to contacts, with
the job signal carried onto every contact row. Bills on the chained event set,
25% below running the three modes separately.

```json
{ "mode": "full", "keywords": ["content editor"], "country": "us",
  "lookback_days": "7", "min_icp_score": "45", "target_contacts": "2",
  "include_email": "true", "verify_email": "true",
  "serpapi_key": "YOUR_SERPAPI_KEY", "prospeo_api_key": "YOUR_KEY",
  "reoon_api_key": "YOUR_KEY" }
```

### What comes back

One flat row shape across every mode. 64 snake\_case columns, one row per entity,
no nested JSON. A mode that does not populate a column leaves it null rather than
dropping it, so a Clay mapping keeps working when you change mode.

The columns that matter most:

| Column | What it tells you |
|---|---|
| `employer_name` | who is actually hiring, not who posted |
| `employer_confidence` | 0 to 100 |
| `employer_resolution_method` | how it was decided |
| `poster_is_employer` | false when the posting company was the board |
| `needs_review` | true when the attribution is not safe to trust |
| `signal_type` | which of the seven buying signals this posting is |
| `icp_score` and `icp_tier` | fit against the ICP rules, A to D or excluded |
| `company_phone` | the company main line, E.164 when a country resolves |
| `company_phone_source` | which node the main line came from |
| `field_sources` | which source supplied each field, as JSON |
| `source_status` | per-source ok, degraded, empty or skipped |
| `data_completeness` | percentage of the contract this row filled |

**Company main line.** `telephone` is a standard property on the schema.org
organization nodes the homepage source already parses, so the switchboard
number costs no extra request, no vendor and no credit. Every JSON-LD block on
the page is scanned rather than only the block the company record resolves out
of, `Organization` subtypes such as `LocalBusiness` and `ProfessionalService`
count as organization nodes, and a block that is not valid JSON is skipped
rather than aborting the scan.

Read in precedence order: a node's own `telephone`, then a nested
`contactPoint` with a general, main or customer service `contactType` ahead of
sales, support and billing, then a `telephone` on the `address` or `location`
node, then the phone meta tags. Where two nodes disagree, the one whose own
`url` or `@id` sits on the domain being enriched wins, then the more specific
node type. `company_phone_source` names the node the number came from, for
example `jsonld_organization` or `jsonld_localbusiness`, and
`company_phone_raw` holds the string exactly as published so a bad
normalization is diagnosable from the row alone.

The number is normalized to E.164 using the HQ country resolved from the same
address node. Where no country can be determined it is emitted exactly as
published, without a leading `+`, so the leading `+` is itself the provenance.
Implausible values are rejected rather than emitted: wrong digit counts,
repeated and sequential digit runs, the NANP 555-01xx fictional range and the
Ofcom drama ranges.

Most sites publish no telephone, so a null `company_phone` is the ordinary
outcome rather than a failure. The field is excluded from `data_completeness`
for that reason: counting it would drag every score down against a denominator
that never applied to the scores already recorded, and this actor charges on
`data_completeness > 0`.

The extractor is shared with Company Firmographics
(`mambalabs/company-firmographic-enricher`) through
`@mambalabsdev/gtm-core/phone`, so one domain cannot yield two different main
lines depending on which actor you ran.

### Employer resolution

The part worth understanding before you trust the output.

A posting says "National Association of Black Journalists". The description says
"Southern California Edison is a proud Equal Opportunity Employer". The employer
is Southern California Edison, and it is 13,700 people rather than 217, which
changes who you contact and what you say.

The resolver reads the description, and the posting page when the description is
truncated, looks for the patterns a company uses to name itself, and weighs them
against the posting company. The equal-opportunity clause is the strongest single
signal: it is legally motivated, it names the hiring entity, and a board posting
on someone's behalf carries the employer's clause rather than its own.

When the evidence is not strong enough, the row is flagged `needs_review` instead
of being attributed. That is the point. The failure mode being fixed is confident
and wrong, so the fix has to be able to say it is unsure.

**Overriding the posting company is deliberately hard.** A company naming another
company inside its own job description is completely normal, so a differing name
is not on its own evidence of anything. An override needs either an explicit
statement (on behalf of, our client, recruiting for) or a posting company that
looks like a board, an association, a staffing agency or a government portal. A
shared brand token or an acronym is read as the same corporate family, not a
different employer.

Measured on 227 real postings pulled from Google Jobs on 2026-08-03:

| | |
|---|---|
| postings where the employer was overridden | 1 (0.4%) |
| postings flagged for human review | 7 (3%) |
| review flags that were correct on a hand check | 7 of 7 |
| employer confidence, median | 60 |
| employer confidence, 75th percentile | 85 |
| postings carrying a description over 500 characters | 85% |

Confidence of 100 happens, but it is rare: 4 of 227. Treat 85 as a strong
result and anything at or below 60 as "the description did not corroborate the
posting company".

### Signal types

Every posting is classified, because a company hiring a copy editor and a company
hiring four content writers want different messages.

| Type | What it means |
|---|---|
| `contract_editorial` | already trying to buy capacity rather than hire it |
| `direct_editorial_hire` | editorial volume exceeds current capacity |
| `new_editorial_owner` | a budget holder just arrived with a mandate |
| `regulated_high_stakes` | one error costs real money |
| `content_production_ramp` | output is about to rise, editing follows |
| `localization_translation` | multi-language output with a QA gap |
| `ai_generated_content` | AI content at volume, unclear who checks it |

Supply `signal_taxonomy` to replace the set with your own. The default is
editorial because that is the vertical with a live use case, not because the
actor is limited to it.

### ICP scoring

Configurable rules, not a hardcoded profile. The default preset encodes real
drafted-versus-skipped decisions from a live outbound pipeline.

Hard excludes: government and public sector, student and campus media, clinical
records work inside an EMR, staffing and recruitment platforms, and any posting
whose employer cannot be resolved.

Scores up: contract, temporary and on-call editorial roles, because a company
posting one is already shopping for the thing a managed service sells.

Size is not a disqualifier. Large employers score neutral and carry a
buyer-check note telling you to target functional owners rather than the C-suite.

### Vendor keys

This actor ships no keys and marks up no vendor cost. You supply your own and are
billed by each vendor directly.

| Key | Needed for |
|---|---|
| `serpapi_key` | discover\_jobs and full. Note this is SerpAPI, not Serper |
| `serper_api_key` | better contact discovery, optional |
| `icypeas_api_key` or `prospeo_api_key` | finding emails |
| `reoon_api_key` or `bounceban_api_key` | verifying emails |

### Cost and speed

Everything runs in one container. There are no sub-actor calls, so you are not
paying a second actor's list price on top of this one, and there are no container
starts between stages.

Measured runtimes, 2026-08-03, one container, no sub-actor calls:

| Mode | Runtime | Work covered |
|---|---|---|
| `discover_jobs` | 6s | 6 postings, employer resolution and scoring on each |
| `find_contacts` | 7s | 1 company |
| `enrich_companies` | 9s | 2 companies, full identity and firmographics and social |
| `full` | 53s | 4 companies end to end: discovery, employer resolution, enrichment, contacts |

`full` at 53 seconds is over the 20 to 25 second target the architecture was
chosen against, so be straight about it: the target was per-company and this is
four companies chained, roughly 13 seconds each. The saving over a fan-out design
is real but it is a saving on container starts, not on network time, and network
time is what dominates once the containers are gone.

### What it costs

Pay per event, **charged on output only**. You are charged for a result once it
carries the thing being sold, never for making a request.

| Event | Charged when | Price |
|---|---|---|
| Company enriched, full | the merge produced data from an external source, with signals and an ICP score | $0.0800 |
| Company enriched, basic | the same, without scoring | $0.0200 |
| Contact verified | a contact is returned with a **verified deliverable** email | $0.0150 |
| Job posting discovered | a posting survives dedupe and resolves to an employer | $0.0100 |

**Nothing is charged for a miss.** A contact found without an email is free. A
company that no external source could enrich is free. A posting whose employer
could not be resolved is free. A cache hit is free. A notice row is free.

Subscription tiers discount every event: Bronze 10%, Silver 20%, Gold 30%.

#### Cheaper than assembling it yourself

The same work bought a la carte from the individual Mamba Labs actors:

| What you get | Here | As separate actors | You save |
|---|---|---|---|
| Company enriched, basic | $0.0200 | $0.0320 | 38% |
| Company enriched, full | $0.0800 | $0.1770 | 55% |
| Contact verified | $0.0150 | $0.0223 | 33% |
| Job posting discovered | $0.0100 | $0.0200 | 50% |

**Running `full` is cheaper again.** The chained path bills its own event set at
**25% below** the sum of the single-mode rates, because one run doing all three
stages costs less to serve than three runs doing one each. It is applied
automatically. You do not opt in and there is nothing to configure.

### Free tier

Free Apify plans get **10 charged results a month**. Paid plans are unlimited.

When the limit is reached the run **finishes normally**. It does not error. It
returns the results it produced, adds a row explaining the limit was reached, and
sets a run status message saying the same. Nothing is charged past the limit.

Results are cached for 7 days, so re-running the same company inside that window
costs nothing.

### Notes

Enable Apify Proxy with the residential group. DuckDuckGo, Brave and LinkedIn all
block datacenter IPs from the platform, which lowers name-to-domain and LinkedIn
resolution rates without it.

Contact discovery is the least proven part of this actor. The people cascade and
the email waterfall are written against each vendor's documented API and covered
by tests, but they have not been run against live vendor accounts. Treat contact
output as unvalidated until you have compared fill rates on your own keys.

Built by [Mamba Labs](https://apify.com/mambalabs)

# Actor input Schema

## `mode` (type: `string`):

What to run. enrich\_companies resolves identity, firmographics, LinkedIn and social for each company. discover\_jobs finds who is hiring for your keywords and resolves the real employer from the job description. find\_contacts finds people at each company. full chains all three.

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

One domain per line. Used by enrich\_companies and find\_contacts.

## `company_names` (type: `array`):

One name per line. Used when you have a name but no domain.

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

Roles to search for. Used by discover\_jobs and full. Example: copy editor, proofreader, content editor.

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

Two letter country code for job search, for example us or gb.

## `location` (type: `string`):

Optional location filter for job search, for example New York.

## `lookback_days` (type: `string`):

Drop postings older than this many days. Sent as a string because Clay sends every field as a string.

## `max_results` (type: `string`):

Cap on postings returned per run. Keeps a broad keyword set from running away.

## `max_pages` (type: `string`):

1 to 5. Each page is one SerpAPI call, so this is the main cost dial on discovery.

## `new_postings_only` (type: `string`):

true to return only postings not seen in a previous run.

## `previous_run_date` (type: `string`):

YYYY-MM-DD watermark for delta detection when the cross-run cache is unavailable.

## `remote_only` (type: `string`):

true to keep only postings flagged remote by the board.

## `company_size_min` (type: `string`):

Drop companies below this headcount. Only takes effect once headcount is known, so it applies in full mode and to any discovery row that carried an employee count. Discovery alone does not enrich.

## `company_size_max` (type: `string`):

Drop companies above this headcount. Same condition as the minimum.

## `exclude_staffing` (type: `string`):

true to drop postings from staffing and recruitment agencies.

## `exclude_marketplaces` (type: `string`):

true to drop Upwork, Fiverr, Freelancer and similar gig listings.

## `extra_marketplaces` (type: `array`):

Additional marketplace names to filter out.

## `fetch_posting_page` (type: `string`):

true to open the job posting when the description alone does not identify the employer. Slower, and it is what catches a job board posting on an employer's behalf.

## `target_contacts` (type: `string`):

1 to 25.

## `job_titles` (type: `array`):

Titles to search for when finding contacts.

## `seniority` (type: `array`):

c\_level, vp, director, manager, senior.

## `departments` (type: `array`):

marketing, sales, engineering, product, finance, hr, operations, legal.

## `include_email` (type: `string`):

true to run the email waterfall. Needs an Icypeas or Prospeo key, which you supply and are billed for directly.

## `verify_email` (type: `string`):

true to verify each address. Needs a Reoon or BounceBan key.

## `score_icp` (type: `string`):

true to score every row against the ICP rules and tier it A to D.

## `icp_preset` (type: `string`):

Which scoring model to apply. "proofed\_editorial" scores for a seller of managed copy editing and proofreading. "generic\_b2b" scores on hiring intent, employer resolvability and headcount with no service-specific vocabulary.

## `min_icp_score` (type: `string`):

In full mode, only companies scoring at or above this go on to contact discovery. Default 45: at 25 the filter passed every keyword-discovered editorial posting, because 25 is the floor such a posting can score.

## `extra_exclude_names` (type: `array`):

Any company whose name contains one of these is excluded.

## `signal_taxonomy` (type: `array`):

Override the default signal types. Each entry is an object with type, strength, title\_keywords, and optional also\_keywords and description\_keywords.

## `include_social` (type: `string`):

true to resolve Facebook, Instagram, X and YouTube alongside LinkedIn.

## `source_timeout_secs` (type: `string`):

5 to 120. A source that exceeds this is marked degraded and the run continues.

## `skip_cache` (type: `string`):

true to ignore the 7 day cross-run cache and recompute everything.

## `serper_api_key` (type: `string`):

Your Serper.dev key. Powers people search in find\_contacts, which is the highest-coverage layer. Without it the free fallback is measurably poor.

## `findymail_api_key` (type: `string`):

Your Findymail key. First provider in the email waterfall.

## `icypeas_api_key` (type: `string`):

Your Icypeas key. Runs on Findymail misses.

## `prospeo_api_key` (type: `string`):

Your Prospeo key. Runs on the residual after Findymail and Icypeas.

## `reoon_api_key` (type: `string`):

Your Reoon key. First email verification provider.

## `bounceban_api_key` (type: `string`):

Your BounceBan key. Second verifier, used for the catch-all case.

## `serpapi_key` (type: `string`):

Your SerpApi key. Required by discover\_jobs and full. Distinct from a Serper key: different vendor.

## Actor input object example

```json
{
  "mode": "enrich_companies",
  "domains": [
    "stripe.com"
  ],
  "company_names": [],
  "keywords": [
    "copy editor",
    "proofreader",
    "content editor"
  ],
  "country": "us",
  "lookback_days": "30",
  "max_results": "25",
  "max_pages": "1",
  "new_postings_only": "false",
  "remote_only": "false",
  "exclude_staffing": "true",
  "exclude_marketplaces": "true",
  "extra_marketplaces": [],
  "fetch_posting_page": "true",
  "target_contacts": "3",
  "job_titles": [],
  "seniority": [],
  "departments": [],
  "include_email": "false",
  "verify_email": "true",
  "score_icp": "true",
  "icp_preset": "proofed_editorial",
  "min_icp_score": "45",
  "extra_exclude_names": [],
  "include_social": "true",
  "source_timeout_secs": "30",
  "skip_cache": "false"
}
```

# Actor output Schema

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

No description

# 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 = {
    "mode": "enrich_companies",
    "domains": [
        "stripe.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("mambalabs/b2b-prospect-engine").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 = {
    "mode": "enrich_companies",
    "domains": ["stripe.com"],
}

# Run the Actor and wait for it to finish
run = client.actor("mambalabs/b2b-prospect-engine").call(run_input=run_input)

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

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

```

## CLI example

```bash
echo '{
  "mode": "enrich_companies",
  "domains": [
    "stripe.com"
  ]
}' |
apify call mambalabs/b2b-prospect-engine --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=mambalabs/b2b-prospect-engine",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

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