# Add a contact to a list (add or update)

Source: https://docs.emelia.io/docs/lists/add-contact

Guide: [Lists & contacts](https://docs.emelia.io/docs/lists.md)

`POST https://api.emelia.io/lists/list/{listId}/contacts`

Adds ONE contact to a list from a flat JSON object: field names as keys, scalar values (string, number or boolean). This is the endpoint for automations (Zapier, Make, n8n, scripts) and the standard way to feed a running campaign: if campaigns in RUNNING, PAUSED or DRAFT status use the list, the contact is propagated to them asynchronously (usually within seconds, at the latest by the nightly reconciliation). Adding contacts consumes no credits. For more than one contact, use the batch route.

### How keys are resolved
Each key is resolved in this order: (1) case-insensitively against the recognized names of the standard fields (`First Name`, `Prénom`, `email_address`, `Company`...); (2) against the standard field keys, ignoring case, spaces, `_` and `-` (`firstName`, `first_name` and `FIRSTNAME` all work), and also as a prefix or suffix of a field key when the key has 3 characters or more; (3) against your existing custom variables (technical or display name); (4) a key whose name ends in `id` is never matched by its value and becomes a custom variable; (5) otherwise the VALUE is inspected: an email-looking value goes to `email`, 6 digits or more go to `phone`, a `linkedin.com/in/` URL goes to `linkedinUrlProfile`, any other `http(s)://` URL goes to the company `websiteUrl`. Only when nothing matched is a new lead custom variable created (display name = the key, technical name derived from it) and reported in `createdCustomVariables`.

### Pitfalls
- Use the exact keys listed below. Short or generic keys are fuzzy-matched: `job` lands in `jobStartDate` (a date, the request fails), `last` in `lastContacted` (the value is lost), `year` in `yearsOfExperience`, `date` in `birthDate`, `note` in the company `notes`, `tag` in the company `tags`.
- An unknown key whose value looks like a phone number (company registration number, order number, a date such as `2024-01-15`) is stored in `phone`, not in a new custom variable. Declare the custom variable first with `POST /lists/custom-variable`: an existing variable is matched before the value is inspected.
- `mobile` maps to `phone`. Use `mobilePhone` for a mobile number.
- A standard field takes at most one key per contact. When two keys target the same field, the second one moves to the next matching field (a second email key can end up in `emailVerified` and be lost).
- Numeric fields (`age`, `yearsOfExperience`, `employeeCount`, `foundedYear`, `annualRevenue`) silently drop non-numeric values. Date fields (`birthDate`, `jobStartDate`) must be valid dates (use ISO 8601, for example `2021-03-01`): an unparsable date makes the request fail.
- `null` and empty strings are ignored: this endpoint cannot clear a field. Arrays and objects are rejected (422).
- Every unknown key creates an account-wide custom variable that cannot be deleted through the API.

### Standard keys
Contact: `firstName`, `lastName`, `fullName`, `email`, `secondaryEmail`, `phone`, `mobilePhone`, `linkedinUrlProfile`, `twitterUrl`, `jobTitle`, `seniority`, `department`, `gender`, `age`, `birthDate`, `language`, `country`, `region`, `city`, `postalCode`, `address`, `timezone`, `jobStartDate`, `yearsOfExperience`, `education`, `skills` (list separated by commas, semicolons or pipes), `bio`, `profilePictureUrl`.
Company: `companyName`, `companyDomain`, `companyEmail`, `websiteUrl`, `linkedinCompanyUrl`, `twitterCompanyUrl`, `logoUrl`, `industry`, `companySize`, `companyType`, `employeeCount`, `foundedYear`, `annualRevenue`, `notes`, `tags`, `techStack`. The keys `country`, `city`, `region`, `postalCode`, `address` and `timezone` go to the contact: use `Company Country`, `Company City` or `Company Address` to fill the company ones.

### Duplicates and idempotency
Before creating, the service looks in this list and the lists linked to it for a contact with the same `email` (exact comparison, case-sensitive), then with the same `linkedinUrlProfile` (exact string). If one is found, nothing is created: the response returns its `leadId` with `duplicate: true`. With `updateIfExists=true` the payload is applied to that contact instead (sent fields overwrite, absent fields are kept, identity fields included) and the response has `updated: true`. A contact without email and without LinkedIn URL is never deduplicated: retrying such a call creates a new contact each time. Send emails in lowercase and LinkedIn URLs in one canonical form.

The status code is 200 for a creation, an ignored duplicate and an update alike: read `duplicate` and `updated`.

## Authentication

Send your API key in the `Authorization` header: `Authorization: <your API key>`.

## Path parameters

| Name | Type | Required | Description |
|---|---|---|---|
| `listId` | string | yes | List id. |

## Query parameters

| Name | Type | Required | Description |
|---|---|---|---|
| `updateIfExists` | enum: `true`, `false` | no | Only the exact string `true` enables it. When a duplicate is found, apply the payload to the existing contact instead of ignoring it. Query parameter, never a body key (a body key would be stored as a contact field). Default: `"false"`. |

## Request body

Content type: `application/json`, required.

Flat contact object: one key per field, scalar values only. Standard keys are listed in the description of `POST /lists/list/{listId}/contacts`; any other key goes to an existing custom variable of the same name or creates a new one. Numbers and booleans are converted to strings. `null` and empty strings are ignored. Arrays and objects are rejected.

```json
{
  "firstName": "John",
  "lastName": "Doe",
  "email": "john.doe@acme.com",
  "linkedinUrlProfile": "https://www.linkedin.com/in/john-doe-acme",
  "jobTitle": "Head of Sales",
  "companyName": "Acme",
  "websiteUrl": "https://www.acme.com",
  "icebreaker": "Loved your talk at SaaStock"
}
```

## Example request

**cURL**

```bash
curl -X POST "https://api.emelia.io/lists/list/65f1a2b3c4d5e6f760f68df4/contacts" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "firstName": "John",
  "lastName": "Doe",
  "email": "john.doe@acme.com",
  "linkedinUrlProfile": "https://www.linkedin.com/in/john-doe-acme",
  "jobTitle": "Head of Sales",
  "companyName": "Acme",
  "websiteUrl": "https://www.acme.com",
  "icebreaker": "Loved your talk at SaaStock"
}'
```

**JavaScript**

```js
const response = await fetch('https://api.emelia.io/lists/list/65f1a2b3c4d5e6f760f68df4/contacts', {
  method: 'POST',
  headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    "firstName": "John",
    "lastName": "Doe",
    "email": "john.doe@acme.com",
    "linkedinUrlProfile": "https://www.linkedin.com/in/john-doe-acme",
    "jobTitle": "Head of Sales",
    "companyName": "Acme",
    "websiteUrl": "https://www.acme.com",
    "icebreaker": "Loved your talk at SaaStock"
  }),
});
console.log(await response.json());
```

**Python**

```python
import os
import requests

response = requests.post(
    "https://api.emelia.io/lists/list/65f1a2b3c4d5e6f760f68df4/contacts",
    headers={"Authorization": os.environ["EMELIA_API_KEY"]},
    json={
        "firstName": "John",
        "lastName": "Doe",
        "email": "john.doe@acme.com",
        "linkedinUrlProfile": "https://www.linkedin.com/in/john-doe-acme",
        "jobTitle": "Head of Sales",
        "companyName": "Acme",
        "websiteUrl": "https://www.acme.com",
        "icebreaker": "Loved your talk at SaaStock"
    },
)
print(response.json())
```

## Responses

### 200: Contact created, duplicate ignored, or duplicate updated.

| Field | Type | Required | Description |
|---|---|---|---|
| `success` | boolean | yes |  |
| `leadId` | string | yes | Id of the created contact, or of the existing one when `duplicate` is true. |
| `companyId` | string or null | yes | Id of the company row, null when the contact has none. |
| `duplicate` | boolean | yes | True when a contact with the same email or LinkedIn URL already existed: nothing was created. |
| `updated` | boolean | yes | True when that existing contact was updated (`updateIfExists=true`). |
| `createdCustomVariables` | array of object | yes |  |
| `createdCustomVariables[].technicalName` | string | yes |  |
| `createdCustomVariables[].displayName` | string | yes | The key as sent. |
| `createdCustomVariables[].schema` | enum: `lead`, `company` | yes | Always `lead` for variables created by the contact routes. |

```json
{
  "success": true,
  "leadId": "string",
  "companyId": "string",
  "duplicate": true,
  "updated": true,
  "createdCustomVariables": [
    {
      "technicalName": "string",
      "displayName": "string",
      "schema": "lead"
    }
  ]
}
```

### 400: The list id is not a valid ObjectId.

| Field | Type | Required | Description |
|---|---|---|---|
| `message` | string | yes | English, human-readable message. |
| `code` | string | no | Stable error code, when available (for example `PAYLOAD_TOO_LARGE`, `INVALID_JSON`, `export_too_large`, `companies_only`, `enrichment_running`). |
| `params` | object | no |  |
| `error` | string | no | Raw internal message (unexpected errors only). |

```json
{
  "message": "Invalid list id"
}
```

### 401: Missing, unknown or disabled API key, or the account of the key no longer exists. Note the body shape: `error` and `code`, not `message`.

| Field | Type | Required | Description |
|---|---|---|---|
| `error` | string | yes |  |
| `code` | enum: `AUTH_REQUIRED`, `ACCOUNT_NOT_FOUND` | yes |  |

```json
{
  "error": "string",
  "code": "AUTH_REQUIRED"
}
```

### 404: The list does not exist or belongs to another account.

| Field | Type | Required | Description |
|---|---|---|---|
| `message` | string | yes | English, human-readable message. |
| `code` | string | no | Stable error code, when available (for example `PAYLOAD_TOO_LARGE`, `INVALID_JSON`, `export_too_large`, `companies_only`, `enrichment_running`). |
| `params` | object | no |  |
| `error` | string | no | Raw internal message (unexpected errors only). |

```json
{
  "message": "List not found"
}
```

### 413: JSON body larger than 5 MB.

| Field | Type | Required | Description |
|---|---|---|---|
| `message` | string | yes | English, human-readable message. |
| `code` | string | no | Stable error code, when available (for example `PAYLOAD_TOO_LARGE`, `INVALID_JSON`, `export_too_large`, `companies_only`, `enrichment_running`). |
| `params` | object | no |  |
| `error` | string | no | Raw internal message (unexpected errors only). |

```json
{
  "message": "Request body too large: the limit is 5mb",
  "code": "PAYLOAD_TOO_LARGE",
  "params": {
    "limit": "5mb"
  }
}
```

### 422: Invalid body.

| Field | Type | Required | Description |
|---|---|---|---|
| `message` | string | yes | English, human-readable message. |
| `code` | string | no | Stable error code, when available (for example `PAYLOAD_TOO_LARGE`, `INVALID_JSON`, `export_too_large`, `companies_only`, `enrichment_running`). |
| `params` | object | no |  |
| `error` | string | no | Raw internal message (unexpected errors only). |

```json
{
  "message": "string",
  "code": "string",
  "params": {},
  "error": "string"
}
```

### 500: Unexpected error, for example an invalid date in `birthDate` or `jobStartDate`. The message is the raw internal message.

| Field | Type | Required | Description |
|---|---|---|---|
| `message` | string | yes | English, human-readable message. |
| `code` | string | no | Stable error code, when available (for example `PAYLOAD_TOO_LARGE`, `INVALID_JSON`, `export_too_large`, `companies_only`, `enrichment_running`). |
| `params` | object | no |  |
| `error` | string | no | Raw internal message (unexpected errors only). |

```json
{
  "message": "string",
  "code": "string",
  "params": {},
  "error": "string"
}
```
