> ## Documentation Index
> Fetch the complete documentation index at: https://www.forager.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Forager is a REST API for B2B people, company and job post data: contact lookups (phone numbers, personal emails, work emails), person, company and job post search, and bulk data feeds.
> Server: https://api-v2.forager.ai. Send the API key in the X-API-KEY header. Most paths include {account_id}. Read FORAGER_API_KEY and FORAGER_ACCOUNT_ID from the environment; never put the key in client-side code, logs or prompts.
> To add Forager to a project, follow https://www.forager.ai/docs/guides/agent-quickstart.md. To pick an endpoint, read https://www.forager.ai/docs/guides/choose-an-endpoint.md. For end-to-end builds, see https://www.forager.ai/docs/recipes/overview.md.
> Most requests spend credits. Do not guess costs; use https://www.forager.ai/docs/api-overview/credit-pricing.md and ask the user before spending credits.
> Use only endpoints and fields defined in https://www.forager.ai/docs/openapi.yaml. Forager has no MCP server for the API yet; the /mcp endpoint on this site searches the documentation only.

# Bulk email finder for work emails

> Submit a batch of work email lookups, poll until it completes, match results to your rows and retry only what failed.

```text Recipe prompt theme={"theme":{"light":"github-light","dark":"github-dark"}}
Implement this recipe in my project: https://www.forager.ai/docs/recipes/bulk-work-emails.md

Build a batch job that reads rows with first_name, last_name and domain, or a LinkedIn public identifier, and submits them to the Forager bulk work email lookup. Poll the batch with backoff until its status is completed, then match each result to its input row using submitted_index. Handle not_found rows, failed rows with an error_code, a rejected batch, a 403, and a 429 with backoff, and retry only the retryable rows in a new batch. Keep the API key server-side in FORAGER_API_KEY with FORAGER_ACCOUNT_ID. Add tests that stub the HTTP calls.
```

![Bulk email finder: a batch of lookups, progress while polling, then results](https://mintcdn.com/forager/OnhSHNo_LBQ7yKjR/images/recipes/bulk-email-finder-batch-results.svg?fit=max\&auto=format\&n=OnhSHNo_LBQ7yKjR\&q=85\&s=7cd1f83f1041985d14fded1d6ecd9055)

## What you'll build

A batch enrichment job. It takes a file of people, sends them to Forager in one request, waits for the batch to finish, and writes a result for every input row, found or not. Use this instead of the single lookup for enrichment runs, because the single lookup is rate limited for interactive use.

```mermaid theme={"theme":{"light":"github-light","dark":"github-dark"}}
flowchart LR
  A[Input rows] --> B[Validate and dedupe]
  B --> C[Submit batch]
  C --> D[Poll batch]
  D --> E[Match by index]
  E --> F[Retry failures]
```

## Steps

**1. Prepare the rows**

Each row identifies a person one of two ways: `first_name`, `last_name` and `domain`, or `linkedin_public_identifier` alone. A row with a domain and an identifier is refused, so check this before you send. Forager collapses duplicates in a request into one lookup, but deduplicate on your side too so your row count matches your records.

**2. Submit the batch**

Send a `POST` to [Bulk work email lookup](https://www.forager.ai/docs/openapi/work-emails/v2_datastorage_person_contacts_lookup_work_emails_bulk_create.md) with `email_lookups`. The response has a `batch_id`, `item_count` and `status`.

```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
BATCH_ID=$(curl -s -X POST "https://api-v2.forager.ai/api/v2/$FORAGER_ACCOUNT_ID/datastorage/person_contacts_lookup/work_emails/bulk/" \
  -H "X-API-KEY: $FORAGER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email_lookups": [
        {"first_name": "Jane", "last_name": "Doe", "domain": "example.com"},
        {"linkedin_public_identifier": "jane-doe-example"}
      ]}' | jq -r .batch_id)
```

```python Python (requests) theme={"theme":{"light":"github-light","dark":"github-dark"}}
import os, requests

BASE = f"https://api-v2.forager.ai/api/v2/{os.environ['FORAGER_ACCOUNT_ID']}/datastorage/person_contacts_lookup/work_emails/bulk/"
HEADERS = {"X-API-KEY": os.environ["FORAGER_API_KEY"]}

rows = [
    {"first_name": "Jane", "last_name": "Doe", "domain": "example.com"},
    {"linkedin_public_identifier": "jane-doe-example"},
]
r = requests.post(BASE, headers=HEADERS, json={"email_lookups": rows}, timeout=60)
r.raise_for_status()
batch_id = r.json()["batch_id"]
```

```javascript JavaScript (fetch) theme={"theme":{"light":"github-light","dark":"github-dark"}}
const BASE = `https://api-v2.forager.ai/api/v2/${process.env.FORAGER_ACCOUNT_ID}/datastorage/person_contacts_lookup/work_emails/bulk/`;
const headers = {
  "X-API-KEY": process.env.FORAGER_API_KEY,
  "Content-Type": "application/json",
};

const rows = [
  { first_name: "Jane", last_name: "Doe", domain: "example.com" },
  { linkedin_public_identifier: "jane-doe-example" },
];
const res = await fetch(BASE, { method: "POST", headers, body: JSON.stringify({ email_lookups: rows }) });
if (!res.ok) throw new Error(`Forager ${res.status}`);
const { batch_id: batchId } = await res.json();
```

**3. Poll until the batch completes**

Poll [Bulk work email lookup status and results](https://www.forager.ai/docs/openapi/work-emails/v2_datastorage_person_contacts_lookup_work_emails_bulk_retrieve.md). `status` is `queued`, `running`, `completed` or `failed`. `results` stays empty until the batch is `completed`, so an empty list is never a partial answer. Polling is free.

```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl "https://api-v2.forager.ai/api/v2/$FORAGER_ACCOUNT_ID/datastorage/person_contacts_lookup/work_emails/bulk/$BATCH_ID/" \
  -H "X-API-KEY: $FORAGER_API_KEY"
```

```python Python (requests) theme={"theme":{"light":"github-light","dark":"github-dark"}}
import time

def wait(batch_id, max_wait=1800):
    delay, deadline = 2, time.time() + max_wait
    while time.time() < deadline:
        r = requests.get(f"{BASE}{batch_id}/", headers=HEADERS, timeout=30)
        if r.status_code != 429:  # on 429, back off and poll again
            r.raise_for_status()
            batch = r.json()
            if batch["status"] in ("completed", "failed"):
                return batch
        time.sleep(delay)
        delay = min(delay * 2, 30)
    raise TimeoutError(batch_id)
```

```javascript JavaScript (fetch) theme={"theme":{"light":"github-light","dark":"github-dark"}}
async function wait(batchId, maxWaitMs = 1800000) {
  let delay = 2000;
  const deadline = Date.now() + maxWaitMs;
  while (Date.now() < deadline) {
    const r = await fetch(`${BASE}${batchId}/`, { headers });
    if (r.status !== 429) {
      // on 429, back off and poll again
      if (!r.ok) throw new Error(`Forager ${r.status}`);
      const batch = await r.json();
      if (["completed", "failed"].includes(batch.status)) return batch;
    }
    await new Promise((ok) => setTimeout(ok, delay));
    delay = Math.min(delay * 2, 30000);
  }
  throw new Error(`Timed out: ${batchId}`);
}
```

**4. Match results to your rows**

Each item in `results` has `submitted_index`, the zero-based position of the row you sent, along with `status`, `result`, `emails`, `error_code` and `submitted_lookup`. Use `submitted_index` to write each result back to its row. Treat the outcomes like this:

| Item | Action |
| - | - |
| `result` is `found` | Save `emails` |
| `result` is `not_found` | Mark the row done with no email |
| `status` is `failed`, `error_code` is `provider_error`, `expired` or `internal_error` | Retry in a new batch |
| `status` is `failed`, `error_code` is `person_not_found`, `no_current_employer` or `input_unresolved` | Mark the row unresolved; fix the input first |

If the batch itself ends with `status` `failed`, `results` is empty. Resubmit those rows in a new batch, cap the retries, then mark the rows as errors.

**Example result item (illustrative values)**

The spec describes each entry in `emails` as an address with a verdict, but types it as a string, so check the shape you receive before you parse it.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "submitted_index": 0,
  "job_id": "00000000-0000-4000-8000-000000000000",
  "status": "completed",
  "result": "found",
  "emails": ["jane.doe@example.com"],
  "error_code": null,
  "person_id": 123,
  "submitted_lookup": { "first_name": "Jane", "last_name": "Doe", "domain": "example.com", "linkedin_public_identifier": "" }
}
```

## Handle the edge cases

* **Not found.** `not_found` is a normal result. Keep it, so you do not look the same person up again.
* **Partial results.** A completed batch can mix found, not found and failed rows. Retry only the retryable failures, in a new batch, and cap the retries.
* **Rejected batch.** Your credit balance must cover the whole batch, otherwise the batch is rejected and nothing is charged. Check your balance before large runs.
* **403.** The key is missing or invalid. Fix the credentials; do not retry.
* **429.** Back off and retry. The poll loop above backs off on a `429` instead of failing. See [errors and rate limits](https://www.forager.ai/docs/errors-and-rate-limits.md).
* **Credits.** Each lookup in a batch costs 5 credits, charged only when an address is found. Polling never costs credits. See [credit pricing](https://www.forager.ai/docs/api-overview/credit-pricing.md).

## Related

> [card] [Bulk work emails](https://www.forager.ai/docs/guides/bulk-work-emails.md)
>
> The guide behind this recipe.

> [card] [Waterfall step](https://www.forager.ai/docs/recipes/waterfall-step.md)
>
> Use a single lookup inside a pipeline.

> [card] [Bulk work email lookup](https://www.forager.ai/docs/openapi/work-emails/v2_datastorage_person_contacts_lookup_work_emails_bulk_create.md)
>
> Request and response reference.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.