> Reverse Contact API documentation. This is the Markdown copy of https://docs.reversecontact.com/endpoints/bulk-enrich-persons
> 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

# Bulk enrichment

> Enrich up to 25,000 people in one request, priced like the unit calls and refunded for what is not found. Read results as they come, or as CSV or NDJSON.

- Endpoint: `POST https://api.reversecontact.com/v2/enrich/persons/bulk`
- Credits: Free to send. Each item costs what its unit call costs, reserved when the job is created and refunded for what is not found.
- Answer: asynchronous. The request returns the job (`data.job`): read `GET {data.job.links.self}?wait=20` while `status` is `queued` or `running`, or receive `job.updated` on its `webhookUrl`, then download the CSV or NDJSON at `links.results`, or read the results while it runs at `GET /v2/jobs/{jobId}/rows`. Reading the job is free and never rate limited; the download and each page of results are free and count as one request.
- Available in: REST API, MCP tool `bulk_enrich_persons`, TypeScript and Python SDKs, CLI command `rc bulk create`.
- Last verified: 2026-11-04

## Introduction

The Bulk enrichment endpoint enriches up to 25,000 people in one request. Send them as JSON items (a Social profile URL, an email, or a name with its company), choose what to look up for each one (the profile, an email, a phone number), and it runs in the background as a job. It answers at once with the job: follow it with [Get a job](https://docs.reversecontact.com/endpoints/job), or be told on your `webhookUrl` when it settles. Read its results a page at a time while it runs with [Read job results by page](https://docs.reversecontact.com/endpoints/job-rows), or download one row per item, as CSV or NDJSON, with [Download job results](https://docs.reversecontact.com/endpoints/job-results).

Bulk enrichment fits a one-off need, such as a CRM export or a file of leads to complete: you get a file back and nothing is kept. To keep people over time, add data to them later and never pay twice for the same person, use a [List](https://docs.reversecontact.com/guides/lists) instead. To look up one person while a user waits, call [Enrich profile](https://docs.reversecontact.com/endpoints/enrich-person) or [Find Email](https://docs.reversecontact.com/endpoints/contact-email).

## Credits

Sending the request is **free**. 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`, as [Person profile](https://docs.reversecontact.com/endpoints/fetch-profile)                                                        |
| Profile, from an email or a name   | 2, as [Enrich profile](https://docs.reversecontact.com/endpoints/enrich-person)                                                                                |
| `contact_email`                    | 3 for a professional email, 5 for a personal one, 8 for both with `emailType: "any"`, as [Find Email](https://docs.reversecontact.com/endpoints/contact-email) |
| `contact_phone`                    | 20 per phone number found, as [Find Phone](https://docs.reversecontact.com/endpoints/contact-phone)                                                            |

When the job is created, 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: it reads `insufficient_credits` in the results, 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: you pay only for what was found. On a Go, Plus or Max plan, a profile your plan covers costs no credit in a job, as it does in its unit call, and 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.

The email and phone finders start from a Social profile URL, or from a first name, a last name and a company domain. For any other item, the job first matches the person's profile, charged like a profile lookup when it is found: an item with only an email, sent for a phone number, costs 2 credits for its profile, plus 20 if a number is found. An item with only an email is never sent to the email finder, since it already holds one.

An item that names the same person as an earlier item of the job is looked up once: its row reads `duplicate`, costs nothing, and `duplicateCount` counts it.

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`. A key with [credit limits](https://docs.reversecontact.com/guides/api-key-management#_4-set-credit-limits-optional) must hold the whole reservation: a job that would take it over its daily, monthly or total limit is refused with `KEY_CREDIT_LIMIT_REACHED` before anything is reserved. Once the job settles, the key counts only what it spent.

Creating a job counts as one request on your rate limits, whatever its number of items. Its items then take their share of your per-minute limit as they run, at most 80% of it, and wait when that share is taken: see [Rate limits](https://docs.reversecontact.com/guides/rate-limits#how-requests-are-counted). Reading the job is never counted. Following it, downloading its results and cancelling it still work at a zero balance.

## Authorization

| Name     | Type   | Required | Description                                                                                         |
| -------- | ------ | -------- | --------------------------------------------------------------------------------------------------- |
| `apikey` | string | Yes      | Your API key from the developer dashboard. Pass it via `Authorization: Bearer YOUR_API_KEY` header. |

## Request body

The route is `POST /v2/enrich/persons/bulk`.

| Name         | Type     | Required | Description                                                                                                    |
| ------------ | -------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `items`      | array    | Yes      | 1 to 25,000 people, see [Items](#items)                                                                        |
| `families`   | string[] | No       | What to look up for each item: `profile`, `contact_email`, `contact_phone`. Defaults to `["profile"]`.         |
| `emailType`  | string   | No       | With `contact_email`, the kind of email to find: `professional` (default), `personal` or `any`                 |
| `live`       | boolean  | No       | `true` fetches the profile of a Social profile URL item live. Omitted or `false`, saved data is used.          |
| `name`       | string   | No       | A name for the job, up to 255 characters, shown in the job history and in the app. Defaults to `Sent via API`. |
| `webhookUrl` | string   | No       | Public HTTPS URL that receives one `POST` when the job settles, see [Webhook callback](#webhook-callback)      |

### Items

| Name            | Type   | Required    | Description                                                                          |
| --------------- | ------ | ----------- | ------------------------------------------------------------------------------------ |
| `linkedinUrl`   | string | Conditional | Social profile URL of the person                                                     |
| `email`         | string | Conditional | Email address of the person                                                          |
| `firstName`     | string | Conditional | First name                                                                           |
| `lastName`      | string | Conditional | Last name                                                                            |
| `companyDomain` | string | No          | Domain of the person's company, such as `acme.com`                                   |
| `companyName`   | string | No          | Name of the person's company, when you have no domain                                |
| `externalId`    | string | No          | Your own id for the item, up to 255 characters, given back in its row of the results |

Give each item a Social profile URL, an email, or a first and last name with its company: these find the right person. An item that holds only a company is still looked up, rarely matches anyone, and costs nothing when no profile is found. An item with no field at all reads `skipped` in the results, and an item whose only field is an invalid profile URL reads `invalid_input`: neither costs anything. With only `contact_email` or `contact_phone`, an item the finders cannot reach, such as an email alone for `contact_email`, reads `skipped` too. A request in which no item can be looked up returns `422 BULK_JOB_CSV_PARSE_ERROR` and nothing is created.

### Unknown fields

A field that the body or an item does not define is refused with `422 UNKNOWN_FIELD`, never ignored: the job would otherwise run without it, for example a profile-only job for a misspelled `families`. `error.details` names the field, such as `items[3].linkedin_url`, the field it most likely stands for, and the accepted fields. Nothing is created and nothing is charged.

### Idempotency-Key header

Creating a job reserves its credits. If the response gets lost (a timeout, a dropped connection), sending the request again would create and charge a second job. To retry safely, send an `Idempotency-Key` header with a key you choose for this job, such as a UUID, and send the same key when you retry.

For 24 hours, the same key with the same body returns the first response with the header `Idempotent-Replayed: true`, without creating another job or charging anything. The same key with another body returns `422 IDEMPOTENCY_KEY_REUSED`, and a key whose first request is still being processed returns `409 IDEMPOTENCY_KEY_IN_USE`: retry after the `Retry-After` seconds. A key that is empty, longer than 255 characters, or holds a space or a non-ASCII character returns `400 INVALID_IDEMPOTENCY_KEY`.

## Response structure

The request answers `202` at once, with a `Retry-After: 2` header.

| Field     | Type    | Description                   |
| --------- | ------- | ----------------------------- |
| `success` | boolean | `true` if the job was created |
| `error`   | `null`  | Always `null` on success      |

**data** (BulkEnrichAck): The job created

| Field              | Type   | Description                                                                                                                                  |
| ------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `job`              | object | The job, with the fields of [Get a job](https://docs.reversecontact.com/endpoints/job#response-structure). **Save its `id`**: it reads, downloads and cancels the job. |
| `duplicateCount`   | number | Items that name the same person as an earlier item: looked up once, free                                                                     |
| `creditsEstimated` | number | Credits reserved for the job: the most its items can cost                                                                                    |
| `notRunForCredits` | number | Items not run because your balance did not cover them                                                                                        |

`quotas` and `metadata` are the blocks of every V2 response: see [Usage](https://docs.reversecontact.com/endpoints/usage#response-structure). `quotas.creditsConsumed` is `0`, and `quotas.workspace.credits` is read before the job reserves its credits: `creditsEstimated` is what the job took, and [Usage](https://docs.reversecontact.com/endpoints/usage) gives the balance after it.

Then read the job at `job.links.self` while its `status` is `queued` or `running`: see [Get a job](https://docs.reversecontact.com/endpoints/job).

## Webhook callback

With `webhookUrl`, that URL receives one `POST` when the job settles: it completed, was cancelled, or failed. It receives nothing while the job runs. The event says which job settled and carries no results: download them at `resultsUrl` with your API key.

This event is not the lookup callback of the [Webhooks guide](https://docs.reversecontact.com/guides/webhooks): it has its own payload, below, and is sent to `webhookUrl` only, never to the workspace webhook URL of **Settings > Webhooks**.

| Field        | Type           | Description                                      |
| ------------ | -------------- | ------------------------------------------------ |
| `event`      | string         | Always `job.updated`                             |
| `jobId`      | string         | The job that settled                             |
| `type`       | string         | Always `enrich_persons`                          |
| `status`     | string         | `completed`, `cancelled` or `failed`             |
| `pollUrl`    | string         | Path that reads the job                          |
| `resultsUrl` | string \| null | Path of the results file, `null` for a failed job |
| `occurredAt` | string         | When the event was sent (ISO 8601)               |

The request is JSON and its `x-reversecontact-event` header names the event, `job.updated`. Answer with a `2xx` within 10 seconds. A timeout, a network error, a `408`, a `429` or a `5xx` is tried again after 2 then 5 seconds. If these three tries fail, the delivery is tried again 1, 2, 4, 8, 16 and 32 minutes later, then twice an hour apart: about 3 hours in all. A redirect is never followed, and a redirect or another `4xx` is not tried again. A retried delivery can reach you after your endpoint already handled it, so the same event can arrive twice: key it on `jobId` and `status`. If the event has not arrived, read the job at `pollUrl`.

The URL must be public HTTPS: a private, local or unresolvable host returns `400 WEBHOOK_URL_UNREACHABLE` and nothing is created. It is checked again before each delivery.

## Errors

| Status | Code                            | Meaning                                                                                |
| ------ | ------------------------------- | -------------------------------------------------------------------------------------- |
| `400`  | `WEBHOOK_URL_UNREACHABLE`       | `webhookUrl` is not a public HTTPS URL                                                 |
| `402`  | `BULK_JOB_INSUFFICIENT_CREDITS` | Your balance does not cover a single item                                              |
| `403`  | `INSUFFICIENT_PERMISSIONS`      | The key lacks the permission of a lookup the job runs                                  |
| `403`  | `BULK_JOB_PHONE_FINDER_DISABLED` | `contact_phone` is asked on a workspace where phone finding is not enabled            |
| `403`  | `KEY_CREDIT_LIMIT_REACHED`      | The job would take the key over its total credit limit                                 |
| `413`  | `REQUEST_BODY_TOO_LARGE`        | The JSON body is over 25 MB                                                            |
| `422`  | `UNKNOWN_FIELD`                 | The body or an item holds a field the job does not define                              |
| `422`  | `VALIDATION_ERROR`              | A required field is missing, a value is not accepted, or more than 25,000 items        |
| `422`  | `BULK_JOB_CSV_PARSE_ERROR`      | No item has a Social profile URL, an email or a name the selected families can look up |
| `429`  | `KEY_CREDIT_LIMIT_REACHED`      | The job would take the key over its daily or monthly credit limit                      |
| `503`  | `BULK_QUEUE_UNAVAILABLE`        | The job queue is busy: retry shortly, with the same `Idempotency-Key`                  |

The `Idempotency-Key` errors are listed in [Idempotency-Key header](#idempotency-key-header).

## Code examples

**Code Examples**

```bash [cURL]
curl -X POST https://api.reversecontact.com/v2/enrich/persons/bulk \
  -H "Authorization: Bearer YOUR_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"],
    "webhookUrl": "https://example.com/hooks/reversecontact"
  }'
```
```javascript [JavaScript]
const KEY = "YOUR_API_KEY";
const BASE = "https://api.reversecontact.com";
const headers = { "Authorization": `Bearer ${KEY}` };

// Start the job, then follow it until it settles.
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();

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

if (job.links.results) {
  const csv = await (await fetch(`${BASE}${job.links.results}`, { headers })).text();
  console.log(job.status, `${job.credits.used} credits`, csv.split("\n").length - 1, "lines");
}
```
```python [Python]
import uuid
import requests

KEY = "YOUR_API_KEY"
BASE = "https://api.reversecontact.com"
headers = {"Authorization": f"Bearer {KEY}"}

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"]

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

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")
```

## Example response

**Example Response**

```json [202 - Created]
{
  "success": true,
  "data": {
    "job": {
      "id": "7b1e4c2a-5d3f-4e8b-9a6c-1f2d3e4b5a60",
      "type": "enrich_persons",
      "entity": "persons",
      "status": "queued",
      "source": "api",
      "name": "CRM export, November",
      "listId": null,
      "progress": {
        "total": 2,
        "processed": 0,
        "succeeded": 0,
        "failed": 0,
        "skipped": 0,
        "remaining": 2
      },
      "credits": { "estimated": 9, "used": null },
      "estimatedSecondsRemaining": null,
      "error": null,
      "createdAt": "2026-11-04T09:30:00.000Z",
      "startedAt": null,
      "completedAt": null,
      "links": {
        "self": "/v2/jobs/7b1e4c2a-5d3f-4e8b-9a6c-1f2d3e4b5a60",
        "results": null,
        "operation": null
      }
    },
    "duplicateCount": 0,
    "creditsEstimated": 9,
    "notRunForCredits": 0
  },
  "error": null,
  "quotas": {
    "creditsConsumed": 0,
    "workspace": {
      "id": "ws_abc123",
      "credits": { "total": 10000, "used": 4522, "left": 5478 }
    }
  },
  "metadata": {
    "requestId": "91f305d7-392b-4af2-9663-b5dd6e943ab8",
    "executionTimeMs": 120
  }
}
```
```json [Webhook event]
{
  "event": "job.updated",
  "jobId": "7b1e4c2a-5d3f-4e8b-9a6c-1f2d3e4b5a60",
  "type": "enrich_persons",
  "status": "completed",
  "pollUrl": "/v2/jobs/7b1e4c2a-5d3f-4e8b-9a6c-1f2d3e4b5a60",
  "resultsUrl": "/v2/jobs/7b1e4c2a-5d3f-4e8b-9a6c-1f2d3e4b5a60/results",
  "occurredAt": "2026-11-04T09:31:12.000Z"
}
```
