Campaigns

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

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
  2. Write the sequence
  3. Set the schedule
  4. Choose the senders
  5. Attach the contacts
  6. Send yourself a test
  7. Start it

The examples use this setup:

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

1. Create the campaign

POST/advanced/campaigns
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"}

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:

SettingDefault
timeZoneEurope/Brussels, unless you send another IANA zone
daysMonday to Friday, [0, 1, 2, 3, 4] (0 is Monday, 6 is Sunday)
start, end08:00 to 17:00
dailyEmailAdded35 new contacts per day in the email part
dailyEmailLimit100 emails per day
dailyLinkedinAdded, dailyWhatsappAdded20 new contacts per day
trackOpens, trackLinksoff
eventToStopa 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

A cold email sequence

Three emails, three then five days apart:

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

{
  "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.

{
  "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:

# 24 lowercase hexadecimal characters
openssl rand -hex 12

Anatomy of a step

FieldDescription
_idLeave it out on new steps. Keep it on steps that already ran, see Editing a running campaign.
stepTypeWhat the step does, see below.
delayWait 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.
versionsThe content of the step. Give every action step at least one version, even VISIT and LIKE, which take an empty one: "versions": [{}].
nextThe following step. Absent on the last one.
conditions, yes, noOnly on CONDITION steps. A missing branch ends the sequence for the contacts that fall into it.
identitiesLegacy field, not read when sending: send [] or leave it out. Senders are set on the campaign.
stepTypeChannelWhat it does
STARTThe root. Always "_id": "START".
EMAILEmailSends an email: versions[].subject and versions[].message (HTML).
CONNECTIONLinkedInSends an invitation, with an optional note in versions[].message.
MESSAGELinkedInSends a message to a contact you are connected with, or a voice note, see Attachments and voice notes.
INMAILLinkedInSends an InMail, with a subject and a message.
VISITLinkedInVisits the profile. "versions": [{}].
LIKELinkedInLikes a recent post of the contact. "versions": [{}].
WHATSAPPWhatsAppSends a WhatsApp message (plain text).
TASKManualCreates a task for a human, see Manual tasks. The label is versions[].subject.
CONDITIONBranches on an event or a field, see below.
END_OF_CAMPAIGNEnds 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.

Conditions

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

FormExamplesoperator and value
An event, in capitalsACCEPTED, MAIL_REPLIED, LINKEDIN_REPLIED, OPENED, CLICKED, UNSUBSCRIBED, BOUNCED, TASK_COMPLETEDEQUAL and "true"
A contact field, with a $$email, $jobTitle, $icebreakerEQUAL, 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
curl -X POST https://api.emelia.io/advanced/attachments \
  -H "Authorization: $EMELIA_API_KEY" \
  -F "attachments=@./brochure.pdf"

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

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

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

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.

SettingWhat it does
dailyEmailAddedNew contacts entering the email part per day, 1 to 500.
dailyEmailLimitEmails sent per day, follow ups included, 1 to 500, and at least dailyEmailAdded.
dailyLinkedinAddedNew contacts per day on LinkedIn, 1 to 80, 40 at most when the sequence sends invitations.
dailyWhatsappAddedNew contacts per day on WhatsApp, 1 to 30.
days, start, end, timeZoneWhen the campaign acts: days from 0 (Monday) to 6 (Sunday), hours as HH:mm, an IANA time zone.
blacklistUnsubA contact who unsubscribes is added to your blacklist.
ignoreAutoRepliesOut of office answers do not count as replies.
eventToStopThe events that end the sequence for a contact. Bounced and unsubscribed contacts always stop.
bccAn address copied on every email, for example your CRM dropbox. Empty string to disable.
alwaysOnThe campaign keeps running when every contact is done, so that contacts added later are processed.

4. Choose the senders

PATCH/advanced/campaigns/{campaignId}/identities

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

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. Attach one or several lists, and optionally lists to exclude:

PATCH/advanced/campaigns/{campaignId}/recipients
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"]}'

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

6. Send yourself a test

POST/advanced/campaigns/{campaignId}/test-email

Take the _id of the email step and of its version from Get a campaign, after step 2. The test uses the variables of a random contact of the campaign.

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

7. Start

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

The start needs an active subscription and runs every check at once. The first that fails is returned with a stable error code: 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 ({"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

StatusHow you get thereWhat you can change
DRAFTCreate or duplicateEverything
RUNNINGStart, or a planned startOnly the attached lists
PAUSEDPause, or Reactivate a finished campaignEverything, then start again
FINISHEDEvery contact is done, or Mark as finishedReactivate it first
ARCHIVEDArchive, on a campaign that is not runningHidden 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 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

NeedRoute
Status, sequence, settings, listsGet a campaign
Sent, opened, clicked, replied, bounced, globally and per stepGet campaign statistics
How many contacts passed or wait at each stepGet per step progress, then List the contacts of a step
The feed of events and the repliesReplies
Everything in a CSV (contacts, activities or replies)Export as CSV
Real time eventsWebhooks

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 POST/advanced/campaigns/{campaignId}/manual-tasks/status
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"}'

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, but every new integration should use the routes on this page.

On this page