Endpoints // Bulk
Enrich up to 25,000 people in one request with profiles, emails or phone numbers, priced like the unit calls and refunded for what is not found, then read the results as they come or download them as CSV or NDJSON.
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.
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, 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, or download one row per item, as CSV or NDJSON, with Download 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 instead. To look up one person while a user waits, call Enrich profile or Find 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 |
| Profile, from an email or a name | 2, as Enrich profile |
contact_email | 3 for a professional email, 5 for a personal one, 8 for both with emailType: "any", as Find Email |
contact_phone | 20 per phone number found, as Find 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, 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 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. 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 |
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 |
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 |
dataobjectThe job created
Show child attributesHide child attributes
| Field | Type | Description |
|---|---|---|
job | object | The job, with the fields of Get a job. 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. quotas.creditsConsumed is 0, and quotas.workspace.credits is read before the job reserves its credits: creditsEstimated is what the job took, and 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.
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: 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.
Code examples
Code Examples
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"
}'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");
}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
{
"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": "req_7f3c9a2e1b4d",
"executionTimeMs": 120
}
}{
"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"
}