# Waterfall Email Enrichment API, Any Identifier (`b2bdata/waterfall-contact-enrichment`) Actor

Waterfall email enrichment from any identifier: LinkedIn URL, email, or name and company domain. Chains profile match, email find and verification, and stops where you choose. No login. Free plan: 100 inputs per run. Pay per result, misses free. No phone numbers.

- **URL**: https://apify.com/b2bdata/waterfall-contact-enrichment.md
- **Developed by:** [Open Enrich](https://apify.com/b2bdata) (community)
- **Categories:** Lead generation, Automation
- **Features:** Limited permissions
- **Stats:** 6 total users, 5 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 contact matcheds

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

## Waterfall Email Enrichment API, Any Identifier

Waterfall email enrichment from any identifier: give a LinkedIn URL, an email, or a name and company domain, and get back the person, a work email, and a mailbox check. The waterfall runs one step at a time and stops where you tell it to. No login, no cookies, no start fee. You pay only for the steps that deliver.

**No login | LinkedIn URL, email or name + domain in | Person, work email, status and confidence out | Stop at person, email or verified | Misses are free | JSON/CSV export**

> **Not a phone finder.** This actor returns people and work emails. It does not return phone numbers. For those, use [phone-number-finder](https://apify.com/b2bdata/phone-number-finder).

[See all actors by b2bdata](https://apify.com/b2bdata)

***

### How the waterfall works

```
input (any identifier)
   |
   v
[1] person     match the profile            -> event: contact
   |   stopAt: person ends here
   v
[2] email      find a work email on the     -> event: email-found
   |   company domain (skipped when you gave an email, or the profile already holds one)
   |   stopAt: email ends here
   v
[3] verified   check the mailbox            -> event: email-verified
```

- Give a **LinkedIn URL**: the person is matched, the employer's domain is looked up, a work email is found and checked.
- Give an **email**: the person behind it is matched and the address is checked. The email step is skipped, you gave it.
- Give **First Last @ domain**: the person is matched, the work email is found on that domain and checked.

A step that finds nothing ends that input. It is listed in `MISSES` with a reason and costs nothing.

***

### Pricing and plans

Three events, each charged once per row only when that step delivered something. Prices per 1,000 (draft, set on the Pricing tab):

| Event | When it is charged | Free plan | Gold plan |
|---|---|---|---|
| `contact` | The input is matched to a person and the row is delivered | $3.963 | $2.996 |
| `email-found` | A work email is found for the person and delivered | $0.963 | $0.696 |
| `email-verified` | The work email gets a valid or catch-all verdict | $1.963 | $1.496 |

Bronze and Silver prices are on the Pricing tab. Higher Apify plans only lower the price. Empty runs, failed lookups, unreadable input lines and notices are free, and there is no start fee.

**Plan-credit math.** The free plan's $5 monthly credit covers about 1,260 matched contacts, or about 5,190 emails found, or about 2,540 emails verified, at the Free price.

**What a row costs.** `stopAt: person` costs one `contact`. `stopAt: email` costs `contact` plus `email-found`. `stopAt: verified` costs all three at most. A row where the verdict is unclear is not charged as verified.

#### The free plan

Free Apify accounts get up to 100 inputs looked up per run, and as many runs as their Apify credit covers. Nothing is stored on our side. When a list is longer, the first 100 are looked up and one free notice row (`reason: "free_plan_limit"`) says how many were not looked up. They are not charged. Run again with the rest, or use a paid Apify plan for no per-run limit.

#### Keep a run small while you test

`limit` caps the inputs looked up. The default is 25, the maximum is 500, `0` means all lines up to 500. Start with 1 to 3 inputs, read the rows, then scale up. Use `maxChargeUsd` (advanced input) to stop a run at a spend you choose.

***

### Quick start

#### 1. One person, full waterfall

```json
{ "items": ["Patrick Collison @ stripe.com"], "limit": 1 }
```

#### 2. Mixed identifiers in one list, stop at the email

```json
{
  "items": [
    "https://www.linkedin.com/in/patrickcollison",
    "patrick@stripe.com",
    "Satya Nadella, microsoft.com",
    { "first": "Sam", "last": "Altman", "domain": "openai.com" }
  ],
  "stopAt": "email"
}
```

#### 3. Match people only

```json
{ "items": ["https://www.linkedin.com/in/patrickcollison"], "stopAt": "person" }
```

***

### Inputs

| Key | Type | Default | What it does |
|---|---|---|---|
| `items` | array | required | One person per entry: a LinkedIn URL or slug, an email, `First Last @ domain` or `First Last, domain`, or an object `{first,last,domain}` / `{fullName,domain}` / `{linkedin}` / `{email}`. Duplicates are collapsed. |
| `stopAt` | string | `verified` | `person`, `email` or `verified`. The waterfall ends at that step. |
| `limit` | integer | 25 | Maximum inputs looked up. `0` = all lines. Maximum 500; a higher number stops the run with a message before any charge. |
| `includeAudit` | boolean | false | Adds an `audit` object to each row: steps that ran, upstream units used, cleaned input. |
| `maxChargeUsd` | number | none | Advanced. Stops new lookups once the run has charged this much. |
| `sourceTag` | string | none | Advanced. Up to 64 characters, copied into each row and `RUN_SUMMARY`. |

Any other field stops the run before a lookup with `remove unsupported fields: ...`. Nothing is charged. A name needs both first and last name; a single name is an invalid line.

***

### Output

One row per input that reached at least the step you asked for. Rows keep input order. Empty values are left out of the row.

| Key | Type | Meaning, and when it is missing |
|---|---|---|
| `reached` | string | Furthest step with a result: `person`, `email` or `verified` |
| `fullName`, `firstName`, `lastName` | string | As matched. Missing when only an email was checked |
| `jobTitle`, `company`, `headline` | string | Current, when on file |
| `location`, `country` | string | Free-text location and ISO-2 country, when on file |
| `profileUrl`, `slug` | string | Professional profile, when matched |
| `email` | string | Work email, lower case. Missing at `stopAt: person` |
| `emailSource` | string | `found` (we found it), `profile` (already on the person record) or `provided` (you gave it) |
| `emailStatus` | string | `valid`, `catch_all` or `unknown`. A row with an `invalid` email is not delivered |
| `confidence` | number | 0-1, see the rule below |
| `inputQuery` | string | Your input, as you gave it |
| `scrapedAt` | string | ISO time the row was produced |
| `sourceTag` | string | Your tag, only if you set one |
| `audit` | object | Only with `includeAudit` |

```json
{
  "fullName": "Patrick Collison",
  "jobTitle": "CEO",
  "company": "Stripe",
  "profileUrl": "https://www.linkedin.com/in/patrickcollison",
  "email": "patrick@stripe.com",
  "emailSource": "found",
  "emailStatus": "valid",
  "confidence": 1,
  "reached": "verified",
  "inputQuery": "Patrick Collison @ stripe.com",
  "scrapedAt": "2026-11-02T10:00:00.000Z"
}
```

*Illustrative row. Which fields appear depends on what is on file for the person.*

#### Confidence rule

Base by status: `valid` 0.95, `catch_all` 0.6, `unknown` 0.5. Add 0.05 when the input was matched to a person. Maximum 1. A catch-all domain accepts any address, so even a good verdict there is a weaker signal.

#### Other records in the run

- **`MISSES`** (key-value store): `[{input, reason}]` for every input without a row: `not_found`, `no_email`, `invalid_email`, `invalid_input`, `no_result` or `Upstream HTTP <code>`.
- **`errors-waterfall-contact-enrichment`**: lookups that failed for a technical reason, so you can re-run only those.
- **`RUN_SUMMARY`**: counts, events charged by name, stop reason, your `sourceTag`.
- **Notice rows** (`type: "notice"`) appear for invalid input, a free-plan list longer than 100, or a reached spend cap. Notices are never charged.

#### What is NOT returned

- No phone numbers
- No personal email addresses
- No several alternative addresses per person

***

### Reliable results

**Our data promise.** We work to keep every contact current, and we stand behind it. If a record is outdated, an email bounces or a person has moved on, open the Issues tab with your run link. We review every report and sort it out with you, case by case.

- **Misses are free and visible.** Every input without a row is in `MISSES` with a reason and is never charged.
- **You pay per step delivered.** A step that does not deliver is not charged.
- **Duplicates are collapsed.** The same person written two ways is looked up once.
- **Each row is saved as it is delivered.** A run that stops keeps what it already produced.
- **Bounded pace.** Lookups run at 60 requests per minute. Each row needs one to three requests, so 500 rows take about 20 minutes.
- **Typos fail early.** An unsupported input field or a limit above 500 ends the run before any charge.
- **Check before you send.** `emailStatus` and `confidence` are signals, not a guarantee.

***

### Use with AI agents (MCP)

This actor runs through the Apify MCP server. Prompt to copy:

> "Use b2bdata/waterfall-contact-enrichment with items=\["Patrick Collison @ stripe.com"], stopAt="verified" and limit=1. Return email, emailStatus, confidence and reached."

**When to use it.** You hold a LinkedIn URL, an email, or a name and a company domain, and need the person and a checked work email in one call.
**When not to.** You need phone numbers (use phone-number-finder), or you hold only a company (use a decision-maker actor first).
**Input contract.** `items` array of strings or objects; `stopAt` `person`, `email` or `verified`; `limit` 1-500 (0 = all).
**Output contract.** One row per resolved input with `reached`, `email`, `emailStatus`, `confidence`. Output is deterministic and schema-stable, rows keep input order. Inputs that resolve to nothing are absent from the dataset and listed in `MISSES`.
**Smoke test.** `limit: 1` on a well-known person returns one row.
**Error cases.** `No valid items` means no line was a LinkedIn URL, an email, or a full name with a domain. `remove unsupported fields` means a misspelled input key. Both end the run SUCCEEDED with one free `invalid-input` notice row and nothing charged.

***

### Integrations

1. **API.** Fetch rows from the run's default dataset: `GET /v2/datasets/{datasetId}/items?format=csv`.
2. **Webhook** on run success to push rows to your own endpoint.
3. **n8n, Make, Zapier.** Use the native Apify nodes: run the actor, then read the dataset.
4. **Google Sheets, Airtable, HubSpot, Salesforce, Clay.** Map `email` to the email field and `emailStatus` to a status column.

***

### Related actors

- `b2bdata/email-finder`: you have a name and a domain and only need the email. [Open](https://apify.com/b2bdata/email-finder)
- `b2bdata/contact-enrich-csv`: enrich a spreadsheet and keep your own columns. [Open](https://apify.com/b2bdata/contact-enrich-csv)
- `b2bdata/bulk-linkedin-profile-enrichment`: you have LinkedIn URLs and need the full profile. [Open](https://apify.com/b2bdata/bulk-linkedin-profile-enrichment)
- `b2bdata/phone-number-finder`: you need phone numbers from a LinkedIn URL. [Open](https://apify.com/b2bdata/phone-number-finder)

[See all actors by b2bdata](https://apify.com/b2bdata)

***

### FAQ

**What is waterfall email enrichment?**
It tries one step after another: match the person, find a work email, check the mailbox. Each step runs only if the one before delivered, and it stops at the step you choose.

**Can I find a work email from a LinkedIn URL?**
Yes. Add the URL as an item. The person is matched, the employer's domain is looked up, and a work email is found on it. If no domain or email can be found, the input is listed in `MISSES` and is free.

**Can I enrich a mixed list of emails, LinkedIn URLs and names?**
Yes. Every line is read on its own, in any of the accepted forms, and the results keep your order.

**Do I need a LinkedIn account or cookies?**
No. It works without LinkedIn login, cookies or an account to ban.

**What do I pay when nothing is found?**
Nothing. There is no start fee. A step is charged only when it delivers: a matched contact, a found email, a clear verdict.

**Why is the status sometimes `unknown`?**
A mailbox check does not always give a clear answer. An unclear verdict is delivered as `unknown` and is not charged as verified.

***

### Support

Use the **Issues** tab on this actor's Apify page for bugs, questions and feature requests. We answer within 48 hours on working days. Please include the run ID and the input you used.

Published on Apify by b2bdata as proprietary software. Not affiliated with, endorsed by, or sponsored by any company or person named in the examples above.

# Changelog

This Actor's version history is a separate document: https://apify.com/b2bdata/waterfall-contact-enrichment/changelog.md

# Actor input Schema

## `items` (type: `array`):

One person per line, in any of these forms: a LinkedIn URL or slug ("https://www.linkedin.com/in/patrickcollison"), an email ("patrick@stripe.com"), or "First Last @ domain" ("Patrick Collison @ stripe.com"). Via API an entry may also be an object {"first":"Patrick","last":"Collison","domain":"stripe.com"}. Results keep input order; duplicates are collapsed; unreadable lines are listed in the MISSES record and are never charged.

## `stopAt` (type: `string`):

person = match the profile and stop. email = also find a work email. verified = also check that email. You pay only for the steps that run and deliver. Omit for verified.

## `limit` (type: `integer`):

Maximum inputs looked up in this run. 0 = all lines, up to the hard maximum of 500. Above 500 the run stops with a message; split the list.

## `includeAudit` (type: `boolean`):

Adds the steps that ran, the upstream units used and the cleaned input to each row under audit. Omit for clean rows.

## `maxChargeUsd` (type: `number`):

Stop looking up new inputs once this run has charged this much. 0 or omitted = no cap beyond your Apify run limit.

## `sourceTag` (type: `string`):

Free text up to 64 characters, copied into every row and the run summary so you can trace a row to your own workflow.

## Actor input object example

```json
{
  "items": [
    "Patrick Collison @ stripe.com"
  ],
  "stopAt": "verified",
  "limit": 25,
  "includeAudit": false
}
```

# Actor output Schema

## `contacts` (type: `string`):

Person, work email, status and confidence per row

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

Counts, charged events and stop reason for this run

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "items": [
        "Patrick Collison @ stripe.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("b2bdata/waterfall-contact-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 = { "items": ["Patrick Collison @ stripe.com"] }

# Run the Actor and wait for it to finish
run = client.actor("b2bdata/waterfall-contact-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 '{
  "items": [
    "Patrick Collison @ stripe.com"
  ]
}' |
apify call b2bdata/waterfall-contact-enrichment --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,b2bdata/waterfall-contact-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/gMPlUb4BBWBPk0Qsd/builds/h2eANAKAsu61pEjWW/openapi.json
