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.

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.

The Python examples of this guide share one session:

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
curl -X POST https://api.emelia.io/lists/list \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Q4 SaaS founders"}'

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
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"}'
  • 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 tells you in advance.
  • Variables cannot be renamed or deleted through the API.

Add one contact

POST/lists/list/{listId}/contacts

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

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"
  }'

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.

Situationduplicateupdated
New contactfalsefalse
Already in the list, updateIfExists absenttruefalse
Already in the list, updateIfExists=truetruetrue

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

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

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" }
    ]
  }'
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

ContactCompany
firstName, lastName, fullNamecompanyName, companyDomain, websiteUrl
email, secondaryEmailcompanyEmail, logoUrl
phone, mobilePhoneindustry, companySize, companyType
jobTitle, seniority, department, jobStartDateemployeeCount, foundedYear, annualRevenue
linkedinUrlProfile, twitterUrllinkedinCompanyUrl, twitterCompanyUrl
country, region, city, postalCode, address, timezonenotes, 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
curl "https://api.emelia.io/lists/list/66f1c0ffee5a7d3b2c4e8a10/rows?email=claire.fontaine@nexora.io" \
  -H "Authorization: $EMELIA_API_KEY"
  • 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. 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}

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

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"}'

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

Delete a contact or a list

DEL/lists/list/{listId}/rows/{leadId} DEL/lists/list/{listId}

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

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 -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

With async: true, an export of more than 5,000 contacts answers 202 with a jobId: poll Get the status of an export every few seconds until status is done, then call Download a finished export. 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
{ "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.

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.

On this page