---
name: emelia-api
description: Use the Emelia REST API correctly. Emelia is a B2B prospecting platform (multichannel cold email, LinkedIn and WhatsApp campaigns, contact lists, Email Finder, Phone Finder, Email Verifier, replies, webhooks, LinkedIn extraction). Use this skill whenever you write code, scripts or HTTP calls against api.emelia.io, build an integration with Emelia (CRM sync, Make or n8n replacement, enrichment pipeline, webhook receiver), create or edit an Emelia campaign by API, push contacts into Emelia lists, or debug an Emelia API error.
---

# Emelia API

Full documentation: https://docs.emelia.io (index for agents: https://docs.emelia.io/llms.txt; every page has a Markdown version at `<url>.md`). OpenAPI spec: https://docs.emelia.io/openapi/emelia.json. When a detail is not in this file, read the matching `.md` page rather than guessing.

## Setup

- Base URL: `https://api.emelia.io`. Lists and contacts live under `https://api.emelia.io/lists` (so `POST /lists/list`), served by a separate service: same key, but errors use `message`, and real 404, 413 and 422 statuses.
- Auth: header `Authorization: <API key>`, the raw key. `Bearer <key>` is accepted. The key is exactly 48 letters and digits: always trim it. Read it from `EMELIA_API_KEY`, never hard-code it, never print it, never put it in a URL.
- JSON bodies with `Content-Type: application/json`, except multipart uploads (attachments, voice notes).
- One key opens the whole account (no scopes). There is no sandbox: every call acts on real data.
- Ids are MongoDB ObjectIds (24 lowercase hex). Dates are ISO 8601 UTC.

## The model, in three rules

1. **Contacts live in lists; campaigns read lists.** Adding a contact to a list used by a campaign makes it enter the campaign. To feed a campaign, add to its list. Deleting a contact from a list removes it from those campaigns.
2. **Every campaign is multichannel.** Create every campaign with `POST /advanced/campaigns`, whatever the channel. Its step tree decides email, LinkedIn, WhatsApp or several. Never create campaigns with `/emails/campaigns` or `/linkedin/campaigns` (legacy system).
3. **Enrichment is asynchronous.** `POST /tools/...` returns a `jobId` at once; poll `GET /tools/.../{jobId}` every 3 to 5 seconds until `data.status` is not `running`.

## Endpoints you will use most

| Purpose | Call |
|---|---|
| Check the key | `GET /advanced/campaigns` |
| Create a list | `POST /lists/list` `{"name"}` -> 201 `{listId}` |
| Declare a custom variable (before importing) | `POST /lists/custom-variable` `{"technicalName", "schema": "lead"|"company", "displayName"?}` (422 = already exists, fine) |
| Add one contact | `POST /lists/list/{listId}/contacts?updateIfExists=true` with a flat object |
| Add up to 100 contacts | `POST /lists/list/{listId}/contacts/batch?updateIfExists=true` `{"contacts":[...]}` |
| Find a contact in a list | `GET /lists/list/{listId}/rows?email=...` (or `linkedinUrl=`); items carry `id` = leadId |
| Which lists hold a person | `GET /lists/list?leadSearch=<email or LinkedIn URL>&page=1&limit=25` |
| Update a contact | `PATCH /lists/contacts/{leadId}` with only the changed fields |
| Delete a contact / a list | `DELETE /lists/list/{listId}/rows/{leadId}` / `DELETE /lists/list/{listId}` (permanent) |
| Export a list | `POST /lists/list/export` `{"listIds": [...], "async": true}` (202 + jobId above 5,000 contacts) |
| Create a campaign | `POST /advanced/campaigns` `{"name", "timeZone"?}` -> `{campaignId}` (name unique per account) |
| Read a campaign | `GET /advanced/campaigns/{id}` (step tree in `campaign.steps`) |
| Set the sequence | `PATCH /advanced/campaigns/{id}/steps` `{"steps": {tree}}` |
| Schedule and volumes | `PATCH /advanced/campaigns/{id}/settings` `{"settings": {...}}` |
| Senders | `PATCH /advanced/campaigns/{id}/identities` `{"identities": [{"name", "email"?, "linkedin"?, "whatsapp"?}]}` |
| Attach lists | `PATCH /advanced/campaigns/{id}/recipients` `{"lists": [...], "excludedLists": [...]}` |
| Test email | `POST /advanced/campaigns/{id}/test-email` `{"stepId", "versionId", "email"}` |
| Start / pause | `POST /advanced/campaigns/{id}/start` / `.../pause` (no body) |
| Stats | `GET /stats/advanced?campaignId=...` |
| Replies | `GET /advanced/campaigns/{id}/activities?types=MAIL_REPLIED,LINKEDIN_REPLIED&page=0` (30 per page, page from 0) |
| Export CSV | `GET /advanced/campaigns/{id}/export?type=replies` (or `contacts`, `activities`) |
| Find email | `POST /tools/find/email` `{"fullname", "companyName", "country", "companyWebsite"?}` |
| Find phone | `POST /tools/find/phone` `{"linkedinUrl"}` (public `linkedin.com/in/` URL) |
| Verify email | `POST /tools/verify/email` `{"email"}` |
| Answer a reply | `POST /emails/reply` `{"content", "providerId" or "senderEmail", "messageId"?, "to"?, "subject"?}` |
| Blacklist | `POST` / `DELETE /emails/blacklists/contact` `{"email": "<address or domain>"}`; LinkedIn: `/emails/blacklists/linkedin` `{"value"}` |
| Mailboxes | `GET /email-providers` (`_id`, `senderEmail`, `disconnected`) |
| Webhooks registered | `GET /webhook` |
| Credit history | `GET /credits/usage?page=1&limit=50` |

LinkedIn and WhatsApp accounts are connected in the Emelia app; there is no documented route to list them. Reuse the ids found in the `identities` of an existing campaign, or ask the user to set the identities in the app.

## Contacts: how keys are read

The body of a contact is a flat object of strings, numbers or booleans (an array or object fails the row; `null` and empty strings are ignored, so a field cannot be cleared). Each key is resolved in order:

1. standard field by name or alias, any case (`First Name`, `Prénom` -> `firstName`);
2. standard field by technical name, also as a prefix or suffix of 3+ characters (`job` -> `jobStartDate`, `last` -> `lastContacted`: avoid short keys);
3. existing custom variable of the account;
4. a key ending in `id` becomes a custom variable;
5. **by the value**: email -> `email`, 6+ digits -> `phone`, `linkedin.com/in/` -> `linkedinUrlProfile`, other URL -> company `websiteUrl`;
6. otherwise a new custom variable (returned in `createdCustomVariables`).

Consequences:
- Use the exact standard keys: `firstName`, `lastName`, `email`, `phone`, `mobilePhone`, `jobTitle`, `linkedinUrlProfile` (person), `linkedinCompanyUrl` (company page), `companyName`, `companyDomain`, `websiteUrl`, `city`, `country`. `mobile` maps to `phone`.
- A number under an unknown key (registration number, order number, a date) lands in `phone`: declare the custom variable first with `POST /lists/custom-variable`.
- Duplicates are matched in the list and its linked lists on the exact `email` (case sensitive: send lowercase), then on the exact `linkedinUrlProfile` string. A duplicate is not created again: `duplicate: true` with the existing `leadId`. `?updateIfExists=true` (query parameter, the exact string `true`) updates it instead (`updated: true`). **A contact without email and without LinkedIn URL is never deduplicated**: retries create copies.
- Batch: 1 to 100 contacts, 5 MB per body (413 `PAYLOAD_TOO_LARGE`). Read `results[]` (same order as input, `index`, `error` on failed rows) and report failures. `created + duplicates + failed = total`.
- Reading back (paginated mode): each contact only carries the columns visible in the app; use the exact lookup (`?email=`) for the full object.

## Campaign sequence (step tree)

- `steps` is an object (the root), not an array: `{"_id": "START", "stepType": "START", "delay": {"amount": 0, "unit": "DAYS"}, "next": {...}}`.
- **Ids: omit every `_id` except `"START"` on the root; the server generates them.** If you must write one, it is a MongoDB ObjectId: 24 lowercase hex chars (`crypto.randomBytes(12).toString('hex')`, `secrets.token_hex(12)`). Never a UUID, never a label. The API accepts any string without error, so a wrong id is not caught at save time.
- **Give every action step at least one version**, including `VISIT` and `LIKE`: `"versions": [{}]`.
- `stepType`: `EMAIL`, `CONNECTION`, `MESSAGE`, `INMAIL`, `VISIT`, `LIKE`, `WHATSAPP`, `TASK`, `CONDITION`, `END_OF_CAMPAIGN`. No `LINKEDIN_` prefix, no `WAIT` (a wait is the `delay` of the next step). `FOLLOW`, `AUDIO` and `API_CALL` are accepted when saved but nothing runs them, and the step is never marked as done: do not use them.
- LinkedIn voice note: a `MESSAGE` step whose `versions[0].attachments` is `[{"name", "url", "mediaType": "AUDIO", "duration"}]`, with the `name`, `url` and `duration` returned by `POST /advanced/linkedin/audio` (multipart field `audio`). `duration` is in milliseconds.
- `delay`: `{"amount": n, "unit": "DAYS"|"HOURS"|"MINUTES"}` from the previous action. The delay of the first action is ignored. On a `CONDITION`, the delay is the observation window.
- EMAIL versions: `{"subject", "message", "disabled": false}`; several versions = A/B test. `message` is HTML: `<p>` per paragraph, `<p></p>` for a blank line. Empty `subject` on a follow up keeps the thread; the first email needs a subject.
- MESSAGE and WHATSAPP versions are successive messages, not A/B. A `MESSAGE` right after a `CONNECTION` waits for the acceptance (up to a year).
- Variables: `{{firstName}}`, `{{lastName}}`, `{{companyName}}`, `{{jobTitle}}`, any contact field or custom variable, `{{companyCountry}}`, `{{companyCity}}`, `{{signature}}`, `{{unsubscribe_link}}` (in an `<a href>`). Spintax: `{Hi|Hello}`.
- `CONDITION`: `{"conditions": {"field", "operator", "value"}, "yes": {...}, "no": {...}}`. `field` is an event in capitals (`ACCEPTED`, `MAIL_REPLIED`, `LINKEDIN_REPLIED`, `OPENED`, `CLICKED`, `BOUNCED`, `UNSUBSCRIBED`, `TASK_COMPLETED`, with `EQUAL` and `"true"`) or a contact field with `$` (`$email`, `$jobTitle`). Operators evaluated: `EQUAL`, `DOES_NOT_EQUAL`, `GREATER`, `LOWER`, `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY`; anything else (`NOT_EQUAL`, `GREATER_THAN`) is saved and always false. `OPENED`/`CLICKED` need tracking on. Branches never merge.
- The tree is replaced whole: read `GET /advanced/campaigns/{id}`, modify, send the whole tree back. On a campaign that already sent, keep every existing `_id`.

## Campaign rules that cause most failures

- A `RUNNING` campaign refuses changes to name, settings, identities and steps: pause, patch, start. Attached lists can change while running.
- Settings: always send `start`, `end` (`HH:mm`), `trackOpens`, `trackLinks`, `blacklistUnsub`; the rest is merged. `dailyEmailAdded` and `dailyEmailLimit` 1 to 500, limit >= added. `dailyLinkedinAdded` max 40 with a `CONNECTION` step, 80 otherwise. `dailyWhatsappAdded` 1 to 30. `days` 0 (Monday) to 6.
- Defaults of a new campaign: Europe/Brussels (unless `timeZone`), Monday to Friday, 08:00 to 17:00, 35 new contacts and 100 emails per day, tracking off, a reply stops the sequence.
- `lists` and `identities` replace the previous values entirely. Identities take account ids (`_id` from `/email-providers`), not addresses. Attach only the mailboxes of the campaign's sender.
- Start errors are `400` with a stable `code`, first failing check only: `SUBSCRIPTION_REQUIRED`, `NO_IDENTITY_SET`, `EMAIL_VERSION_NO_SUBJECT`, `EMAIL_PROVIDER_DISCONNECTED`, `LINKEDIN_ACCOUNT_AMBIGUOUS`, `MC_LINKEDIN_DAILY_LIMIT_CONNECTION`, `NO_RECIPIENTS`, `TOO_MANY_RECIPIENTS` (30,000 max), `RECIPIENTS_NO_EMAIL`...
- Connecting a mailbox or a LinkedIn account, and the subscription a start needs, happen in the Emelia app.

## Enrichment

- Find email: `country` (ISO 2 letters, e.g. `FR`) is required: without it, `400 "country" is required`. 1 credit, refunded when no email is found (not on timeout). A search can take up to about 4 minutes.
- Result: `GET /tools/find/email/{jobId}` -> `{"data": {"status": "running"|"done"|"error", "email"?, "qualification"}}`. `done` without `email` = not found. An email found as `valid` is already verified: do not verify it again.
- Find phone: 50 credits, refunded when no number is found; no fixed upper bound on the wait. Verify: 0.25 credit, never refunded; the job ends in `error` after about 60 seconds without a verdict.
- **Never re-POST a job that may have started**: it starts and bills a second job. Poll the existing `jobId`. History: `GET /tools/find/email` (page from 1).
- For many contacts: filter first, start all jobs, then poll them. Stop at the first "You do not have enough credits to perform this action" (400).
- Confirm the credit cost with the user before any bulk enrichment.

## Webhooks

- Registration is not in the public API: webhooks come from the Make integration and are managed in the app. `GET /webhook` lists them.
- Event names of current campaigns: `EMAIL_SENT` (not `MAIL_SENT`), `OPENED`, `CLICKED`, `MAIL_REPLIED`, `BOUNCED`, `UNSUBSCRIBED`, `VISITED`, `INVITED`, `ACCEPTED`, `MESSAGE_SENT`, `INMAIL_SENT`, `LIKED`, `LINKEDIN_REPLIED`, `WHATSAPP_MESSAGE_SENT`, `WHATSAPP_REPLIED`, `CAMPAIGN_ENDING`.
- Payload: JSON `POST` with `event`, `campaign` (name), `contact` (custom variables then standard fields), `date`, `step`, and for replies `reply`, `sentiment` `{classification, score}`, `sender`, `messageId`.
- Deliveries are not signed, not retried on HTTP errors, not ordered and carry no event id: use a hard to guess URL, answer 2xx at once, process async, and reconcile with the activity feed if you cannot miss events.

## Errors and limits

- Error body: `{"error": "...", "code"?, "params"?}` on the main API (branch on `code` when present); `{"message": "...", "code"?}` on `/lists`. "Not found" is a `400` on the main API, a `404` on `/lists`. Unknown paths answer an HTML 404.
- `401 {"error": "Authentication required"}` = bad, disabled or untrimmed key. On `/stats/...`, a `401` can also mean "campaign not found".
- Rate limit per account (all keys together), per fixed minute: 30 without a plan, 100 Start, 300 Grow, 1,000 Scale. `/tools` and `/lists` are not counted. **Over the limit the API answers `500 {"error": "Internal server error"}`, not 429**: wait for the next minute, then go slower.
- Pagination: lists `page` from 1 (`limit` max 100), list rows `page` from 1 (`pageSize` max 100), activities and campaign contacts `page` from 0, tool and credit histories `page` from 1. Campaigns, mailboxes and warmups are not paginated.

## Safety

- Ask before anything that sends a message, starts a campaign, deletes data or spends credits. Show what will happen (recipient count, cost) and wait for an explicit yes.
- Never print, log or commit the API key, or LinkedIn session cookies.
- Read the campaign back after each write to confirm what Emelia now holds.
