> Reverse Contact API documentation. This is the Markdown copy of https://docs.reversecontact.com/guides/bulk-enrichment
> Every page is listed at https://docs.reversecontact.com/llms.txt, every endpoint with its credit cost and status at https://docs.reversecontact.com/catalog.json

# Enrich people in bulk

> Enrich up to 25,000 people in one request, follow the job, then read the results as they come or download one row per person as CSV or NDJSON.

- Last verified: 2026-11-04

Bulk enrichment looks up a whole file of people in one request. You send up to 25,000 people as JSON, each with a Social profile URL, an email, or a name with its company, and choose what to find for them: the profile, a work email, a phone number. The job runs in the background, and you get one row per person, in the order you sent them, with your own id on each row: read them as they come, or download them as a CSV or NDJSON file.

It is the API version of [Bulk Import](https://app.reversecontact.com/bulk) in the app: the same lookups, the same prices, the same CSV.

## When to use Bulk enrichment

| You want to                                                       | Use                                                                 |
| ----------------------------------------------------------------- | ------------------------------------------------------------------- |
| Look up one person while a user waits                             | [Enrich profile](https://docs.reversecontact.com/endpoints/enrich-person) or [Find Email](https://docs.reversecontact.com/endpoints/contact-email) |
| Enrich a file or a CRM export once and get it back as CSV         | Bulk enrichment                                                     |
| Keep the people, add to them over time, never pay twice for someone | A [List](https://docs.reversecontact.com/guides/lists)                                      |
| Work on companies, or cap the spend with `maxCredits`             | A [List](https://docs.reversecontact.com/guides/lists)                                        |
| Enrich a CSV without writing code                                 | [Bulk Import](https://app.reversecontact.com/bulk) in the app                                     |

Bulk enrichment keeps nothing once you have the file. If you will come back to the same people, to buy their phone numbers next month or to add next week's leads, start with a List instead.

## How a job runs

1. **Start the job** with [Bulk enrichment](https://docs.reversecontact.com/endpoints/bulk-enrich-persons), `POST /v2/enrich/persons/bulk`. It answers `202` at once with the job.
2. **Follow it** with [Get a job](https://docs.reversecontact.com/endpoints/job), `GET /v2/jobs/:jobId?wait=20`, while its `status` is `queued` or `running`. Or pass a `webhookUrl`: it receives a `job.updated` event when the job settles.
3. **Get the results**: read them a page at a time while the job runs with [Read job results by page](https://docs.reversecontact.com/endpoints/job-rows), `GET /v2/jobs/:jobId/rows`, or download them once it settles with [Download job results](https://docs.reversecontact.com/endpoints/job-results), `GET /v2/jobs/:jobId/results`: one row per item, as CSV or NDJSON.
4. **Stop it** if you need to with [Cancel a job](https://docs.reversecontact.com/endpoints/job-cancel), and find any past job in the [Job history](https://docs.reversecontact.com/endpoints/jobs).

Starting, following, reading the results and cancelling are free. You pay only for what the items find.

## What it costs

Each item costs what its unit call costs, and only for what is found:

| What the item looks up             | Credits                                                                       |
| ---------------------------------- | ----------------------------------------------------------------------------- |
| Profile, from a Social profile URL | 1, or 2 with `live: true`                                                     |
| Profile, from an email or a name   | 2                                                                             |
| `contact_email`                    | 3 for a professional email, 5 for a personal one, 8 for both with `emailType: "any"` |
| `contact_phone`                    | 20 per phone number found                                                     |

When the job starts, it reserves the most each item can cost, in the order you sent them, as long as your balance covers it. An item the balance no longer covers is not run: its row reads `insufficient_credits`, it costs nothing, and `notRunForCredits` counts it, while a later item that costs less can still run. When the job settles, what an item did not find is given back, so you pay only for what was found.

- An item that names the same person as an earlier one is looked up once: its row reads `duplicate` and costs nothing.
- The email and phone finders start from a Social profile URL, or from a first name, a last name and a company domain. Any other item first has its profile matched, charged like a profile lookup when it is found.
- On a Go, Plus or Max plan, a profile your plan covers costs no credit, as in its unit call. Once the workspace has delivered its [daily profile cap](https://docs.reversecontact.com/guides/rate-limits#daily-profile-cap-of-the-go-plus-and-max-plans), the items of the job wait until 00:00 UTC instead of being refused.
- A key with [credit limits](https://docs.reversecontact.com/guides/api-key-management#_4-set-credit-limits-optional) must hold the whole reservation, and counts only what the job spent once it settles.

## Step by step

### 1. Prepare the items

Each item takes a `linkedinUrl`, an `email`, or a `firstName` and `lastName` with a `companyDomain` (or a `companyName` when you have no domain). Add `externalId`, the id of the line in your CRM or file: it comes back on the row of the item. An item with no field at all is not run and reads `skipped`, and an item that holds only a company is looked up but rarely matches anyone.

Then choose `families`: `profile` (the default), `contact_email`, `contact_phone`, or several. With `contact_email`, `emailType` picks `professional` (the default), `personal` or `any`.

### 2. Start the job

**POST /v2/enrich/persons/bulk**

```bash [cURL]
curl -X POST https://api.reversecontact.com/v2/enrich/persons/bulk \
  -H "Authorization: Bearer $RC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9e2f4a1c-6b3d-4e7f-8a5c-2d1b0e9f8a7c" \
  -d '{
    "name": "CRM export, November",
    "items": [
      { "linkedinUrl": "https://www.social.com/in/janedoe", "externalId": "crm-001" },
      { "firstName": "John", "lastName": "Smith", "companyDomain": "acme.com", "externalId": "crm-002" }
    ],
    "families": ["profile", "contact_email"]
  }'
```

The answer gives the job in `data.job`: keep its `id` (`$JOB_ID` in the commands below). `creditsEstimated` is what the job reserved, `duplicateCount` the items looked up once, and `notRunForCredits` the items your balance did not cover.

Send an `Idempotency-Key` you choose for the job. If the answer gets lost, send the same request with the same key: for 24 hours, it returns the job it created instead of reserving credits for a second one.

### 3. Follow the job

**GET /v2/jobs/:jobId**

```bash [cURL]
curl "https://api.reversecontact.com/v2/jobs/$JOB_ID?wait=20" \
  -H "Authorization: Bearer $RC_API_KEY"
```

With `wait=20`, each read waits up to 20 seconds for the job to settle. Read it again while `status` is `queued` or `running`, with an HTTP timeout above 20 seconds. These reads are free, also at a zero balance, and never counted against your rate limits. `progress` counts the items done, and `credits.used` is set once the job settles.

Instead of reading, you can pass a public HTTPS `webhookUrl` when you start the job: it receives one `job.updated` event when the job completes, is cancelled or fails, with the path of the results. A failed delivery is tried again for about 3 hours, so the same event can arrive twice: key it on `jobId` and `status`. Read the job if the event has not arrived.

### 4. Download the results

**GET /v2/jobs/:jobId/results**

```bash [cURL]
curl -o results.csv "https://api.reversecontact.com/v2/jobs/$JOB_ID/results" \
  -H "Authorization: Bearer $RC_API_KEY"
```

The file is ready once the job is `completed` or `cancelled`: `links.results` of the job is set then. It holds one row per item, in the order you sent them: your fields and `externalId` first, then `rc_status`, `rc_message`, and the columns of what you asked for, such as `rc_email` or the `rc_` profile columns. See [Download job results](https://docs.reversecontact.com/endpoints/job-results) for every column. The file is UTF-8 with a byte order mark, so a spreadsheet opens accented names correctly.

For a program, add `format=ndjson`: the file then holds one JSON object per item, with the person in the shape of [Enrich profile](https://docs.reversecontact.com/endpoints/enrich-person).

**GET /v2/jobs/:jobId/results?format=ndjson**

```bash [cURL]
curl -o results.ndjson "https://api.reversecontact.com/v2/jobs/$JOB_ID/results?format=ndjson" \
  -H "Authorization: Bearer $RC_API_KEY"
```

### Or read the results as they come

You do not have to wait for the job to settle: [Read job results by page](https://docs.reversecontact.com/endpoints/job-rows) returns the same JSON objects, up to 100 per page, in the order sent, from the start of the run. Pass `nextCursor` back as `cursor` until it is `null`. While the next item still runs, the page is empty: read the same cursor again after the `Retry-After` seconds. Each page counts toward your rate limits, and the items of a running job already take up to 80% of your per-minute limit: read with `limit=100`, at the `Retry-After` pace.

**GET /v2/jobs/:jobId/rows**

```bash [cURL]
curl "https://api.reversecontact.com/v2/jobs/$JOB_ID/rows?limit=100" \
  -H "Authorization: Bearer $RC_API_KEY"
# The next page: pass back data.nextCursor of the previous one.
curl "https://api.reversecontact.com/v2/jobs/$JOB_ID/rows?limit=100&cursor=$NEXT_CURSOR" \
  -H "Authorization: Bearer $RC_API_KEY"
```

## The complete pipeline

**Start, follow, download**

```javascript [JavaScript]
import { writeFile } from "node:fs/promises";

const BASE = "https://api.reversecontact.com";
const headers = { "Authorization": `Bearer ${process.env.RC_API_KEY}` };

// 1. Start the job: keep the Idempotency-Key to retry safely
const created = await fetch(`${BASE}/v2/enrich/persons/bulk`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    name: "CRM export, November",
    items: [
      { linkedinUrl: "https://www.social.com/in/janedoe", externalId: "crm-001" },
      { firstName: "John", lastName: "Smith", companyDomain: "acme.com", externalId: "crm-002" }
    ],
    families: ["profile", "contact_email"]
  })
});
let { data: { job } } = await created.json();

// 2. Follow it while it is queued or running
while (job.status === "queued" || job.status === "running") {
  const response = await fetch(`${BASE}${job.links.self}?wait=20`, { headers });
  job = (await response.json()).data;
}

// 3. Download the results: write the bytes, byte order mark included
if (job.links.results) {
  const response = await fetch(`${BASE}${job.links.results}`, { headers });
  await writeFile("results.csv", Buffer.from(await response.arrayBuffer()));
}
console.log(job.status, `${job.credits.used} credits`);
```
```python [Python]
import os
import uuid

import requests

BASE = "https://api.reversecontact.com"
headers = {"Authorization": f"Bearer {os.environ['RC_API_KEY']}"}

# 1. Start the job: keep the Idempotency-Key to retry safely
job = requests.post(
    f"{BASE}/v2/enrich/persons/bulk",
    headers={**headers, "Idempotency-Key": str(uuid.uuid4())},
    json={
        "name": "CRM export, November",
        "items": [
            {"linkedinUrl": "https://www.social.com/in/janedoe", "externalId": "crm-001"},
            {"firstName": "John", "lastName": "Smith", "companyDomain": "acme.com", "externalId": "crm-002"},
        ],
        "families": ["profile", "contact_email"],
    },
).json()["data"]["job"]

# 2. Follow it while it is queued or running
while job["status"] in ("queued", "running"):
    job = requests.get(
        f"{BASE}{job['links']['self']}", headers=headers, params={"wait": 20}, timeout=30
    ).json()["data"]

# 3. Download the results
if job["links"]["results"]:
    with open("results.csv", "wb") as file:
        file.write(requests.get(f"{BASE}{job['links']['results']}", headers=headers).content)
print(job["status"], job["credits"]["used"], "credits")
```
```typescript [TypeScript SDK]
import { writeFile } from "node:fs/promises";
import ReverseContact from "@reverse-contact/sdk";

const client = new ReverseContact({ apiKey: process.env.RC_API_KEY });

// 1. Start the job: the SDK sends an Idempotency-Key and reuses it on its retries
const created = await client.bulk.enrichPersons({
  name: "CRM export, November",
  items: [
    { linkedinUrl: "https://www.social.com/in/janedoe", externalId: "crm-001" },
    { firstName: "John", lastName: "Smith", companyDomain: "acme.com", externalId: "crm-002" }
  ],
  families: ["profile", "contact_email"]
});
const jobId = created.data.job.id;

// 2. Follow it until it no longer moves
const job = await client.bulk.wait(jobId);

// 3. Download the results: bulk.results returns the text without its byte order mark,
// so write "\uFEFF" before it for a spreadsheet to open accented names correctly
if (job.data.links.results) {
  await writeFile("results.csv", "\uFEFF" + (await client.bulk.results(jobId)));
}
console.log(job.data.status, "credits:", job.data.credits.used);
```
```python [Python SDK]
import os

from reversecontact import ReverseContact

with ReverseContact(api_key=os.environ["RC_API_KEY"]) as client:
    # 1. Start the job: the SDK sends an Idempotency-Key and reuses it on its retries
    created = client.bulk.enrich_persons(
        name="CRM export, November",
        items=[
            {"linkedinUrl": "https://www.social.com/in/janedoe", "externalId": "crm-001"},
            {"firstName": "John", "lastName": "Smith", "companyDomain": "acme.com", "externalId": "crm-002"},
        ],
        families=["profile", "contact_email"],
    )
    job_id = created.data.job.id

    # 2. Follow it until it no longer moves
    job = client.bulk.wait(job_id)

    # 3. Download the results
    if job.data.links.results:
        with open("results.csv", "w", encoding="utf-8") as file:
            file.write(client.bulk.results(job_id))
    print(job.data.status, "credits:", job.data.credits.used)
```

## Handle the results

`rc_status` says what happened to each row, and what to do with it:

| `rc_status`             | What to do                                                                 |
| ----------------------- | -------------------------------------------------------------------------- |
| `enriched`, `partial`   | Use the row. `partial` found part of what you asked, such as the profile but no email |
| `not_found`             | Nothing to retry: only a profile matched on the way to a finder was charged |
| `duplicate`             | Read the row of the earlier item that names the same person                 |
| `insufficient_credits`  | Top up, then send these items again in a new job                            |
| `rate_limited`, `error` | Send these items again later: they cost nothing                             |
| `skipped`               | Not run: the item had nothing to look up, or the job was cancelled before it. Complete the item, or send it again |
| `invalid_input`         | Fix the item, such as an invalid Social profile URL, then send it again     |
| `data_subject_blocked`  | Drop the person: they asked not to be processed under GDPR or CCPA          |
| `unknown`               | The lookup may have run but its result was not saved. It is not run again on its own |
| `running`               | Only in the file of a cancelled job: the item was still running. It ends within the hour: download the file again for its outcome |

To retry, filter the rows by `rc_status` and send their items in a new job, with a new `Idempotency-Key`. `externalId` ties each new row back to your file.

## Cancel a job and read the history

[Cancel a job](https://docs.reversecontact.com/endpoints/job-cancel) stops a job an API key or an AI assistant started: items already in flight may finish and are charged as usual, and items not started are never charged and read `skipped`. The results of the items done stay downloadable. An item in flight ends within the hour: until then, its row of the file reads `running`, and a page of [Read job results by page](https://docs.reversecontact.com/endpoints/job-rows) waits for it. A job started by a person in the app or with Ask is stopped there.

The [Job history](https://docs.reversecontact.com/endpoints/jobs) lists every job of your workspace, newest first, whoever started it: your Bulk enrichment jobs, the files enriched with Bulk Import in the app, the imports and enrichments of your Lists, and the Search exports. Filter it by `type`, `status` or List.

## Limits

| Limit                                  | Value                                                    |
| -------------------------------------- | -------------------------------------------------------- |
| Items per request                      | 25,000                                                   |
| Request body                           | 25 MB of JSON, above it `413 REQUEST_BODY_TOO_LARGE`     |
| Rate limits                            | Starting a job counts as one request, whatever its number of items. Its items then take at most 80% of your per-minute limit as they run, and wait when that share is taken |
| Following a job                        | Free and never counted against your rate limits          |
| Downloading the results                | Free, counted like any other call                        |
| Reading the results by page            | Up to 100 items and 2 MB of JSON per page. Free, each page counted like any other call |

A larger file goes in several jobs. The API key needs the permission of what the job asks for: Person Enrichment when `families` includes `profile` (the default), Contact for `contact_email` and `contact_phone`. See [Rate limits](https://docs.reversecontact.com/guides/rate-limits#how-requests-are-counted) for how a job shares the per-minute limit with your other calls.

## FAQ

### How many people can I enrich in one Bulk enrichment request?

`POST /v2/enrich/persons/bulk` takes up to 25,000 people per request, in a JSON body of at most 25 MB. Each person is found best from a Social profile URL, an email, or a first and last name with a company. Split a larger file into several jobs.

### How much does Reverse Contact Bulk enrichment cost?

Each person costs what its unit call costs, only for what is found: 1 credit for a profile from a Social profile URL (2 with `live: true`), 2 from an email or a name, 3 for a professional email, 5 for a personal one and 20 for a phone number. The job reserves the most each person can cost when it starts and gives back what was not found. Starting, following, downloading and cancelling a job are free.

### What happens when my balance does not cover every person of a Bulk enrichment job?

The job reserves credits person by person, in the order you sent them, as long as your balance covers them, and does not run the people it no longer covers: their rows read `insufficient_credits` in the results and cost nothing, and `notRunForCredits` counts them. Top up, then send those people again in a new job.

### How do I know when a Bulk enrichment job is finished?

Read `GET /v2/jobs/:jobId?wait=20` while `status` is `queued` or `running`: each read waits up to 20 seconds and is free. The job is finished when `status` is `completed`, `cancelled` or `failed`. You can also pass a public HTTPS `webhookUrl` when you start the job: it receives a `job.updated` event once the job settles. The results file, CSV or NDJSON, is then at `GET /v2/jobs/:jobId/results`. To use the results before the job finishes, read them a page at a time with `GET /v2/jobs/:jobId/rows`.

### Should I use Bulk enrichment or a List?

Use Bulk enrichment to enrich a file of people once and get it back as CSV: nothing is kept. Use a List to keep people or companies in your workspace, add to them over time, buy their data later and never pay twice for someone already on the List, with a `maxCredits` cap on each import.
