# Lists & contacts

> Create contact lists, add and update contacts one by one or 100 at a time, declare custom variables, look a contact up, export lists, and feed running campaigns.

Source: https://docs.emelia.io/docs/lists

## The model

Contacts live in **lists**. A campaign reads one or several lists, its recipients.

* Add a contact to a list used by a campaign: the contact enters the campaign, usually within seconds.
* Update a contact: the new values reach the campaigns that use its list.
* Delete a contact from the list: it leaves those campaigns.

This is how you keep a campaign alive: you add to the list, never to the campaign. A list attached to two campaigns feeds both, so a contact would receive both sequences. Lists, contacts and exports never cost credits.

> **A separate service.** The lists and contacts routes are served under `https://api.emelia.io/lists`, with the same key. They are not counted in the per minute quota, their errors carry a `message` field instead of `error`, and they use real `404`, `413` and `422` statuses. See [Errors](https://docs.emelia.io/docs/errors.md).

The Python examples of this guide share one session:

```python title="Python setup"
import os
import requests

session = requests.Session()
session.headers["Authorization"] = os.environ["EMELIA_API_KEY"]
list_id = "66f1c0ffee5a7d3b2c4e8a10"
```

## Create a list

[`POST /lists/list`](https://docs.emelia.io/docs/lists/create-list.md)

**curl**

```bash
curl -X POST https://api.emelia.io/lists/list \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Q4 SaaS founders"}'
```

**JavaScript**

```js
const res = await fetch('https://api.emelia.io/lists/list', {
  method: 'POST',
  headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Q4 SaaS founders' }),
});
const { listId } = await res.json(); // 201
```

**Python**

```python
res = session.post("https://api.emelia.io/lists/list", json={"name": "Q4 SaaS founders"})
list_id = res.json()["listId"]  # 201
```

Give each list the name of the campaign it feeds: it saves time later.

## Declare your custom variables first

Any key that is not a standard field becomes a custom variable, shared by all your lists. Declare yours before the first import: an existing variable is matched before Emelia guesses from the value, which avoids most mapping surprises.

[`POST /lists/custom-variable`](https://docs.emelia.io/docs/lists/create-custom-variable.md)

**curl**

```bash
curl -X POST https://api.emelia.io/lists/custom-variable \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"technicalName": "icebreaker", "schema": "lead", "displayName": "Icebreaker"}'
```

**JavaScript**

```js
const res = await fetch('https://api.emelia.io/lists/custom-variable', {
  method: 'POST',
  headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ technicalName: 'icebreaker', schema: 'lead', displayName: 'Icebreaker' }),
});
// 422 means the name already exists: safe to ignore.
```

**Python**

```python
res = session.post(
    "https://api.emelia.io/lists/custom-variable",
    json={"technicalName": "icebreaker", "schema": "lead", "displayName": "Icebreaker"},
)
# 422 means the name already exists: safe to ignore.
```

* `schema` is `lead` for a contact variable, `company` for a company variable. This route is the only way to create a company variable.
* The call answers `422` when the name exists or is a standard field: call it at every import and treat `422` as "already there". [Check a custom variable](https://docs.emelia.io/docs/lists/check-custom-variable.md) tells you in advance.
* Variables cannot be renamed or deleted through the API.

## Add one contact

[`POST /lists/list/{listId}/contacts`](https://docs.emelia.io/docs/lists/add-contact.md)

The body is a **flat object**: one key per field, values that are strings, numbers or booleans.

**curl**

```bash
curl -X POST "https://api.emelia.io/lists/list/66f1c0ffee5a7d3b2c4e8a10/contacts?updateIfExists=true" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Claire",
    "lastName": "Fontaine",
    "email": "claire.fontaine@nexora.io",
    "jobTitle": "CTO",
    "linkedinUrlProfile": "https://www.linkedin.com/in/claire-fontaine",
    "companyName": "Nexora",
    "websiteUrl": "https://nexora.io",
    "icebreaker": "your move to Postgres 16 last month"
  }'
```

**JavaScript**

```js
const listId = '66f1c0ffee5a7d3b2c4e8a10';
const res = await fetch(`https://api.emelia.io/lists/list/${listId}/contacts?updateIfExists=true`, {
  method: 'POST',
  headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    firstName: 'Claire',
    lastName: 'Fontaine',
    email: 'claire.fontaine@nexora.io',
    jobTitle: 'CTO',
    linkedinUrlProfile: 'https://www.linkedin.com/in/claire-fontaine',
    companyName: 'Nexora',
    websiteUrl: 'https://nexora.io',
    icebreaker: 'your move to Postgres 16 last month',
  }),
});
const { leadId, duplicate, updated } = await res.json();
```

**Python**

```python
res = session.post(
    f"https://api.emelia.io/lists/list/{list_id}/contacts",
    params={"updateIfExists": "true"},
    json={
        "firstName": "Claire",
        "lastName": "Fontaine",
        "email": "claire.fontaine@nexora.io",
        "jobTitle": "CTO",
        "linkedinUrlProfile": "https://www.linkedin.com/in/claire-fontaine",
        "companyName": "Nexora",
        "websiteUrl": "https://nexora.io",
        "icebreaker": "your move to Postgres 16 last month",
    },
)
print(res.json())
```

### Duplicates and updates

Before creating, Emelia looks in this list, and in the lists linked to it, for a contact with the same `email`, then with the same `linkedinUrlProfile`. If one is found, nothing is created: the response returns its `leadId` with `duplicate: true`.

With `?updateIfExists=true` (the exact string `true`, as a query parameter), a duplicate is **updated** instead: the fields you send overwrite the stored values, the others stay, and the response says `updated: true`.

| Situation                                    | `duplicate` | `updated` |
| -------------------------------------------- | ----------- | --------- |
| New contact                                  | `false`     | `false`   |
| Already in the list, `updateIfExists` absent | `true`      | `false`   |
| Already in the list, `updateIfExists=true`   | `true`      | `true`    |

The status is `200` in all three cases. Three things to know:

* **The email match is exact and case sensitive.** `Claire@Nexora.io` and `claire@nexora.io` make two contacts. Send emails in lowercase.
* **The LinkedIn URL is compared as a string.** A trailing slash or a missing `www.` makes another URL. Send one canonical form, like `https://www.linkedin.com/in/claire-fontaine`.
* **A contact without email and without LinkedIn URL is never deduplicated:** every retry creates a new one.

## Add up to 100 contacts

[`POST /lists/list/{listId}/contacts/batch`](https://docs.emelia.io/docs/lists/add-contacts-in-bulk.md)

Same objects, wrapped in `contacts`. Each row is processed on its own: a failing row is reported and does not stop the others.

**curl**

```bash
curl -X POST "https://api.emelia.io/lists/list/66f1c0ffee5a7d3b2c4e8a10/contacts/batch?updateIfExists=true" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      { "firstName": "Claire", "email": "claire.fontaine@nexora.io", "companyName": "Nexora" },
      { "firstName": "Hugo", "email": "hugo.martin@brightlane.io", "companyName": "Brightlane" }
    ]
  }'
```

**JavaScript**

```js
// Load a large array 100 rows at a time.
async function loadContacts(listId, rows) {
  const report = { created: 0, duplicates: 0, updated: 0, failed: [] };
  for (let i = 0; i < rows.length; i += 100) {
    const chunk = rows.slice(i, i + 100);
    const res = await fetch(`https://api.emelia.io/lists/list/${listId}/contacts/batch?updateIfExists=true`, {
      method: 'POST',
      headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify({ contacts: chunk }),
    });
    const body = await res.json();
    report.created += body.created;
    report.duplicates += body.duplicates;
    report.updated += body.updated;
    for (const r of body.results) if (r.error) report.failed.push({ row: chunk[r.index], error: r.error });
  }
  return report;
}
```

**Python**

```python
def load_contacts(list_id, rows):
    report = {"created": 0, "duplicates": 0, "updated": 0, "failed": []}
    for i in range(0, len(rows), 100):
        chunk = rows[i : i + 100]
        body = session.post(
            f"https://api.emelia.io/lists/list/{list_id}/contacts/batch",
            params={"updateIfExists": "true"},
            json={"contacts": chunk},
        ).json()
        for key in ("created", "duplicates", "updated"):
            report[key] += body[key]
        report["failed"] += [
            {"row": chunk[r["index"]], "error": r["error"]} for r in body["results"] if "error" in r
        ]
    return report
```

```json title="Response"
{
  "success": true,
  "total": 3,
  "created": 1,
  "duplicates": 1,
  "updated": 0,
  "failed": 1,
  "results": [
    { "index": 0, "leadId": "66f2a1b2c3d4e5f6a7b8c9d0", "companyId": "66f2a1b2c3d4e5f6a7b8c9e0", "duplicate": true, "updated": false },
    { "index": 1, "leadId": "66f2a1b2c3d4e5f6a7b8c9d1", "companyId": "66f2a1b2c3d4e5f6a7b8c9e1", "duplicate": false, "updated": false },
    { "index": 2, "error": "Field \"tags\" must be a string, number or boolean" }
  ],
  "createdCustomVariables": []
}
```

`results` follows the order of your array: `index` points back to the row you sent. Counters add up: `created + duplicates + failed = total`, and `updated` is part of `duplicates`. A row that repeats an email already seen earlier in the same batch is a duplicate of that row and is never updated.

Limits: 1 to 100 contacts per call (`422` otherwise), and 5 MB per body (`413`, `PAYLOAD_TOO_LARGE`).

## How keys are read

Each key of a contact is resolved in this order:

1. **The name of a standard field or one of its aliases**, whatever the case: `firstName`, `First Name` or `Prénom` all land in `firstName`.
2. **The technical name of a standard field**, ignoring case, spaces, `_` and `-` (`first_name` works), and also as a prefix or a suffix when the key has 3 characters or more.
3. **One of your custom variables**, by technical or display name.
4. A key that ends in `id` becomes a custom variable, without looking at its value.
5. **The value**: an email goes to `email`, 6 digits or more go to `phone`, a `linkedin.com/in/` URL goes to `linkedinUrlProfile`, another `http(s)://` URL goes to the company `websiteUrl`.
6. Otherwise, **a new custom variable** is created and returned in `createdCustomVariables`.

Traps that come from this order:

* **Short keys are captured by prefix.** `job` lands in `jobStartDate` (a date: the contact fails), `last` in `lastContacted`, `year` in `yearsOfExperience`, `note` in the company `notes`. Use the exact keys below.
* **Numbers sent under an unknown key go to `phone`**: a company registration number, an order number, a date such as `2024-01-15`. Declare the custom variable first: step 3 comes before step 5.
* **`mobile` goes to `phone`.** Use `mobilePhone` for a mobile number.
* **A standard field takes one key per contact.** A second email key moves to the next matching field and can be lost.
* **`null` and empty strings are ignored**, so a field cannot be cleared by the API. Arrays and objects make the row fail.

### Standard fields

| Contact                                                                | Company                                         |
| ---------------------------------------------------------------------- | ----------------------------------------------- |
| `firstName`, `lastName`, `fullName`                                    | `companyName`, `companyDomain`, `websiteUrl`    |
| `email`, `secondaryEmail`                                              | `companyEmail`, `logoUrl`                       |
| `phone`, `mobilePhone`                                                 | `industry`, `companySize`, `companyType`        |
| `jobTitle`, `seniority`, `department`, `jobStartDate`                  | `employeeCount`, `foundedYear`, `annualRevenue` |
| `linkedinUrlProfile`, `twitterUrl`                                     | `linkedinCompanyUrl`, `twitterCompanyUrl`       |
| `country`, `region`, `city`, `postalCode`, `address`, `timezone`       | `notes`, `tags`, `techStack`                    |
| `gender`, `age`, `birthDate`, `language`                               |                                                 |
| `yearsOfExperience`, `education`, `skills`, `bio`, `profilePictureUrl` |                                                 |

* `country`, `city` and the other address keys go to the contact. Use `Company Country`, `Company City` or `Company Address` for the company.
* Numeric fields (`age`, `employeeCount`, `annualRevenue`...) drop values that are not numbers. Date fields (`birthDate`, `jobStartDate`) need a valid date, such as `2021-03-01`.
* `phone` is the number the list counts and the Phone Finder fills.

## Find a contact

Before updating or deleting, look the contact up by email or by LinkedIn URL:

[`GET /lists/list/{listId}/rows`](https://docs.emelia.io/docs/lists/list-or-find-contacts.md)

**curl**

```bash
curl "https://api.emelia.io/lists/list/66f1c0ffee5a7d3b2c4e8a10/rows?email=claire.fontaine@nexora.io" \
  -H "Authorization: $EMELIA_API_KEY"
```

**JavaScript**

```js
const url = new URL(`https://api.emelia.io/lists/list/${listId}/rows`);
url.searchParams.set('email', 'claire.fontaine@nexora.io');
const res = await fetch(url, { headers: { Authorization: process.env.EMELIA_API_KEY } });
const { items } = await res.json();
const leadId = items[0]?.id;
```

**Python**

```python
res = session.get(
    f"https://api.emelia.io/lists/list/{list_id}/rows",
    params={"email": "claire.fontaine@nexora.io"},
)
items = res.json()["items"]
lead_id = items[0]["id"] if items else None
```

* `email` is matched without case, `linkedinUrl` exactly as stored. Send one of them, not both (`422`).
* The lookup covers the list and the lists linked to it, returns up to 50 full contacts, and ignores the pagination.
* Each item has an `id`: it is the `leadId` the other routes take.

To know which lists contain a person, across your whole account, pass the full email or LinkedIn URL in `leadSearch`: `GET /lists/list?leadSearch=claire.fontaine@nexora.io&page=1&limit=25`.

Without `email` or `linkedinUrl`, the same route lists the contacts page by page, see [Pagination](https://docs.emelia.io/docs/pagination.md). In that mode each contact only carries the fields of the columns visible in the list table of the app.

## Update a contact

[`PATCH /lists/contacts/{contactId}`](https://docs.emelia.io/docs/lists/update-contact.md)

Send only the fields that change. The list is found from the contact, and the campaigns using it get the new values.

**curl**

```bash
curl -X PATCH https://api.emelia.io/lists/contacts/66f2a1b2c3d4e5f6a7b8c9d0 \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"jobTitle": "VP Engineering", "city": "Lyon"}'
```

**JavaScript**

```js
await fetch('https://api.emelia.io/lists/contacts/66f2a1b2c3d4e5f6a7b8c9d0', {
  method: 'PATCH',
  headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ jobTitle: 'VP Engineering', city: 'Lyon' }),
});
```

**Python**

```python
session.patch(
    "https://api.emelia.io/lists/contacts/66f2a1b2c3d4e5f6a7b8c9d0",
    json={"jobTitle": "VP Engineering", "city": "Lyon"},
)
```

No duplicate check is made here: changing the email to one already in the list creates a duplicate.

## Delete a contact or a list

[`DELETE /lists/list/{listId}/rows/{leadId}`](https://docs.emelia.io/docs/lists/delete-contact.md)

[`DELETE /lists/list/{listId}`](https://docs.emelia.io/docs/lists/delete-list.md)

Both are permanent: there is no trash and no undo. Deleting a list also deletes its contacts, its companies and its linked lists, and removes them from your campaigns. Deleting a list used as an exclusion list puts the contacts it excluded back into those campaigns.

## Export

[`POST /lists/list/export`](https://docs.emelia.io/docs/lists/export-lists-as-csv.md)

One list comes back as a CSV file, several lists as a ZIP with one CSV each. The CSV is UTF-8 with BOM, comma separated, with one column per standard field, one per custom variable of your account, then `Lists` and `Campaigns`.

**curl**

```bash
curl -X POST https://api.emelia.io/lists/list/export \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"listIds": ["66f1c0ffee5a7d3b2c4e8a10"], "async": true}' \
  -o export.csv
```

**JavaScript**

```js
const res = await fetch('https://api.emelia.io/lists/list/export', {
  method: 'POST',
  headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({ listIds: ['66f1c0ffee5a7d3b2c4e8a10'], async: true }),
});
if (res.status === 202) {
  const { jobId } = await res.json(); // large export: follow the job
} else {
  const csv = await res.text(); // 5,000 contacts or fewer: the file itself
}
```

**Python**

```python
res = session.post(
    "https://api.emelia.io/lists/list/export",
    json={"listIds": ["66f1c0ffee5a7d3b2c4e8a10"], "async": True},
)
if res.status_code == 202:
    job_id = res.json()["jobId"]  # large export: follow the job
else:
    csv = res.text  # 5,000 contacts or fewer: the file itself
```

With `async: true`, an export of more than 5,000 contacts answers `202` with a `jobId`: poll [Get the status of an export](https://docs.emelia.io/docs/lists/get-status-of-export.md) every few seconds until `status` is `done`, then call [Download a finished export](https://docs.emelia.io/docs/lists/download-finished-export.md). Up to 5,000 contacts, the file comes back directly. Above 100,000 contacts the export is refused. One asynchronous export runs per account at a time, and jobs are kept 24 hours.

## Attach a list to a campaign

[`PATCH /advanced/campaigns/{campaignId}/recipients`](https://docs.emelia.io/docs/campaigns/set-contact-lists.md)

```json
{ "lists": ["66f1c0ffee5a7d3b2c4e8a10"], "excludedLists": ["66f1c0ffee5a7d3b2c4e8a11"] }
```

`lists` replaces the whole selection: send every list you want attached, not only the new one. Contacts found in an excluded list are left out. This call works while the campaign runs. See [Campaigns](https://docs.emelia.io/docs/campaigns.md#5-attach-the-contacts).

New contacts reach the `RUNNING`, `PAUSED` and `DRAFT` campaigns that use the list, usually within seconds and at the latest after the nightly resynchronisation. A `FINISHED` campaign does not start again: reactivate it, or keep it running with `alwaysOn`. To know which campaigns a list feeds, use [Find the campaigns of lists](https://docs.emelia.io/docs/lists/find-campaigns-of-lists.md).
