# Campaigns

> Create, configure and launch multichannel campaigns by API. Cold email sequences, LinkedIn automation, WhatsApp and manual tasks, conditions, senders, schedule and tracking.

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

Every Emelia campaign is multichannel: email steps, LinkedIn steps, WhatsApp messages, manual tasks and conditions live in the same sequence. Its steps decide its channels, so a cold email campaign and a LinkedIn automation campaign are created the same way.

A campaign is built in a few calls:

1. [Create the campaign](#1-create-the-campaign)
2. [Write the sequence](#2-write-the-sequence)
3. [Set the schedule](#3-set-the-schedule)
4. [Choose the senders](#4-choose-the-senders)
5. [Attach the contacts](#5-attach-the-contacts)
6. [Send yourself a test](#6-send-yourself-a-test)
7. [Start it](#7-start)

The examples use this setup:

**curl**

```bash
export EMELIA_API_KEY="your_api_key"
export CAMPAIGN_ID="6650f1a2b839d80011c5a0f1"
```

**JavaScript**

```js
const emelia = async (method, path, body) => {
  const res = await fetch(`https://api.emelia.io${path}`, {
    method,
    headers: { Authorization: process.env.EMELIA_API_KEY, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${json.code ?? ''} ${json.error ?? json.message}`);
  return json;
};
```

**Python**

```python
import os
import requests

session = requests.Session()
session.headers["Authorization"] = os.environ["EMELIA_API_KEY"]

def emelia(method, path, body=None):
    res = session.request(method, f"https://api.emelia.io{path}", json=body)
    data = res.json()
    if not res.ok:
        raise RuntimeError(f"{res.status_code} {data.get('code', '')} {data.get('error') or data.get('message')}")
    return data
```

## 1. Create the campaign

[`POST /advanced/campaigns`](https://docs.emelia.io/docs/campaigns/create-campaign.md)

**curl**

```bash
curl -X POST https://api.emelia.io/advanced/campaigns \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Q4 SaaS founders", "timeZone": "Europe/Paris"}'
# {"success":true,"campaignId":"6650f1a2b839d80011c5a0f1"}
```

**JavaScript**

```js
const { campaignId } = await emelia('POST', '/advanced/campaigns', {
  name: 'Q4 SaaS founders',
  timeZone: 'Europe/Paris',
});
```

**Python**

```python
campaign_id = emelia("POST", "/advanced/campaigns", {
    "name": "Q4 SaaS founders",
    "timeZone": "Europe/Paris",
})["campaignId"]
```

The name must be unique on your account. The campaign is created as a `DRAFT`, with a sequence that only holds the `START` step, and these defaults:

| Setting                                    | Default                                                                    |
| ------------------------------------------ | -------------------------------------------------------------------------- |
| `timeZone`                                 | `Europe/Brussels`, unless you send another IANA zone                       |
| `days`                                     | Monday to Friday, `[0, 1, 2, 3, 4]` (0 is Monday, 6 is Sunday)             |
| `start`, `end`                             | `08:00` to `17:00`                                                         |
| `dailyEmailAdded`                          | 35 new contacts per day in the email part                                  |
| `dailyEmailLimit`                          | 100 emails per day                                                         |
| `dailyLinkedinAdded`, `dailyWhatsappAdded` | 20 new contacts per day                                                    |
| `trackOpens`, `trackLinks`                 | off                                                                        |
| `eventToStop`                              | a reply by email, LinkedIn or WhatsApp stops the sequence for that contact |

## 2. Write the sequence

The sequence is a **tree** of steps, sent whole in `steps`: an object, the root of the tree, not an array. The call replaces the whole tree, and the campaign must not be running.

[`PATCH /advanced/campaigns/{campaignId}/steps`](https://docs.emelia.io/docs/campaigns/replace-sequence.md)

### A cold email sequence

Three emails, three then five days apart:

**curl**

```bash
curl -X PATCH "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/steps" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d @sequence.json
```

**JavaScript**

```js
import { readFile } from 'node:fs/promises';

const sequence = JSON.parse(await readFile('sequence.json', 'utf8'));
await emelia('PATCH', `/advanced/campaigns/${campaignId}/steps`, sequence);
```

**Python**

```python
import json

with open("sequence.json") as f:
    sequence = json.load(f)
emelia("PATCH", f"/advanced/campaigns/{campaign_id}/steps", sequence)
```

```json title="sequence.json"
{
  "steps": {
    "_id": "START",
    "stepType": "START",
    "delay": { "amount": 0, "unit": "DAYS" },
    "next": {
      "stepType": "EMAIL",
      "delay": { "amount": 0, "unit": "DAYS" },
      "versions": [
        {
          "subject": "{{companyName}} and Postgres 16",
          "message": "<p>Hi {{firstName}},</p><p>I saw {{icebreaker}}. ...</p><p></p><p>{{signature}}</p>",
          "disabled": false
        }
      ],
      "next": {
        "stepType": "EMAIL",
        "delay": { "amount": 3, "unit": "DAYS" },
        "versions": [
          {
            "subject": "",
            "message": "<p>Following up, {{firstName}}. ...</p><p></p><p>{{signature}}</p><p></p><p><a href=\"{{unsubscribe_link}}\">Unsubscribe</a></p>",
            "disabled": false
          }
        ],
        "next": {
          "stepType": "EMAIL",
          "delay": { "amount": 5, "unit": "DAYS" },
          "versions": [
            {
              "subject": "",
              "message": "<p>Last note from me, {{firstName}}. ...</p><p></p><p>{{signature}}</p><p></p><p><a href=\"{{unsubscribe_link}}\">Unsubscribe</a></p>",
              "disabled": false
            }
          ]
        }
      }
    }
  }
}
```

* **The body is HTML.** One `<p>` per paragraph, `<p></p>` for an empty line.
* **An empty subject keeps the thread**: the follow up arrives as a reply to the previous email. The first email needs a subject.
* **Several `versions` on an `EMAIL` step make an A/B test**: each contact gets one of them. `disabled: true` takes a version out of the rotation.
* **`cc`** on an `EMAIL` step copies an address, which can be a variable such as `{{assistantEmail}}`.

### A LinkedIn automation sequence

Visit the profile, send an invitation, then two messages once it is accepted. Send it with the same call.

```json
{
  "steps": {
    "_id": "START",
    "stepType": "START",
    "delay": { "amount": 0, "unit": "DAYS" },
    "next": {
      "stepType": "VISIT",
      "delay": { "amount": 0, "unit": "DAYS" },
      "versions": [{}],
      "next": {
        "stepType": "CONNECTION",
        "delay": { "amount": 1, "unit": "DAYS" },
        "versions": [{ "message": "" }],
        "next": {
          "stepType": "MESSAGE",
          "delay": { "amount": 2, "unit": "HOURS" },
          "versions": [
            { "message": "Thanks for connecting, {{firstName}}. I work with teams like {{companyName}} on ..." },
            { "message": "{{firstName}}, would a 15 minute call next week make sense?" }
          ]
        }
      }
    }
  }
}
```

* An empty note on `CONNECTION` sends the invitation without a message.
* A `MESSAGE` step placed right after a `CONNECTION` waits for the invitation to be accepted, for up to a year.
* On `MESSAGE` and `WHATSAPP` steps, the versions are successive messages sent in order, not an A/B test.
* `dailyLinkedinAdded` is capped at 40 new contacts per day when the sequence sends invitations, 80 otherwise.

### A multichannel sequence with a condition

LinkedIn first. If the invitation is accepted within 5 days, a LinkedIn message. If not, and the contact has an email, two emails.

```json
{
  "steps": {
    "_id": "START",
    "stepType": "START",
    "delay": { "amount": 0, "unit": "DAYS" },
    "next": {
      "stepType": "CONNECTION",
      "delay": { "amount": 0, "unit": "DAYS" },
      "versions": [{ "message": "" }],
      "next": {
        "stepType": "CONDITION",
        "delay": { "amount": 5, "unit": "DAYS" },
        "conditions": { "field": "ACCEPTED", "operator": "EQUAL", "value": "true" },
        "yes": {
          "stepType": "MESSAGE",
          "delay": { "amount": 2, "unit": "HOURS" },
          "versions": [{ "message": "Thanks for connecting, {{firstName}}. ..." }]
        },
        "no": {
          "stepType": "CONDITION",
          "delay": { "amount": 1, "unit": "HOURS" },
          "conditions": { "field": "$email", "operator": "IS_NOT_EMPTY", "value": "" },
          "yes": {
            "stepType": "EMAIL",
            "delay": { "amount": 1, "unit": "DAYS" },
            "versions": [{ "subject": "{{companyName}} and Postgres 16", "message": "<p>Hi {{firstName}},</p><p>...</p><p></p><p>{{signature}}</p>", "disabled": false }],
            "next": {
              "stepType": "EMAIL",
              "delay": { "amount": 4, "unit": "DAYS" },
              "versions": [{ "subject": "", "message": "<p>...</p><p></p><p>{{signature}}</p><p></p><p><a href=\"{{unsubscribe_link}}\">Unsubscribe</a></p>", "disabled": false }]
            }
          },
          "no": { "stepType": "END_OF_CAMPAIGN", "delay": { "amount": 0, "unit": "DAYS" } }
        }
      }
    }
  }
}
```

### Step and version ids: ObjectIds, never UUIDs

> **Leave the ids out.** The `_id` of a step or of a version is a MongoDB **ObjectId**: 24 lowercase hexadecimal characters, like `66f2b4c19e7a3d5b8c0e1f24`. Never a UUID, never a label like `"email-1"`.
>
> **The simplest is to leave them out.** The server generates one for every step and every version that has none. The only `_id` you write is `"START"` on the root.

The API accepts any string without an error, so a wrong id is not caught when you save the sequence. If you must write ids yourself, for example to keep a reference in your system, generate real ObjectIds:

**curl**

```bash
# 24 lowercase hexadecimal characters
openssl rand -hex 12
```

**JavaScript**

```js
import { randomBytes } from 'node:crypto';
const objectId = () => randomBytes(12).toString('hex'); // 24 lowercase hex characters
```

**Python**

```python
import secrets
object_id = secrets.token_hex(12)  # 24 lowercase hex characters
```

### Anatomy of a step

| Field                     | Description                                                                                                                                                                                                                                                                                  |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `_id`                     | Leave it out on new steps. Keep it on steps that already ran, see [Editing a running campaign](#edit-a-campaign-that-already-sent).                                                                                                                                                          |
| `stepType`                | What the step does, see below.                                                                                                                                                                                                                                                               |
| `delay`                   | Wait before this step, counted from the previous action: `{ "amount": 3, "unit": "DAYS" }`. `unit` is `MINUTES`, `HOURS` or `DAYS`. The delay of the first action is ignored: new contacts enter according to the daily quotas. With `DAYS`, the days that are not sending days are skipped. |
| `versions`                | The content of the step. Give every action step at least one version, even `VISIT` and `LIKE`, which take an empty one: `"versions": [{}]`.                                                                                                                                                  |
| `next`                    | The following step. Absent on the last one.                                                                                                                                                                                                                                                  |
| `conditions`, `yes`, `no` | Only on `CONDITION` steps. A missing branch ends the sequence for the contacts that fall into it.                                                                                                                                                                                            |
| `identities`              | Legacy field, not read when sending: send `[]` or leave it out. Senders are set on the campaign.                                                                                                                                                                                             |

| `stepType`        | Channel  | What it does                                                                                                                           |
| ----------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `START`           |          | The root. Always `"_id": "START"`.                                                                                                     |
| `EMAIL`           | Email    | Sends an email: `versions[].subject` and `versions[].message` (HTML).                                                                  |
| `CONNECTION`      | LinkedIn | Sends an invitation, with an optional note in `versions[].message`.                                                                    |
| `MESSAGE`         | LinkedIn | Sends a message to a contact you are connected with, or a voice note, see [Attachments and voice notes](#attachments-and-voice-notes). |
| `INMAIL`          | LinkedIn | Sends an InMail, with a `subject` and a `message`.                                                                                     |
| `VISIT`           | LinkedIn | Visits the profile. `"versions": [{}]`.                                                                                                |
| `LIKE`            | LinkedIn | Likes a recent post of the contact. `"versions": [{}]`.                                                                                |
| `WHATSAPP`        | WhatsApp | Sends a WhatsApp message (plain text).                                                                                                 |
| `TASK`            | Manual   | Creates a task for a human, see [Manual tasks](#manual-tasks). The label is `versions[].subject`.                                      |
| `CONDITION`       |          | Branches on an event or a field, see below.                                                                                            |
| `END_OF_CAMPAIGN` |          | Ends the sequence for the contact.                                                                                                     |

There is no `WAIT` step: a wait is the `delay` of the next step. LinkedIn step types have no `LINKEDIN_` prefix. `FOLLOW`, `AUDIO` and `API_CALL` exist in the model and are accepted when you save the sequence, but nothing runs them: no action happens and the step is never marked as done. Do not use them. A voice note is a `MESSAGE` step, see [Attachments and voice notes](#attachments-and-voice-notes).

### Conditions

A `CONDITION` step has a `conditions` object, a `yes` branch and a `no` branch. `field` takes two forms:

| Form                        | Examples                                                                                                         | `operator` and `value`                                                                                    |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| An event, in capitals       | `ACCEPTED`, `MAIL_REPLIED`, `LINKEDIN_REPLIED`, `OPENED`, `CLICKED`, `UNSUBSCRIBED`, `BOUNCED`, `TASK_COMPLETED` | `EQUAL` and `"true"`                                                                                      |
| A contact field, with a `$` | `$email`, `$jobTitle`, `$icebreaker`                                                                             | `EQUAL`, `DOES_NOT_EQUAL`, `CONTAINS`, `DOES_NOT_CONTAIN`, `GREATER`, `LOWER`, `IS_EMPTY`, `IS_NOT_EMPTY` |

* **The `delay` of an event condition is its observation window.** "Accepted within 5 days?" keeps the contacts that have not accepted on hold for 5 days, then sends them down `no`. A field condition is evaluated at once.
* **Only the operators above are evaluated.** Any other value, for example `NOT_EQUAL` or `GREATER_THAN`, is saved without error and always evaluates to false: everybody goes down `no`.
* **`{{email}}` is not a valid field**: write `$email`.
* **`OPENED` and `CLICKED` need tracking** (`trackOpens`, `trackLinks`). Without it the event is never recorded and everybody goes down `no`.
* **Branches never join again.** A step needed after both branches is written in each of them.

### Variables

Write variables as `{{name}}` in subjects and messages:

* the contact fields `firstName`, `lastName`, `fullName`, `email`, `secondaryEmail`, `phone`, `mobilePhone`, `jobTitle`, `seniority`, `department`, `gender`, `age`, `language`, `linkedinUrlProfile`, `twitterUrl`, `country`, `region`, `city`, `postalCode`, `address`, `timezone`, `yearsOfExperience`, `education`, `bio`, `companyName`;
* every custom variable of your contacts, for example `{{icebreaker}}`;
* the company fields, with `companyCountry` and `companyCity` for the country and city of the company;
* `{{signature}}`, the signature of the sending mailbox, and `{{unsubscribe_link}}`, the unsubscribe URL to put in a link.

Messages also support spintax: `{Hi|Hello|Hey} {{firstName}}` picks one of the options for each contact. Check that every contact has a value for the variables you use, then send yourself a test (step 6) before you start.

### Attachments and voice notes

Upload the files first, then reference their URL in `versions[].attachments` of an `EMAIL`, `MESSAGE` or `WHATSAPP` step. Uploaded files get a public URL: anyone with the link can download them.

[`POST /advanced/attachments`](https://docs.emelia.io/docs/campaigns/upload-message-attachments.md)

**curl**

```bash
curl -X POST https://api.emelia.io/advanced/attachments \
  -H "Authorization: $EMELIA_API_KEY" \
  -F "attachments=@./brochure.pdf"
```

**JavaScript**

```js
import { readFile } from 'node:fs/promises';

const form = new FormData();
form.append('attachments', new Blob([await readFile('brochure.pdf')]), 'brochure.pdf');
const res = await fetch('https://api.emelia.io/advanced/attachments', {
  method: 'POST',
  headers: { Authorization: process.env.EMELIA_API_KEY },
  body: form,
});
const { attachments } = await res.json(); // [{ url, name }]
```

**Python**

```python
with open("brochure.pdf", "rb") as f:
    res = session.post(
        "https://api.emelia.io/advanced/attachments",
        files={"attachments": ("brochure.pdf", f)},
    )
attachments = res.json()["attachments"]  # [{"url": ..., "name": ...}]
```

Up to 3 files of 20 MB per call. A LinkedIn voice note goes through its own route (webm, 10 MB and 5 minutes at most), which returns its `url` and its `duration` in milliseconds, to attach with `"mediaType": "AUDIO"`:

[`POST /advanced/linkedin/audio`](https://docs.emelia.io/docs/campaigns/upload-linkedin-voice-note.md)

The voice note itself is a `MESSAGE` step, not a step type of its own: put the recording as the attachment of its first version, with the `name`, `url` and `duration` the upload returned. `duration` stays in milliseconds (`32000` is 32 seconds).

```json title="Voice note step"
{
  "stepType": "MESSAGE",
  "delay": { "amount": 2, "unit": "DAYS" },
  "versions": [
    {
      "message": "",
      "attachments": [
        { "name": "voice-note.m4a", "url": "https://...", "mediaType": "AUDIO", "duration": 32000 }
      ]
    }
  ]
}
```

A `MESSAGE` step whose first attachment has no `url` blocks the start with `LINKEDIN_AUDIO_MISSING`.

## 3. Set the schedule

[`PATCH /advanced/campaigns/{campaignId}/settings`](https://docs.emelia.io/docs/campaigns/update-campaign-settings.md)

**curl**

```bash
curl -X PATCH "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/settings" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "timeZone": "Europe/Paris",
      "days": [0, 1, 2, 3, 4],
      "start": "09:00",
      "end": "17:30",
      "dailyEmailAdded": 20,
      "dailyEmailLimit": 60,
      "trackOpens": false,
      "trackLinks": false,
      "blacklistUnsub": true,
      "ignoreAutoReplies": true
    }
  }'
```

**JavaScript**

```js
await emelia('PATCH', `/advanced/campaigns/${campaignId}/settings`, {
  settings: {
    timeZone: 'Europe/Paris',
    days: [0, 1, 2, 3, 4],
    start: '09:00',
    end: '17:30',
    dailyEmailAdded: 20,
    dailyEmailLimit: 60,
    trackOpens: false,
    trackLinks: false,
    blacklistUnsub: true,
    ignoreAutoReplies: true,
  },
});
```

**Python**

```python
emelia("PATCH", f"/advanced/campaigns/{campaign_id}/settings", {
    "settings": {
        "timeZone": "Europe/Paris",
        "days": [0, 1, 2, 3, 4],
        "start": "09:00",
        "end": "17:30",
        "dailyEmailAdded": 20,
        "dailyEmailLimit": 60,
        "trackOpens": False,
        "trackLinks": False,
        "blacklistUnsub": True,
        "ignoreAutoReplies": True,
    },
})
```

The fields you send in `settings` are merged into the current settings. Always send `start`, `end`, `trackOpens`, `trackLinks` and `blacklistUnsub`: the call fails without them. The simplest is to read the campaign, change its `schedule` and send it back.

| Setting                            | What it does                                                                                       |
| ---------------------------------- | -------------------------------------------------------------------------------------------------- |
| `dailyEmailAdded`                  | New contacts entering the email part per day, 1 to 500.                                            |
| `dailyEmailLimit`                  | Emails sent per day, follow ups included, 1 to 500, and at least `dailyEmailAdded`.                |
| `dailyLinkedinAdded`               | New contacts per day on LinkedIn, 1 to 80, 40 at most when the sequence sends invitations.         |
| `dailyWhatsappAdded`               | New contacts per day on WhatsApp, 1 to 30.                                                         |
| `days`, `start`, `end`, `timeZone` | When the campaign acts: days from 0 (Monday) to 6 (Sunday), hours as `HH:mm`, an IANA time zone.   |
| `blacklistUnsub`                   | A contact who unsubscribes is added to your [blacklist](https://docs.emelia.io/docs/blacklist.md).                          |
| `ignoreAutoReplies`                | Out of office answers do not count as replies.                                                     |
| `eventToStop`                      | The events that end the sequence for a contact. Bounced and unsubscribed contacts always stop.     |
| `bcc`                              | An address copied on every email, for example your CRM dropbox. Empty string to disable.           |
| `alwaysOn`                         | The campaign keeps running when every contact is done, so that contacts added later are processed. |

## 4. Choose the senders

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

An **identity** is a sender: a name, plus an email provider, a LinkedIn account, a WhatsApp account, or several of them. For each step, Emelia picks the least used identity that has an account on the channel.

Get the ids of your mailboxes with [List email providers](https://docs.emelia.io/docs/email-providers/list-email-providers.md) (`_id`, and skip the ones marked `disconnected`). LinkedIn and WhatsApp accounts are connected in the Emelia app; there is no documented route to list them yet: set them in the app, or reuse the ids found in the `identities` of an existing campaign (`GET /advanced/campaigns`).

**curl**

```bash
curl -X PATCH "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/identities" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"identities": [{"name": "Marie Martin", "email": "6650f1a2b839d80011c5a0aa", "linkedin": "6650f1a2b839d80011c5a0bb"}]}'
```

**JavaScript**

```js
await emelia('PATCH', `/advanced/campaigns/${campaignId}/identities`, {
  identities: [{ name: 'Marie Martin', email: '6650f1a2b839d80011c5a0aa', linkedin: '6650f1a2b839d80011c5a0bb' }],
});
```

**Python**

```python
emelia("PATCH", f"/advanced/campaigns/{campaign_id}/identities", {
    "identities": [{"name": "Marie Martin", "email": "6650f1a2b839d80011c5a0aa", "linkedin": "6650f1a2b839d80011c5a0bb"}],
})
```

The array replaces the identities of the campaign. With an email step, every identity needs an email provider. With a LinkedIn step, use one LinkedIn account shared by all identities, or one per identity: otherwise the start fails with `LINKEDIN_ACCOUNT_AMBIGUOUS`. On an agency account, attach only the mailboxes of this client.

## 5. Attach the contacts

Contacts live in [lists](https://docs.emelia.io/docs/lists.md). Attach one or several lists, and optionally lists to exclude:

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

**curl**

```bash
curl -X PATCH "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/recipients" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"lists": ["66f1c0ffee5a7d3b2c4e8a10"]}'
```

**JavaScript**

```js
await emelia('PATCH', `/advanced/campaigns/${campaignId}/recipients`, {
  lists: ['66f1c0ffee5a7d3b2c4e8a10'],
});
```

**Python**

```python
emelia("PATCH", f"/advanced/campaigns/{campaign_id}/recipients", {
    "lists": ["66f1c0ffee5a7d3b2c4e8a10"],
})
```

`lists` replaces the whole selection. A contact whose email or LinkedIn URL is in one of the `excludedLists` is left out. This call works on a running campaign, and every contact added to an attached list later joins the campaign on its own. A campaign starts with 30,000 recipients at most.

To add one contact straight to a campaign, without managing a list, use [Add a contact to a campaign](https://docs.emelia.io/docs/campaigns/add-contact-to-campaign.md). Emelia stores it in a list named "\<campaign name> contacts from API". A running campaign schedules it at once, and a finished campaign starts again on its own. Only `email`, `linkedinUrlProfile`, `phone`, `firstName` and `lastName` are native fields there; every other key becomes a custom variable, and every value must be a string.

[`POST /advanced/campaign/contacts`](https://docs.emelia.io/docs/campaigns/add-contact-to-campaign.md)

## 6. Send yourself a test

[`POST /advanced/campaigns/{campaignId}/test-email`](https://docs.emelia.io/docs/campaigns/send-test-email.md)

Take the `_id` of the email step and of its version from [Get a campaign](https://docs.emelia.io/docs/campaigns/get-campaign.md), after step 2. The test uses the variables of a random contact of the campaign.

**curl**

```bash
curl -X POST "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/test-email" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"stepId": "6650f1a2b839d80011c5c001", "versionId": "6650f1a2b839d80011c5c002", "email": "you@yourcompany.com"}'
```

**JavaScript**

```js
await emelia('POST', `/advanced/campaigns/${campaignId}/test-email`, {
  stepId: '6650f1a2b839d80011c5c001',
  versionId: '6650f1a2b839d80011c5c002',
  email: 'you@yourcompany.com',
});
```

**Python**

```python
emelia("POST", f"/advanced/campaigns/{campaign_id}/test-email", {
    "stepId": "6650f1a2b839d80011c5c001",
    "versionId": "6650f1a2b839d80011c5c002",
    "email": "you@yourcompany.com",
})
```

## 7. Start

[`POST /advanced/campaigns/{campaignId}/start`](https://docs.emelia.io/docs/campaigns/start-campaign.md)

**curl**

```bash
curl -X POST "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/start" \
  -H "Authorization: $EMELIA_API_KEY"
```

**JavaScript**

```js
await emelia('POST', `/advanced/campaigns/${campaignId}/start`);
```

**Python**

```python
emelia("POST", f"/advanced/campaigns/{campaign_id}/start")
```

The start needs an active subscription and runs every check at once. The first that fails is returned with a stable [error code](https://docs.emelia.io/docs/errors.md#starting-a-campaign): `NO_IDENTITY_SET`, `EMAIL_VERSION_NO_SUBJECT`, `NO_RECIPIENTS`, `RECIPIENTS_NO_EMAIL`... Contacts are scheduled in the background after the response.

To start later, set a date with [Schedule a planned start](https://docs.emelia.io/docs/campaigns/schedule-planned-start.md) (`{"plannedStart": "2026-10-20T08:00:00.000Z"}`, at least in the future), and send `null` to cancel it. The same checks run when you schedule.

## Lifecycle

| Status     | How you get there                                                                                                 | What you can change               |
| ---------- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| `DRAFT`    | Create or duplicate                                                                                               | Everything                        |
| `RUNNING`  | [Start](https://docs.emelia.io/docs/campaigns/start-campaign.md), or a planned start                                                       | Only the attached lists           |
| `PAUSED`   | [Pause](https://docs.emelia.io/docs/campaigns/pause-campaign.md), or [Reactivate](https://docs.emelia.io/docs/campaigns/reactivate-campaign.md) a finished campaign | Everything, then start again      |
| `FINISHED` | Every contact is done, or [Mark as finished](https://docs.emelia.io/docs/campaigns/mark-campaign-as-finished.md)                           | Reactivate it first               |
| `ARCHIVED` | [Archive](https://docs.emelia.io/docs/campaigns/archive-campaign.md), on a campaign that is not running                                    | Hidden from the list of campaigns |

Pausing deletes the scheduled actions, the pending conditions and the pending manual tasks: they are created again when the campaign restarts and contacts reach those steps. [Delete a campaign](https://docs.emelia.io/docs/campaigns/delete-campaign.md) is irreversible; the lists and contacts stay.

### Edit a campaign that already sent

1. Pause it: `POST /advanced/campaigns/{campaignId}/pause`.
2. Read it: `GET /advanced/campaigns/{campaignId}`. The tree is in `campaign.steps`.
3. Change what you need **in the tree you read**, and keep the `_id` of every existing step and version. History, statistics per version and the position of each contact in the sequence are attached to those ids: new ids make contacts receive steps again.
4. Add new steps without `_id`.
5. Send the whole tree back, then start the campaign again.

## Follow the campaign

| Need                                                           | Route                                                                                                                                     |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Status, sequence, settings, lists                              | [Get a campaign](https://docs.emelia.io/docs/campaigns/get-campaign.md)                                                                                            |
| Sent, opened, clicked, replied, bounced, globally and per step | [Get campaign statistics](https://docs.emelia.io/docs/campaigns/get-campaign-statistics.md)                                                                        |
| How many contacts passed or wait at each step                  | [Get per step progress](https://docs.emelia.io/docs/campaigns/get-per-step-progress.md), then [List the contacts of a step](https://docs.emelia.io/docs/campaigns/list-contacts-of-step.md) |
| The feed of events and the replies                             | [Replies](https://docs.emelia.io/docs/replies.md)                                                                                                                  |
| Everything in a CSV (`contacts`, `activities` or `replies`)    | [Export as CSV](https://docs.emelia.io/docs/campaigns/export-as-csv.md)                                                                                            |
| Real time events                                               | [Webhooks](https://docs.emelia.io/docs/webhooks.md)                                                                                                                |

The statistics are computed on demand and cached. An unknown campaign answers `401` there, not `400`: check the id before you check the key.

### Manual tasks

A `TASK` step waits for a human, for example a phone call. List what is waiting, then close each task with `ok` (done, event `TASK_COMPLETED`) or `ko` (failed, event `TASK_FAILED`). A condition on `TASK_COMPLETED` can route each case.

[`GET /advanced/campaigns/{campaignId}/manual-tasks`](https://docs.emelia.io/docs/campaigns/list-pending-manual-tasks.md)

[`POST /advanced/campaigns/{campaignId}/manual-tasks/status`](https://docs.emelia.io/docs/campaigns/complete-manual-task.md)

**curl**

```bash
curl -X POST "https://api.emelia.io/advanced/campaigns/$CAMPAIGN_ID/manual-tasks/status" \
  -H "Authorization: $EMELIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contactId": "6650f1a2b839d80011c5d001", "taskId": "6650f1a2b839d80011c5c010", "status": "ok"}'
```

**JavaScript**

```js
await emelia('POST', `/advanced/campaigns/${campaignId}/manual-tasks/status`, {
  contactId: '6650f1a2b839d80011c5d001',
  taskId: '6650f1a2b839d80011c5c010',
  status: 'ok',
});
```

**Python**

```python
emelia("POST", f"/advanced/campaigns/{campaign_id}/manual-tasks/status", {
    "contactId": "6650f1a2b839d80011c5d001",
    "taskId": "6650f1a2b839d80011c5c010",
    "status": "ok",
})
```

`taskId` is the `_id` of the `TASK` step. A task can be completed once.

## Legacy campaigns

The `/emails/...` and `/linkedin/...` campaign routes belong to the previous campaign system, where a campaign was email only or LinkedIn only. They still work and are documented under [Legacy](https://docs.emelia.io/docs/campaigns/legacy.md), but every new integration should use the routes on this page.
