Endpoints // Lists
Queue up to 25,000 Signals or a public CSV into a List, buy profile, company, email and phone data for each member under a credit ceiling, and follow the import by polling or webhook.
Credits
Free to queue.
Each new member costs its identity price, and each data family you ask for costs what its endpoint costs, up to your maxCredits.
Introduction
The Import members and data endpoint queues up to 25,000 Signals, or a public CSV file or Google Sheet, as one operation on a List. It answers at once with an operationId and a pollUrl, then works in the background: each line is identified like an add, and with families, the data you ask for is bought for each member, new or already on the List.
Use it instead of Add members for more than 1,000 Signals, to fill a List with emails, phone numbers, profiles or company details, to set a credit ceiling, or to be told by webhook when the import stops moving. Follow it with Read a List operation and stop it with Cancel a List operation.
Credits
Queuing the import is free: the operation is charged line by line as it runs, and never more than maxCredits.
| What the line does | Credits |
|---|---|
| Adds a new person | 1 from a Social profile URL (2 with live: true), 2 from an email or a name |
| Adds a new company | 1 from a company page URL or id (2 when the page is read live), 2 from a domain |
| Names a member already on the List, or no one | Free |
profile family | Per member that lacks it, 1 when read from its Social profile URL, 2 when found by name (2 in the worst case). A person the import adds comes with their profile, free. |
company family | Per member, 1 from a company page URL, 2 from a domain (2 in the worst case). On a people List, it is the person's current company. On a companies List, a company the import adds comes with its details, free. |
contact_email family | 3 per email found, free when none is found. The email of an email Signal is kept as the member's email, free. |
contact_phone family | 20 per phone number found, free when none is found |
Data a member already holds is never bought again, and an email or a phone number already looked for, found or not, is not looked for again: an import replayed on the same List charges only what is missing. A line no one matches is kept as Pending Intake, free. When a Signal sent again finds someone, the new member replaces its Pending Intake line.
The contact_phone family needs phone finding on your workspace, as Find Phone does. Without it, the member of each line that asks for a phone number is still added, the line reads resolved with errorCode: "CONTACT_PHONE_FINDER_DISABLED", and no phone number is charged.
The API key needs the lists permission, plus the permission of each thing the import buys: persons:enrich for people and their profiles, companies:enrich for companies and company details, contact for the email and phone families (see API key management).
The request that starts an import counts as one request on your daily limit and is not counted against your per-minute limit, whatever its number of lines. Its lines then take their share of your per-minute limit as they run, at most 80% of it, and wait when that share is taken. Reading the operation is never counted. See Rate limits.
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 List is named in the path: POST /v2/lists/:listId/intake/signals. Send signals or sourceUrl, not both.
| Name | Type | Required | Description |
|---|---|---|---|
signals | array | Conditional | 1 to 25,000 Signals, with the fields of Add members, externalId included |
sourceUrl | string | Conditional | A public HTTPS CSV file or Google Sheet URL instead of signals: up to 25,000 rows and 25 MiB. Columns are matched from their headers (Social URL, email, first name, last name, company, domain). |
families | string | No | Data to buy for each member: profile, company, contact_email, contact_phone. A companies List takes company only. |
maxCredits | integer | Conditional | The most this operation may spend. Required with families when the worst case is above 25 credits. Omitted, the ceiling is the worst case of the import. |
live | boolean | No | true fetches the profile of each Social profile URL live. Omitted or false, saved data is used. No effect on a companies List. |
operationId | string | No | Your UUID for this operation. Sending the same request again with it returns the same operation and never charges twice. |
webhookUrl | string | No | Public HTTPS URL told each time the operation stops moving, see Webhook callback |
source | string | No | paste (default) or file: how a Pending Intake line says it arrived |
The credit ceiling
When families makes the worst case of the import (every line a new member, every family bought, every email and phone found) higher than 25 credits, the request must carry maxCredits: without it, it returns 400 LIST_INVALID_INPUT and nothing is queued. maxCredits is your consent to spend up to that amount, and the operation stops before a step that would cross it. An import without families needs no ceiling: it costs at most the identity of its lines. An import sent without maxCredits gets its worst case as ceiling, so it may spend up to that amount.
Deduplication and replays
Lines naming the same identity with the same externalId are deduplicated: duplicateCount gives how many were dropped. Two lines naming the same person under two externalId values stay two lines, and the second one reads already_present.
With operationId, sending the same request again returns the operation it created, whatever its state, and charges nothing twice. The same operationId with other Signals, families, live or source returns 400 LIST_INVALID_INPUT. A cancelled operation stays cancelled: a new run needs a new operationId.
Pauses and how to resume
The operation pauses instead of failing when it cannot go on, and keeps the work done:
maxCreditsreached (stopReason: "max_credits"): send the same request again, sameoperationId, with a highermaxCredits.nextStep.minimumMaxCreditsgives the smallest ceiling that lets it continue.- Balance too low (
stopReason: "balance"): add credits, then send the same request again with the sameoperationId. - Paused from the app (
stopReason: "paused_by_user"): send the same request again with the sameoperationId.
A paused operation holds the imports queued after it on the same List (stopReason: "blocked_by_paused_operation", with nextStep.blockingOperationId): resume or cancel it to let them start. An operation also stops for good when the List reaches its member or Pending Intake limit (list_member_limit, list_pending_intake_limit); the lines it did not reach are not charged.
Lines that name no one, or the wrong kind
A line never refuses the whole import. A line naming a company on a people List, or a person on a companies List, reads refused with errorCode: "ENTITY_MISMATCH". A Signal that identifies no one reads failed with errorCode: "LIST_INVALID_INPUT", and a row of a sourceUrl file with no usable column reads refused with errorCode: "NO_INPUT". These lines are free, and Read a List operation gives them line by line.
Response structure
The request answers 202 at once, with a Retry-After: 5 header. It runs within a 15-second time limit, which a slow sourceUrl download can cross: past it, it returns 504, and the import may have been queued all the same. Always send an operationId for this reason: the same request sent again with it returns the operation if it exists, or queues it, and never charges twice.
| Field | Type | Description |
|---|---|---|
success | boolean | true if the operation was queued |
error | null | Always null on success |
dataobjectThe operation queued
Show child attributesHide child attributes
| Field | Type | Description |
|---|---|---|
operationId | string | Operation id (UUID). Save it: it reads, resumes and cancels the operation. |
pollUrl | string | Path that reads the operation: GET it, with ?wait=20 to hold the request until it settles |
totalRows | number | Lines queued after deduplication |
duplicateCount | number | Lines dropped as duplicates of an earlier line |
sourceRows | number | With sourceUrl only: the non-empty data rows read from the file |
mapping | object | With sourceUrl only: the header each Signal field was read from, for example { "linkedinUrl": "Profile", "email": "Work email" } |
job | object | The internal record behind the operation, left out of the example below. Its fields may change: read the operation at pollUrl instead. |
quotas and metadata are the blocks of every V2 response: see Usage. quotas.creditsConsumed is 0: the credits of the operation appear in its creditsConsumed as it runs.
Then read the operation at its pollUrl until clientAction is done: see Read a List operation.
Webhook callback
With webhookUrl, that URL receives one POST each time the operation stops moving: it completed, paused at a ceiling or a low balance, was cancelled, or its outcome became unknown. It receives nothing while the operation runs. The event says which operation changed and carries no member data: read the operation, and its per-line results, at pollUrl 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 list.operation.updated |
operationId | string | The operation that changed |
listId | string | Its List |
status | string | Status of the operation, as Read a List operation gives it |
clientAction | string | done, resume or reconcile |
stopReason | string | null | Why it stopped, null once completed |
pollUrl | string | Path that reads the operation |
occurredAt | string | When the event was sent (ISO 8601) |
The request is JSON and its x-reversecontact-event header names the event, list.operation.updated. Answer with a 2xx within 10 seconds. A timeout, a 408, a 429 or a 5xx is tried again twice, after 2 then 5 seconds; a redirect is never followed. Each state is sent once, so a receiver that missed it can always read the operation at pollUrl.
The URL must be public HTTPS: a private, local or unresolvable host returns 400 WEBHOOK_URL_UNREACHABLE and nothing is queued. It is checked again before each delivery.
Errors
| Status | Code | Meaning |
|---|---|---|
400 | LIST_INVALID_INPUT | Neither or both of signals and sourceUrl, maxCredits missing above 25 credits, a family the List cannot hold, or an operationId sent again with another request |
400 | WEBHOOK_URL_UNREACHABLE | webhookUrl is not a public HTTPS URL |
403 | INSUFFICIENT_PERMISSIONS | The key lacks lists, or the permission of what the import buys |
404 | LIST_NOT_FOUND | No List with this id in your workspace |
404 | LISTS_NOT_ACTIVATED | The workspace has never had a List: create one first |
422 | BULK_JOB_CSV_PARSE_ERROR | The sourceUrl file could not be read: not a public HTTPS URL, no header row, no recognized column, too many rows, or over 25 MiB |
502 | LIST_JOB_ENGINE_ERROR | The List service is briefly unavailable: send the same request again, with the same operationId |
503 | BULK_QUEUE_UNAVAILABLE | The import queue is busy: retry shortly |
504 | GATEWAY_TIMEOUT | The request ran out of time after 15 seconds: send it again with the same operationId |
Code examples
Code Examples
curl -X POST https://api.reversecontact.com/v2/lists/3f6c2a8e-1b4d-4f0a-9c7e-5d2b8a1e6f40/intake/signals \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"operationId": "c4d5e6f7-8a9b-4c0d-9e1f-2a3b4c5d6e7f",
"signals": [
{ "linkedinUrl": "https://www.social.com/in/janedoe", "externalId": "crm-001" },
{ "email": "john.smith@acme.com", "externalId": "crm-002" }
],
"families": ["contact_email"],
"maxCredits": 100,
"webhookUrl": "https://example.com/hooks/reversecontact"
}'const KEY = "YOUR_API_KEY";
const BASE = "https://api.reversecontact.com";
const listId = "3f6c2a8e-1b4d-4f0a-9c7e-5d2b8a1e6f40";
// Queue the import, then follow it until it no longer moves.
const queued = await fetch(`${BASE}/v2/lists/${listId}/intake/signals`, {
method: "POST",
headers: { "Authorization": `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
operationId: crypto.randomUUID(),
signals: [
{ linkedinUrl: "https://www.social.com/in/janedoe", externalId: "crm-001" },
{ email: "john.smith@acme.com", externalId: "crm-002" }
],
families: ["contact_email"],
maxCredits: 100
})
});
const { data: ack } = await queued.json();
let operation;
do {
const response = await fetch(`${BASE}${ack.pollUrl}?wait=20`, {
headers: { "Authorization": `Bearer ${KEY}` }
});
operation = (await response.json()).data;
} while (operation.clientAction === "poll");
console.log(operation.status, `${operation.creditsConsumed} credits`, operation.nextStep?.message ?? "");import uuid
import requests
KEY = "YOUR_API_KEY"
BASE = "https://api.reversecontact.com"
list_id = "3f6c2a8e-1b4d-4f0a-9c7e-5d2b8a1e6f40"
headers = {"Authorization": f"Bearer {KEY}"}
ack = requests.post(
f"{BASE}/v2/lists/{list_id}/intake/signals",
headers=headers,
json={
"operationId": str(uuid.uuid4()),
"signals": [
{"linkedinUrl": "https://www.social.com/in/janedoe", "externalId": "crm-001"},
{"email": "john.smith@acme.com", "externalId": "crm-002"}
],
"families": ["contact_email"],
"maxCredits": 100
}
).json()["data"]
while True:
operation = requests.get(f"{BASE}{ack['pollUrl']}", headers=headers, params={"wait": 20}, timeout=30).json()["data"]
if operation["clientAction"] != "poll":
break
print(operation["status"], operation["creditsConsumed"], "credits")Example response
Example Response
{
"success": true,
"data": {
"operationId": "c4d5e6f7-8a9b-4c0d-9e1f-2a3b4c5d6e7f",
"pollUrl": "/v2/lists/3f6c2a8e-1b4d-4f0a-9c7e-5d2b8a1e6f40/operations/c4d5e6f7-8a9b-4c0d-9e1f-2a3b4c5d6e7f/progress",
"totalRows": 2,
"duplicateCount": 0
},
"error": null,
"quotas": {
"creditsConsumed": 0,
"workspace": {
"id": "ws_abc123",
"credits": { "total": 10000, "used": 4522, "left": 5478 }
}
},
"metadata": {
"requestId": "req_i1n2t3a4k5e6",
"executionTimeMs": 88
}
}{
"event": "list.operation.updated",
"operationId": "c4d5e6f7-8a9b-4c0d-9e1f-2a3b4c5d6e7f",
"listId": "3f6c2a8e-1b4d-4f0a-9c7e-5d2b8a1e6f40",
"status": "completed",
"clientAction": "done",
"stopReason": null,
"pollUrl": "/v2/lists/3f6c2a8e-1b4d-4f0a-9c7e-5d2b8a1e6f40/operations/c4d5e6f7-8a9b-4c0d-9e1f-2a3b4c5d6e7f/progress",
"occurredAt": "2026-10-28T09:21:40.000Z"
}