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:
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"}'schemaisleadfor a contact variable,companyfor a company variable. This route is the only way to create a company variable.- The call answers
422when the name exists or is a standard field: call it at every import and treat422as "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.
| 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.ioandclaire@nexora.iomake 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, likehttps://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" }
]
}'{
"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:
- The name of a standard field or one of its aliases, whatever the case:
firstName,First NameorPrénomall land infirstName. - The technical name of a standard field, ignoring case, spaces,
_and-(first_nameworks), and also as a prefix or a suffix when the key has 3 characters or more. - One of your custom variables, by technical or display name.
- A key that ends in
idbecomes a custom variable, without looking at its value. - The value: an email goes to
email, 6 digits or more go tophone, alinkedin.com/in/URL goes tolinkedinUrlProfile, anotherhttp(s)://URL goes to the companywebsiteUrl. - Otherwise, a new custom variable is created and returned in
createdCustomVariables.
Traps that come from this order:
- Short keys are captured by prefix.
joblands injobStartDate(a date: the contact fails),lastinlastContacted,yearinyearsOfExperience,notein the companynotes. Use the exact keys below. - Numbers sent under an unknown key go to
phone: a company registration number, an order number, a date such as2024-01-15. Declare the custom variable first: step 3 comes before step 5. mobilegoes tophone. UsemobilePhonefor 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.
nulland 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,cityand the other address keys go to the contact. UseCompany Country,Company CityorCompany Addressfor the company.- Numeric fields (
age,employeeCount,annualRevenue...) drop values that are not numbers. Date fields (birthDate,jobStartDate) need a valid date, such as2021-03-01. phoneis 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"emailis matched without case,linkedinUrlexactly 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 theleadIdthe 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.csvWith 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.