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

# Build your first List

> Turn CRM contacts into a List, buy their work emails under a credit cap, follow the import and read the result, with cURL, JavaScript, Python and the SDKs.

- Last verified: 2026-11-04

This guide turns three contacts of a CRM into a [List](https://docs.reversecontact.com/guides/lists), finds their work emails, and reads them back. It takes five calls, all free: you pay only for the members the List adds and the emails it finds, 11 credits at most here.

## Before you start

- An API key in `RC_API_KEY`, from [API Keys](https://app.reversecontact.com/api-keys). A key with full access works. A scoped key needs `lists`, plus Person Enrichment for the people the List adds and Contact for the emails it buys (see [API key management](https://docs.reversecontact.com/guides/api-key-management)).
- A few credits. A free trial holds one List of up to 50 members, enough for this guide.

## The steps

### 1. Create the List

**POST /v2/lists**

```bash [cURL]
curl -X POST https://api.reversecontact.com/v2/lists \
  -H "Authorization: Bearer $RC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: q4-sales-leaders" \
  -d '{"name": "Q4 sales leaders", "entity": "persons"}'
```

The response gives the List in `data`: keep `data.id`, the List id the next calls name in their path (`$LIST_ID` in the commands below). The `Idempotency-Key` makes the call safe to retry: sent again, it returns the same List instead of a second one.

### 2. Import the people and buy their emails

**POST /v2/lists/:listId/intake/signals**

```bash [cURL]
curl -X POST https://api.reversecontact.com/v2/lists/$LIST_ID/intake/signals \
  -H "Authorization: Bearer $RC_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" },
      { "firstName": "Alex", "lastName": "Martin", "companyDomain": "acme.com", "externalId": "crm-003" }
    ],
    "families": ["contact_email"],
    "maxCredits": 50
  }'
```

Each Signal says what you know about one person, and `externalId` carries the id of the line in your CRM. `families` asks for a work email for each member that has none yet. The import answers at once with `data.operationId` and `data.pollUrl`, and runs in the background.

The worst case of this import is 14 credits: 1 for Jane Doe found from her Social profile URL, 2 each for John Smith found from his email and Alex Martin found from his name, and 3 per email found. John Smith's email is the one you sent: it becomes his work email for free and is not looked up, so this import charges at most 11 credits. A line no one matches and an email not found cost nothing. Up to 25 credits of worst case, `maxCredits` is optional: without it, the import may spend up to its worst case. Send it anyway to set your own cap.

`operationId` is a UUID you choose. Keep it: sending the same import again with it never charges twice, and it is how you resume an import that paused.

### 3. Follow the import

**GET /v2/lists/:listId/operations/:operationId/progress**

```bash [cURL]
curl "https://api.reversecontact.com/v2/lists/$LIST_ID/operations/c4d5e6f7-8a9b-4c0d-9e1f-2a3b4c5d6e7f/progress?wait=20" \
  -H "Authorization: Bearer $RC_API_KEY"
```

This route is the `pollUrl` the import answered with, built from the List id and your `operationId`. With `wait=20`, each read waits up to 20 seconds for the import to complete, pause or stop, and otherwise answers with the import still running. Read it again while `clientAction` is `poll`. Then `done` with `status: "completed"` means the members and their emails are in the List, and `resume` means it paused: `nextStep` says what to do. These reads are free and never counted against your rate limits. Instead of reading, you can pass a public HTTPS `webhookUrl` with the import: it is told each time the operation stops moving.

### 4. Read the members and their emails

**GET /v2/lists/:listId/members**

```bash [cURL]
curl "https://api.reversecontact.com/v2/lists/$LIST_ID/members?kind=members" \
  -H "Authorization: Bearer $RC_API_KEY"

curl "https://api.reversecontact.com/v2/lists/$LIST_ID/members?externalId=crm-002" \
  -H "Authorization: Bearer $RC_API_KEY"
```

Each member carries its `fields` (name, headline, company, location), your `externalId`, and `families.contactEmail` with the email found, or for John Smith the email you sent (`source: "signal:paste"`). The second call finds the member of a CRM line by its `externalId`, without storing any id of ours.

### 5. Or download the List as CSV

**GET /v2/lists/:listId/export**

```bash [cURL]
curl -o q4-sales-leaders.csv "https://api.reversecontact.com/v2/lists/$LIST_ID/export" \
  -H "Authorization: Bearer $RC_API_KEY"
```

The file has one row per line of the List, members and Pending Intake, in the order they were added, with the data the List holds. Exporting is free and never looks anyone up again. A workspace exports one List at a time: run your exports one after the other, a second one at the same time returns `429 LIST_EXPORT_IN_PROGRESS`.

## The complete pipeline

The same five steps in one script, with plain HTTP calls or with the [TypeScript](https://docs.reversecontact.com/sdk/javascript) and [Python](https://docs.reversecontact.com/sdk/python) SDKs.

**Create, import, follow, read**

```javascript [JavaScript]
const BASE = "https://api.reversecontact.com";
const headers = {
  "Authorization": `Bearer ${process.env.RC_API_KEY}`,
  "Content-Type": "application/json"
};

// 1. Create the List: the Idempotency-Key makes a retry safe
const list = await fetch(`${BASE}/v2/lists`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": "q4-sales-leaders" },
  body: JSON.stringify({ name: "Q4 sales leaders", entity: "persons" })
}).then((res) => res.json());
const listId = list.data.id;

// 2. Import the people and buy their emails, 50 credits at most
const queued = await fetch(`${BASE}/v2/lists/${listId}/intake/signals`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    operationId: crypto.randomUUID(), // keep it to resume the import
    signals: [
      { linkedinUrl: "https://www.social.com/in/janedoe", externalId: "crm-001" },
      { email: "john.smith@acme.com", externalId: "crm-002" },
      { firstName: "Alex", lastName: "Martin", companyDomain: "acme.com", externalId: "crm-003" }
    ],
    families: ["contact_email"],
    maxCredits: 50
  })
}).then((res) => res.json());

// 3. Follow the import until it stops moving
let operation;
do {
  const res = await fetch(`${BASE}${queued.data.pollUrl}?wait=20`, { headers });
  operation = (await res.json()).data;
} while (operation.clientAction === "poll");
if (operation.status !== "completed") throw new Error(operation.nextStep?.message ?? `Import ${operation.status}`);

// 4. Read the members and their emails
const members = await fetch(`${BASE}/v2/lists/${listId}/members?kind=members`, { headers }).then((res) => res.json());
for (const member of members.data.items) {
  console.log(member.externalId, member.fields.name, member.families.contactEmail?.value ?? "no email");
}
```
```python [Python]
import os
import uuid
import requests

BASE = "https://api.reversecontact.com"
headers = {
    "Authorization": f"Bearer {os.environ['RC_API_KEY']}",
    "Content-Type": "application/json",
}

# 1. Create the List: the Idempotency-Key makes a retry safe
list_ = requests.post(
    f"{BASE}/v2/lists",
    headers={**headers, "Idempotency-Key": "q4-sales-leaders"},
    json={"name": "Q4 sales leaders", "entity": "persons"},
).json()
list_id = list_["data"]["id"]

# 2. Import the people and buy their emails, 50 credits at most
queued = requests.post(
    f"{BASE}/v2/lists/{list_id}/intake/signals",
    headers=headers,
    json={
        "operationId": str(uuid.uuid4()),  # keep it to resume the import
        "signals": [
            {"linkedinUrl": "https://www.social.com/in/janedoe", "externalId": "crm-001"},
            {"email": "john.smith@acme.com", "externalId": "crm-002"},
            {"firstName": "Alex", "lastName": "Martin", "companyDomain": "acme.com", "externalId": "crm-003"},
        ],
        "families": ["contact_email"],
        "maxCredits": 50,
    },
).json()

# 3. Follow the import until it stops moving
while True:
    operation = requests.get(
        f"{BASE}{queued['data']['pollUrl']}", headers=headers, params={"wait": 20}, timeout=30
    ).json()["data"]
    if operation["clientAction"] != "poll":
        break
if operation["status"] != "completed":
    raise RuntimeError((operation.get("nextStep") or {}).get("message") or f"Import {operation['status']}")

# 4. Read the members and their emails
members = requests.get(f"{BASE}/v2/lists/{list_id}/members", headers=headers, params={"kind": "members"}).json()
for member in members["data"]["items"]:
    email = (member["families"].get("contactEmail") or {}).get("value", "no email")
    print(member.get("externalId"), member["fields"].get("name"), email)
```
```typescript [TypeScript SDK]
import ReverseContact from "@reverse-contact/sdk";

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

// 1. Create the List: the SDK sends an Idempotency-Key and reuses it on its retries
const list = await client.lists.create({ name: "Q4 sales leaders", entity: "persons" });
const listId = list.data.id;

// 2. Import the people and buy their emails, 50 credits at most
const queued = await client.lists.importSignals(listId, {
  signals: [
    { linkedinUrl: "https://www.social.com/in/janedoe", externalId: "crm-001" },
    { email: "john.smith@acme.com", externalId: "crm-002" },
    { firstName: "Alex", lastName: "Martin", companyDomain: "acme.com", externalId: "crm-003" }
  ],
  families: ["contact_email"],
  maxCredits: 50
});

// 3. Follow the import until it stops moving
const operation = await client.lists.operations.wait(listId, queued.data.operationId);
console.log(operation.data.status, operation.data.clientAction);

// 4. Read the members and their emails
for await (const member of client.lists.members.listAll(listId, { kind: "members" })) {
  console.log(member);
}
```
```python [Python SDK]
import os

from reversecontact import ReverseContact

with ReverseContact(api_key=os.environ["RC_API_KEY"]) as client:
    # 1. Create the List: the SDK sends an Idempotency-Key and reuses it on its retries
    list_id = client.lists.create(name="Q4 sales leaders", entity="persons").data.id

    # 2. Import the people and buy their emails, 50 credits at most
    queued = client.lists.import_signals(
        list_id,
        signals=[
            {"linkedinUrl": "https://www.social.com/in/janedoe", "externalId": "crm-001"},
            {"email": "john.smith@acme.com", "externalId": "crm-002"},
            {"firstName": "Alex", "lastName": "Martin", "companyDomain": "acme.com", "externalId": "crm-003"},
        ],
        families=["contact_email"],
        max_credits=50,
    )

    # 3. Follow the import until it stops moving
    operation = client.lists.operations.wait(list_id, queued.data.operation_id)
    print(operation.data.status, operation.data.client_action)

    # 4. Read the members and their emails
    for member in client.lists.members.list_all(list_id, kind="members"):
        print(member)
```

A List holds more than one page of members: send `nextCursor` back as `cursor` to read the next one (see [List members](https://docs.reversecontact.com/endpoints/list-members)). The SDK iterators read every page for you.

## Import a CSV file instead

Send the public URL of a CSV file or a Google Sheet in `sourceUrl`, in place 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), and each row becomes a Signal.

**Import a CSV file**

```bash [cURL]
curl -X POST https://api.reversecontact.com/v2/lists/$LIST_ID/intake/signals \
  -H "Authorization: Bearer $RC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "operationId": "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b",
    "sourceUrl": "https://example.com/exports/q4-leads.csv",
    "families": ["contact_email"],
    "maxCredits": 2000
  }'
```

The answer adds `sourceRows`, the data rows read from the file, and `mapping`, the header each field was read from: check it before you buy anything more. A file that is not public can be sent as `signals` instead, one object per row, with the id of the row in `externalId`.

## What to do next

- Add the new leads of next week to the same List: a person already on it costs nothing (see [Grow a List and buy data over time](https://docs.reversecontact.com/guides/lists#grow-a-list-and-buy-data-over-time)).
- Buy phone numbers or full profiles later, for the members that lack them, with the same import and other `families`.
- See what was not found in [Pending Intake](https://docs.reversecontact.com/guides/lists#pending-intake), and how an import pauses and resumes in [Control spend](https://docs.reversecontact.com/guides/lists#control-spend-maxcredits-pauses-and-resume).
- Hand the work to an AI agent: the [MCP server](https://docs.reversecontact.com/guides/mcp) runs the same steps on the same Lists.

## FAQ

### How much does it cost to build a List of CRM contacts with their work emails?

Creating, following, reading and exporting a List are free. Each new person costs 1 credit from a Social profile URL and 2 from an email or a name, and each work email found costs 3 credits. The email of a contact sent by email becomes its work email for free. For the three contacts of this guide, the import costs at most 11 credits, and a contact no one matches or an email not found costs nothing.

### Can I send the same List import twice without paying twice?

Yes. Send the import to `POST /v2/lists/:listId/intake/signals` with the same `operationId`: the API returns the operation it already created and never charges anything twice. The same `operationId` also resumes an import that paused at its `maxCredits` or at a low balance.

### How do I find the member of a CRM contact in a List?

Send the id of the CRM contact as `externalId` on its Signal. The member keeps it, and `GET /v2/lists/:listId/members?externalId=crm-001` returns that member, so you never have to store a Reverse Contact id.

### How do I know when a List import is finished?

Read `GET /v2/lists/:listId/operations/:operationId/progress`, the `pollUrl` of the import, with `wait=20` until `clientAction` is no longer `poll`: `done` means it ended, `resume` means it paused and `nextStep` says what to do. These reads are free and not counted against your rate limits. You can also pass a public HTTPS `webhookUrl` with the import, which is told each time the operation stops moving.

### Can I build a List with the Reverse Contact SDK?

Yes. From version 0.2.0, the TypeScript SDK `@reverse-contact/sdk` and the Python SDK `reversecontact` manage Lists with `client.lists`: `create`, `importSignals` (`import_signals` in Python), `operations.wait` to follow an import, and `members.listAll` (`members.list_all`) to read every member.
