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

# Import members and data

> Import up to 25,000 Signals or CSV rows into a List, enrich members within a credit budget, and track progress by polling or webhook.

- Endpoint: `POST https://api.reversecontact.com/v2/lists/:listId/intake/signals`
- 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.
- Answer: asynchronous. The request returns an operation (`data.operationId`, `data.pollUrl`): read `GET {pollUrl}?wait=20` until `clientAction` is no longer `poll`, or receive its state on the `webhookUrl` of the import. Reads are free and never rate limited.
- Available in: REST API, MCP tool `import_to_list`, TypeScript and Python SDKs.
- Last verified: 2026-11-04

## 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](https://docs.reversecontact.com/endpoints/list-members-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](https://docs.reversecontact.com/endpoints/list-members-add) 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](https://docs.reversecontact.com/endpoints/list-operation) and stop it with [Cancel a List operation](https://docs.reversecontact.com/endpoints/list-operation-cancel).

## 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](https://docs.reversecontact.com/guides/lists#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](https://docs.reversecontact.com/endpoints/contact-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](https://docs.reversecontact.com/guides/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](https://docs.reversecontact.com/guides/rate-limits#how-requests-are-counted).

## 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](https://docs.reversecontact.com/endpoints/list-members-add#request-body), `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](#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:

- **`maxCredits` reached** (`stopReason: "max_credits"`): send the same request again, same `operationId`, with a higher `maxCredits`. `nextStep.minimumMaxCredits` gives the smallest ceiling that lets it continue.
- **Balance too low** (`stopReason: "balance"`): add credits, then send the same request again with the same `operationId`.
- **Paused from the app** (`stopReason: "paused_by_user"`): send the same request again with the same `operationId`.

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](https://docs.reversecontact.com/endpoints/list-operation#response-structure) 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                         |

**data** (OperationAck): The operation queued

| 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](https://docs.reversecontact.com/endpoints/usage#response-structure). `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](https://docs.reversecontact.com/endpoints/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](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 `list.operation.updated`                                        |
| `operationId`  | string         | The operation that changed                                             |
| `listId`       | string         | Its List                                                               |
| `status`       | string         | Status of the operation, as [Read a List operation](https://docs.reversecontact.com/endpoints/list-operation#response-structure) 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**

```bash [cURL]
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"
  }'
```
```javascript [JavaScript]
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 ?? "");
```
```python [Python]
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**

```json [202 - Queued]
{
  "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": "b1d8124d-e074-4d7b-9a14-fba9bfe74625",
    "executionTimeMs": 88
  }
}
```
```json [Webhook event]
{
  "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"
}
```
