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:
- Create the campaign
- Write the sequence
- Set the schedule
- Choose the senders
- Attach the contacts
- Send yourself a test
- 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:
| 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.
/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{
"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
versionson anEMAILstep make an A/B test: each contact gets one of them.disabled: truetakes a version out of the rotation. ccon anEMAILstep 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
CONNECTIONsends the invitation without a message. - A
MESSAGEstep placed right after aCONNECTIONwaits for the invitation to be accepted, for up to a year. - On
MESSAGEandWHATSAPPsteps, the versions are successive messages sent in order, not an A/B test. dailyLinkedinAddedis 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 12Anatomy of a step
| Field | Description |
|---|---|
_id | Leave it out on new steps. Keep it on steps that already ran, see Editing a running campaign. |
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 | Sends an email: versions[].subject and versions[].message (HTML). | |
CONNECTION | Sends an invitation, with an optional note in versions[].message. | |
MESSAGE | Sends a message to a contact you are connected with, or a voice note, see Attachments and voice notes. | |
INMAIL | Sends an InMail, with a subject and a message. | |
VISIT | Visits the profile. "versions": [{}]. | |
LIKE | Likes a recent post of the contact. "versions": [{}]. | |
WHATSAPP | Sends a WhatsApp message (plain text). | |
TASK | Manual | Creates a task for a human, see 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.
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
delayof 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 downno. A field condition is evaluated at once. - Only the operators above are evaluated. Any other value, for example
NOT_EQUALorGREATER_THAN, is saved without error and always evaluates to false: everybody goes downno. {{email}}is not a valid field: write$email.OPENEDandCLICKEDneed tracking (trackOpens,trackLinks). Without it the event is never recorded and everybody goes downno.- 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
companyCountryandcompanyCityfor 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.
/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":
/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).
{
"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.
| 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. |
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
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.
/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
| Status | How you get there | What you can change |
|---|---|---|
DRAFT | Create or duplicate | Everything |
RUNNING | Start, or a planned start | Only the attached lists |
PAUSED | Pause, or Reactivate a finished campaign | Everything, then start again |
FINISHED | Every contact is done, or Mark as finished | Reactivate it first |
ARCHIVED | Archive, 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 is irreversible; the lists and contacts stay.
Edit a campaign that already sent
- Pause it:
POST /advanced/campaigns/{campaignId}/pause. - Read it:
GET /advanced/campaigns/{campaignId}. The tree is incampaign.steps. - Change what you need in the tree you read, and keep the
_idof 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. - Add new steps without
_id. - Send the whole tree back, then start the campaign again.
Follow the campaign
| Need | Route |
|---|---|
| Status, sequence, settings, lists | Get a campaign |
| Sent, opened, clicked, replied, bounced, globally and per step | Get campaign statistics |
| How many contacts passed or wait at each step | Get per step progress, then List the contacts of a step |
| The feed of events and the replies | Replies |
Everything in a CSV (contacts, activities or replies) | Export as CSV |
| Real time events | Webhooks |
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.
/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.