Campaigns and sequences
Build automated LinkedIn and email outreach campaigns with the Swarmhit API, including multi-step sequences, branching, schedules, and lead progress.
A campaign is an automated outreach program: a pool of sender accounts (linkedinAccountIds) and, for email steps, of mailboxes (emailAccountIds), a sequence of steps to run on each lead, a send schedule, exclusion rules, and the leads enrolled in it. You create the campaign, add leads, set it active, and the executor works each lead through the sequence inside the schedule window.
Campaign endpoints live under the Campaigns and Leads in Campaigns reference sections. This guide covers the concepts; the reference covers every field.
Create a campaign
createCampaign accepts everything up front: name, sender pool (linkedinAccountIds, plus emailAccountIds when the sequence has email steps), sequence, schedule, aiInterestEnabled (labels; off unless you pass true), the email unsubscribe line, tracking and matchEmailProvider (provider matching), exclusion rules (excludeListIds, excludeOtherCampaigns, excludeOtherSenders, excludeSameSenderOtherCampaigns), the company rules (companyLimit, pauseCompanyOnReply), the out-of-office wait (outOfOffice), and an initial status. Every id in linkedinAccountIds, emailAccountIds and excludeListIds must belong to the workspace, or the request is rejected with 400. Anything omitted falls back to a default: empty sequence, 09:00-19:00 Mon-Fri schedule in Etc/GMT, and paused status. Creating a campaign as active runs the same checks as activating it (see Campaign lifecycle).
curl -X POST https://app.swarmhit.com/api/v1/campaigns \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Q3 Founders",
"linkedinAccountIds": ["aCc123", "aCc456"],
"schedule": {
"timezone": "America/New_York",
"activeHours": { "from": "09:00", "to": "17:00" },
"activeDays": [1, 2, 3, 4, 5]
},
"sequence": {
"steps": [
{ "type": "visit_profile", "nextDelayHours": 24 },
{
"type": "invite",
"config": {
"note": "Hi {{firstName}}, came across your profile...",
"fallbackNote": "Hi there, came across your profile...",
"withdrawEnabled": true,
"withdrawDays": 25
},
"branchDelays": { "no": 336 }
},
{ "type": "message", "config": { "text": "Thanks for connecting, {{firstName}}!", "fallbackText": "Thanks for connecting!" } }
]
}
}'Then add leads with addLeadsToCampaign and set status to active with updateCampaign.
The sequence
A sequence is a tree of steps. Each lead walks the tree from the root, one step at a time.
Step types
| Type | What it does |
|---|---|
visit_profile | Views the lead's LinkedIn profile. |
follow_profile | Follows the lead. |
like_post | Likes the lead's most recent post. Takes no config. |
comment_post | Writes an AI comment on the lead's latest post from your config.prompt (supports {{variables}}, up to 10,000 characters). maxPostAgeDays (1-365, default 30) skips the step when that latest post is older than that many days, so the comment never lands on a stale post. With config.requireApproval: true each draft waits in the approvals queue for a human to edit or approve before it posts; a draft that is rejected or not approved within 7 days is skipped. Writing the comment uses the workspace's credits: while it is out of credits, leads wait on the step. |
invite | Sends a connection request with an optional note (up to 300 characters; invites from free LinkedIn accounts go out without it, see Notes on free accounts). Branches: "yes" = Accepted, "no" = still not accepted. withdrawEnabled (on by default) plus withdrawDays (1-365, default 25) auto-withdraws an unanswered request after that many days; withdrawing never moves the lead along the sequence. |
message | Sends a direct message (text, up to 8,000 characters). A reply ends the sequence: the "yes" (Replied) branch is terminal, so the follow-up continues on the "no" (still no reply) branch. |
voice_note | Sends a recorded voice note: config.voice ({ url, mimeType, durationMs }, an m4a/AAC file served from a public URL), or one recorded in the campaign builder. The recording is the whole message, so every variant needs one before activation. It supports one recording for the whole sender pool or a separate recording per sender (see below). |
ai_voice_note | Speaks a personalized script (up to 800 characters, about a minute of audio) in an AI or cloned voice. Generating the audio uses the workspace's credits: while it is out of credits, leads wait on the step. It supports one voice for the whole sender pool or explicit voice assignments by sender (see below). With config.requireApproval: true each generated note waits in the approvals queue for a human to listen to it and approve before it is sent; a note that is rejected or not approved within 7 days is not sent, and the lead continues on the "no" branch. |
inmail | Sends an InMail (subject up to 200 and text up to 1,900 characters, with fallbackSubject / fallbackText). Reply semantics match message. config.whenUnable decides what happens when the InMail cannot be sent (see below). |
if_connected | Condition: "yes" branch when the lead is already connected, "no" otherwise. |
if_open_profile | Condition: branch on whether the lead is an open profile. |
email | Sends an email (subject up to 200 and text up to 5,000 characters, with fallbackSubject / fallbackText for leads missing a variable) from one of the campaign's mailboxes (emailAccountIds). Reply semantics match message. A lead with no usable email address stops here (route it with if_has_email). config.sameThread (default true) sends it as a reply under the campaign's first email to that lead, with that email's subject prefixed "Re:"; the step's own subject is used when there is no earlier email. The first email of a path needs a subject. A follow-up (an email step with another email step above it on its path) may leave subject empty: it then goes out as that reply whatever sameThread says, and a lead who never got an earlier email gets it under the subject of the email above it. |
if_has_email | Condition: "yes" when the lead has a usable email address (present, not bounced, not reported as spam, not opted out), "no" otherwise. Needs no sender, so a mixed sequence can start on it. |
if_has_linkedin | Condition: "yes" when the lead has a LinkedIn profile, "no" for a lead added by email alone. Needs no sender. Use it to route email-only leads before any LinkedIn step. |
if_email_opened | Waits on the last email sent to the lead: "yes" at its first open (a click counts as one), "no" once the "no" branch delay (default 72 hours, minimum 3) has passed since the lead reached the step. Must sit below an email step. Needs no sender. Opens are approximate: some mail apps load images on their own, others block them. |
if_email_clicked | The same for a click on a link in the last email (a click on the unsubscribe link does not count). An email with no link can only take "no". |
call | Creates a call task in the app's Tasks for your team, with the lead's details (the phone number once revealed), the step's config.instructions (talking points; supports {{variables}}, up to 2,000 characters) and the last message exchanged. The lead waits until someone logs the call (reached, voicemail or no answer, with an optional note) or skips it, then moves on; after config.waitDays (1-30, default 3; values outside are clamped) without either, the task is skipped and the lead moves on. Stopping the sequence from the task stops the lead. A call sends nothing, needs no sender of its own and uses no sender quota or credits; it can appear several times, in LinkedIn, email or mixed sequences, and a lead added by email alone gets its task too. Call tasks are worked in the app's Tasks or with the call tasks endpoints, and call_task.pending fires when one is made. |
if_has_phone | Condition: "yes" when the lead has a phone number (phone on the lead, revealed from the database or found by a lookup), "no" otherwise. Needs no sender and no email channel; use it to send only leads with a number to a call step. |
find_email | Looks up the lead's work email (from its LinkedIn profile, or its name and company) and saves it on the lead, then moves on whether or not one was found; the lead waits a few minutes on the lookup. A lead that already has an email moves on at once. Needs no sender. Charged at the Email finder price when an email is found, free otherwise (see credit prices). Route on the result with if_has_email. |
find_phone | The same for a mobile phone number, looked up from the lead's LinkedIn profile, at the Phone finder price when one is found. Route on the result with if_has_phone. |
Text longer than its field's limit is cut to it when the sequence is saved; a fallback shares the limit of the field it stands in for. email, if_has_email, if_has_linkedin, if_email_opened and if_email_clicked need the email channel enabled on the workspace: without it, saving a sequence that adds one returns 400.
Email steps need a mailbox pool: set emailAccountIds to one or more mailboxes from GET /email-accounts. A lead is pinned to one mailbox by its first email and keeps it for the whole thread, the way it is pinned to a LinkedIn sender. A mailbox bought through Swarmhit is held out of campaigns while it warms up: until its warmup hold ends it sends no campaign email and takes no new leads, and leads already assigned to it wait (see Mailboxes).
Emails go out in plain text with light formatting, paced a few minutes apart per mailbox and capped by the mailbox's daily limit and by the workspace's email allowance for the billing period (workspace.emails on GET /): once its remaining reaches zero, email steps wait. A lead whose address bounced, was reported as spam or opted out (emailStatus and emailOptOut on the lead) stops at the email step (see below), and a bounce, spam report or failed delivery on the email a lead is still waiting on marks that membership failed. A reply on either channel ends the sequence; an automatic reply such as an out-of-office does not. See Email deliverability before the first send.
Open and click steps make the campaign track its own emails, whatever the campaign's tracking or the mailbox settings say: opens for if_email_opened, links for if_email_clicked. They look at the last email sent to the lead and take "yes" as soon as it was opened or clicked, even if that happened before the lead reached the step (the "yes" branch delay then counts from the open or click). A lead whose last email went out untracked, for example because the step was added to a campaign that was already sending, stops there (stopped, with result.stopped: "email_not_tracked" on its last history entry). Opens and clicks in the first two minutes after a send are ignored, because mail security scanners make them. Each first open and click shows in the lead's activity, and in the email.opened and email.clicked webhooks.
A step never answers a question it could not ask. A lead added by email alone (no LinkedIn profile, see Leads) passes the LinkedIn actions that decide nothing (visit_profile, follow_profile, like_post, comment_post), and stops at any other LinkedIn step it reaches (invite, message, inmail, the voice notes, if_connected, if_open_profile) instead of taking its "no" branch. Likewise, a lead with no usable email address (none, bounced, reported spam, or opted out) stops at an email step instead of taking "no reply". A stopped lead ends with status stopped, nothing is charged, and its last history entry carries result.stopped: no_linkedin_profile or no_email. Route such leads explicitly: if_has_linkedin ("yes" when the lead has a LinkedIn profile, needs no sender) and if_has_email send each lead down the path it can take, for example if_has_linkedin → yes: invite and messages, no: emails.
Finding emails and phones. find_email and find_phone run the same lookups as lead enrichment: a value found is saved on the lead, so every campaign it is in sees it, and each finished lookup fires lead.enriched. A typical path puts find_email before its first email step, then if_has_email to route the leads with none found. A bounced or opted-out address is never replaced.
InMail whenUnable (auto | pause | skip, default auto, invalid values coerced to auto) applies when the sender cannot send the InMail: it has no premium LinkedIn plan (free senders cannot InMail anyone, open profiles included), or it is out of InMail credits and cannot InMail this lead for free (only Sales Navigator and Recruiter senders InMail open profiles free, within a monthly allowance). auto pauses the sender when it is out of credits and otherwise continues to the still-no-reply branch, pause always pauses the sender, skip always continues.
Notes on free LinkedIn accounts
A free LinkedIn account can't add a note to its connection requests, so its invites go out without the step's note. An account with a monthly note allowance does the same once the allowance is used up. A Premium account's invites keep their note. Each membership's invitationSentWithoutNote says when the invite went without it, and getLeadInCampaign's history says why: its invite_sent entry carries result.noteDroppedReason, which is free_account, allowance_used, or too_long (LinkedIn found the note too long).
With the invite step's noteAsMessage: true (off by default), the note goes as a message once the person accepts, to the leads of a free account or a used-up allowance only:
- On the run that sees the acceptance, within the campaign's sending hours and never while the campaign is paused (a paused campaign's accepted leads get it on the first run after it resumes), counted against the account's daily message limit like a message step.
- It's the note the invite was sent with, filled in for the lead (or its fallback), once, and it shows as an
invite_note_sententry in the lead'shistoryand in the inbox thread. - The accepted branch's delay then counts from that message, and a LinkedIn message step right after it waits at least 24 hours, so the person never gets two messages back to back.
- A lead who replies before it goes ends the sequence as usual. A note too long for an invite isn't sent. When the account sends no messages (its limit is 0), or LinkedIn refuses the message for good or keeps failing, the note is left out and the sequence goes on.
Variables and fallbacks
Message bodies support {{variables}}: the lead's {{firstName}}, {{lastName}}, {{company}}, {{headline}}, {{profileUrl}} and {{email}}, plus any of its customVariables (see Leads and lists). They can also name the sender: {{senderFirstName}}, {{senderLastName}} and {{senderFullName}} read the LinkedIn account sending the step or, for an email, the mailbox's From name (its first word is the first name, the rest the last name). Whenever a body uses a required variable, its fallback must be set: fallbackNote on invites, fallbackText on messages, InMails and emails, fallbackSubject for InMail and email subjects, and fallbackScript on AI voice notes. The fallback is sent verbatim to any lead missing the value. This is enforced when the campaign is activated, and on every sequence save once it has been activated; a campaign that has never been active may leave fallbacks blank and fill them in before launching.
A ? after the name makes a variable optional. {{ps?}} comes out empty for a lead without the value instead of sending the fallback, and never makes the fallback required. A line holding only optional variables that all come out empty is left out, together with one of the blank lines around it, so the paragraphs close up. With a ps custom variable that only some leads have:
Hi {{firstName}},
Thanks for your time today.
{{ps?}}
Best,
{{senderFirstName}}A lead without ps gets the message without the P.S. paragraph, while one without a first name still gets the fallback. Spaces inside the braces are allowed ({{ ps ? }}).
A {{name}} is matched exactly first: one of the fields above, then the custom variable with that key. After those come the fields and custom variables whose names only differ in case, accents or separators, so {{FirstName}} reads firstName and {{Plan_Tier}} reads plantier or planTier (fields first, then custom keys in code-point order). The first of these that has a value fills the variable in, so an empty field never hides a filled custom variable of the same name; only text, numbers and booleans count as values. The sender's name comes last, so a lead's own senderFirstName custom variable wins over it; a sender with no name counts as a missing value.
In an email step's text and fallbackText, {{unsubscribeLink}} places the campaign's unsubscribe line (unsubscribe on the campaign, see Opting out), and {{signature}} places the mailbox's signature where it is written instead of under the message, for example above a P.S. Both are filled in by the send, so they never make a fallback required, and they are the variables a fallback fills in. A mailbox without a signature leaves nothing in its place, and a line that held only {{signature}} closes up. Only those exact spellings count: {{unsubscribe_link}} is an ordinary variable, and so is {{signature}} in a subject or a LinkedIn step.
A/B variants
Message-bearing steps (invite, message, voice_note, ai_voice_note, inmail, email) accept config.variants: up to 5 message variations (extra ones are dropped) rotated evenly across leads. Each variant carries the step's message fields. A voice_note variant carries its recording; an ai_voice_note variant carries script and fallbackScript; message and inmail variants may also carry attachments (up to 5 files served from a public URL). Neither takes a voice note: a voice sent on a message or inmail step is dropped, so send voice notes with a voice_note or ai_voice_note step. The flat keys always mirror variant A, so single-variant steps look unchanged. A voice_note that records per sender does not rotate variants: its recordings vary by who is speaking, not by what is being tested.
Updating one step or variant
For a copy or media change to an existing step, use updateCampaignStep. First read the campaign with getCampaign, then use the stable step id from its sequence. Omit variantId to update variant A, or pass the id of another existing variant to update only that variant. The endpoint preserves the step's wiring, delays, and every other variant.
curl -X PATCH https://app.swarmhit.com/api/v1/campaigns/cMp456/sequence/message-step-2 \
-H "Authorization: Bearer swh_live_..." \
-H "Content-Type: application/json" \
-d '{
"variantId": "b",
"text": "{{firstName}}, does this production gap sound familiar?"
}'The accepted fields depend on the step type: text/fallbackText for messages; note/fallbackNote for invites; subject/fallbackSubject plus message fields for InMail and email; script/fallbackScript for AI voice notes; prompt for an AI post comment; and instructions for a call. Message and InMail variants also accept attachments, but not voice (it returns 400), and recorded voice-note variants accept voice. A recorded voice note that records per sender is rejected here: replace its recordings through updateCampaign instead. An unknown step or variant id returns 400, and once the campaign has been activated the sequence must still pass the activation checks after the edit (a fallback for every variable, for example). The response contains the full campaign with the persisted sequence, so clients can verify the saved value immediately.
You can make the same change through updateCampaign, but that endpoint replaces the complete sequence: send every step and its wiring back. A campaign read exposes variant A in both the legacy flat fields (config.text, config.note, and so on) and config.variants[0]. When changing variant A through a full-sequence update, update both copies to the same value; conflicting copy or media values return 400. To change variant B or later, edit only that variant and leave variant A's flat mirrors unchanged. Omitting variants and supplying flat content intentionally collapses the step to a single variant.
Voices and recordings by sender
An ai_voice_note step takes script and fallbackScript (up to 800 characters each; longer text is cut), voiceId (default Enceladus), voiceProvider (derived from voiceId), an optional speaking style (up to 300 characters; voices whose provider is elevenlabs, the studio and cloned ones, ignore it), and requireApproval. The rendered script goes to the voice as written: Swarmhit has no markup for pauses or tone, so steer the delivery with style. List available library and workspace-cloned voices with GET /voices. A cloned voice also reports sampleLanguage, the language its recording was made in: that is the accent it carries, not a limit on what it can say, since the spoken language follows the step's script.
With no voiceAssignments field, the step's voiceId applies to every sender in the campaign. This keeps single-sender campaigns and existing integrations simple:
{
"type": "ai_voice_note",
"config": {
"script": "Hi {{firstName}}, I had a quick idea for {{company}}.",
"fallbackScript": "Hi there, I had a quick idea for your team.",
"voiceId": "cjVigY5qzO86Huf0OWal",
"voiceProvider": "elevenlabs"
}
}For a multi-sender campaign, add voiceAssignments. Each assignment attaches one voice to one sender, several senders, or the whole pool:
{
"type": "ai_voice_note",
"config": {
"script": "Hi {{firstName}}, I had a quick idea for {{company}}.",
"fallbackScript": "Hi there, I had a quick idea for your team.",
"voiceAssignments": [
{
"voiceId": "voice_alex",
"voiceProvider": "elevenlabs",
"linkedinAccountIds": ["aCc123", "aCc456"]
},
{
"voiceId": "cjVigY5qzO86Huf0OWal",
"voiceProvider": "elevenlabs",
"linkedinAccountIds": ["aCc789"]
}
]
}
}Once voiceAssignments is present, every id in the campaign's linkedinAccountIds pool must appear in an assignment. Activating the campaign, or adding an uncovered sender while it is active, is rejected with 400. The campaign builder marks the AI voice-note step as needing attention and lists the senders that still need a voice. A sender can belong to only one assignment; duplicate ids are normalized to their first assignment.
A recorded voice_note step works the same way, with a recording where the AI step has a voice id. With no voiceAssignments, the step's single recording is sent by every sender. To have each sender send its own voice, add voiceAssignments with a voice per group:
{
"type": "voice_note",
"config": {
"voiceAssignments": [
{
"voice": { "url": "https://cdn.example.com/alex.m4a", "mimeType": "audio/mp4", "durationMs": 24000 },
"linkedinAccountIds": ["aCc123", "aCc456"]
},
{
"voice": { "url": "https://cdn.example.com/sam.m4a", "mimeType": "audio/mp4", "durationMs": 19000 },
"linkedinAccountIds": ["aCc789"]
}
]
}
}The same coverage rule applies, with one addition: a group with no recording covers nobody, so every sender must be in a group that actually has audio before the campaign can be activated. When a sender is listed in two groups, the one holding a recording keeps it. Each group may also carry an opaque id, which the campaign builder uses to track a group while the list around it changes. The audio at voice.url must already be an m4a/AAC voice note. Per-sender mode also turns off A/B rotation for that step, and its config.voice and config.variants are left alone but no longer sent.
Wiring and branching
Every step has an optional id, parent, and branch ("yes" | "no"); once any step sets one of them, every step needs an id. The root step has parent: null.
Linear chains: omit id/parent/branch on every step and they are auto-wired in array order, with the ids s_1, s_2, and so on. Each step attaches to the previous step's natural continuation: the "yes" branch of a connection request or condition, but the "no" (still no reply) branch after a message, voice_note, ai_voice_note, inmail, or email, since their "yes" (Replied) branch ends the sequence.
Branching trees: give each step a stable id and set parent/branch explicitly. This sequence invites the lead, messages them if they accept, and falls back to an InMail if they do not:
{
"sequence": {
"steps": [
{
"id": "invite1",
"type": "invite",
"config": { "note": "Hi {{firstName}}!", "fallbackNote": "Hi there!" },
"branchDelays": { "no": 336 }
},
{
"id": "welcome",
"type": "message",
"parent": "invite1",
"branch": "yes",
"config": { "text": "Thanks for connecting, {{firstName}}!", "fallbackText": "Thanks for connecting!" }
},
{
"id": "inmail-fallback",
"type": "inmail",
"parent": "invite1",
"branch": "no",
"config": { "subject": "Quick question", "text": "We never connected, so reaching out here instead.", "whenUnable": "skip" }
}
]
}
}Wiring rules the API enforces:
- A sequence has at most 200 steps and exactly one root. Ids are unique, every step must be reachable from the root, and a step has at most one child per branch (an action step has a single child, which sets no
branch). - The
invitestep may appear at most once per sequence. - No step may sit on the "yes" (Replied) branch of a
message,voice_note,ai_voice_note,inmail, oremail: a reply ends the sequence. - Once the lead is connected (the "yes" branch of an
inviteor anif_connectedcondition), theinvite,inmail,if_connected, andif_open_profilesteps cannot sit below it. The lead is already connected, so a connection request or InMail cannot fire, and a repeat connected or open-profile check is redundant. - To send an InMail only when a connection request goes unanswered, wire it onto the invite's "no" branch, as in the example above. It cannot follow the invite in a linear chain.
Delays
nextDelayHourson an action step: hours to wait before its child runs.branchDelays({ "yes": h, "no": h }) on condition and wait steps: per-branch wait in hours. On a wait step (invite,message,inmail,email, the voice notes, and the open and click checks) the "no" delay is the wait timeout, with a 3 hour minimum; if omitted it defaults to 14 days (72 hours forif_email_openedandif_email_clicked). A "yes" delay after an acceptance, open or click counts from that event, not from when the executor noticed it.
Schedule
The schedule is the window during which the executor may act on the campaign's leads:
timezone: an IANA timezone thatactiveHoursandactiveDaysare read in. List valid values withlistTimezones; treatidas the stable value sinceoffsetshifts with daylight saving.activeHours: daily send window in 24-hourHH:MM;frommust be earlier thanto.activeDays: ISO weekdays that may send (1 = Mon ... 7 = Sun); must be non-empty.startDate/endDate: optional ISO 8601 bounds;nullmeans no bound. Once no send window is left beforeendDate, leads still in the sequence are set topausedas they come due.
Sending schedule replaces the whole object; omitted fields fall back to the defaults (Etc/GMT, 09:00-19:00, Mon-Fri). Unknown timezones, empty activeDays, inverted hours, or startDate after endDate are rejected with 400.
There is no per-campaign daily cap: daily limits belong to each sender account and mailbox, and the leads of a large enrollment start gradually, at the pace each sender can sustain, rather than all at once.
Campaign lifecycle
Campaign status is one of draft, active, paused, or finished. New campaigns start paused unless you pass a different initial status. Only an active campaign sends: set active to start sending and paused to stop, via updateCampaign. An active campaign switches to finished on its own once none of its leads is pending or running (each is done, replied, failed, stopped, or paused), which also fires the campaign.finished webhook. Leads added to a finished campaign wait until you set it active again.
Activating a campaign, or creating it as active, is rejected with 400 until it can run:
- It has a sender in
linkedinAccountIds, unless it is email-only (its steps areemailsteps and conditions that need no sender), and a mailbox inemailAccountIdsif it has anemailstep. - Every body that uses a required variable has its fallback; every
voice_notehas its recordings and everyai_voice_notevariant a script; everyemailvariant has a body, and a subject unless the step is a follow-up (a subject made only of optional variables doesn't count); and everyinmailvariant a body. - Every
if_email_openedandif_email_clickedsits below anemailstep. - Any
voiceAssignmentscover every sender in the pool, and every AI voice is one the workspace can use (a library voice or one of its own cloned voices).
Once a campaign has been activated, the step checks above also run on every later sequence save, even while it is paused.
- An
activecampaign must keep its pools: an emptylinkedinAccountIds(unless the campaign is email-only) or an emptyemailAccountIds(when it has anemailstep) is rejected with400(pause first). sequenceandscheduleare replaced wholesale on update: send the complete object, not a partial patch of it.unsubscribeandtrackingare merged per key.deleteCampaignpermanently deletes the campaign and every lead's membership in it; the leads themselves and the campaign's own list are kept. This cannot be undone.
Editing a live sequence
Once any lead in a campaign has started, the sequence may only be edited safely: you can change step messages and delays and append new steps, but removing, reordering, or retyping existing steps is rejected with 400, because a lead's progress is pinned to a step id. In a linear chain the ids follow array order, so add new steps at the end. To rework a sequence from scratch, duplicateCampaign clones the blueprint (sequence, schedule, sender and mailbox pools, exclusion rules, email settings) into a fresh paused campaign named "<name> (copy)", with no leads.
Leads in a campaign
addLeadsToCampaign takes either a leads array of { profileUrl, ... } objects (or { email, ... } with the email channel; max 1000 per request) or a listId to enroll a whole lead list. To fill a campaign from a LinkedIn or Sales Navigator search, start a lead import with its campaignId. Leads are upserted and enrolled in one call, so you do not need to create them separately first. Leads already in the campaign are skipped, and so are items with neither a valid profileUrl nor (with the email channel) a valid email.
- A
leadsarray returns{ inserted, excluded, blocklisted, listId }:excludedcounts leads an exclusion rule kept out (see below),blocklistedthe leads on the blocklist, andlistIdis the campaign's own list. Skipped items are not counted. - A
listIdimport returns{ inserted, skipped, excluded, blocklisted, listId, listName }:skippedcounts leads already in the campaign, andlistId/listNamename the source list.
Adding a lead makes no LinkedIn call. LinkedIn steps read the lead's profile when they reach it and refresh its firstName, lastName, headline and picture from LinkedIn before rendering the message, replacing the values you sent; other fields, such as company, email and customVariables, are kept.
Every campaign has its own lead list, created on the first enrollment, named "<campaign name>'s leads" (or "<campaign name>' leads" when the name ends in s), and exposed as listId on the campaign object. The list and the campaign stay in step in both directions: every enrolled lead is in the list (whether it came from a leads array, a listId import, or the app's manual add, CSV import and database), adding leads to the list with addListLeads enrolls them (exclusion rules apply, leads already in the campaign are skipped), removing a lead from the list with removeListLead removes it from the campaign, and removing a lead from the campaign takes it off the list. Leads imported from another list keep that list too; only the campaign's own list is bound this way. Lead fields, tags and custom variables live on the lead itself, so an edit made anywhere shows everywhere.
The campaign's exclusion rules filter who actually gets contacted: excludeListIds (members of those lists are never contacted), excludeOtherCampaigns (skip leads any other campaign already invited or messaged), excludeOtherSenders (skip leads already messaged by a different sender account), and excludeSameSenderOtherCampaigns (skip leads the assigned sender already contacted from another campaign). The list and other-campaign rules are checked when leads are added: a matching lead is not enrolled and counts as excluded. All four are checked again at each step until the lead is first invited or messaged, so a rule turned on later still applies: a matching lead becomes stopped, with an excluded entry in its history. The two sender rules apply to LinkedIn senders only.
Company rules
Two campaign settings decide how a campaign treats several people at the same company. Both are off by default.
companyLimit caps the new people the campaign contacts per company: { "max": 2, "period": "week", "scope": "workspace" } means at most two new people at one company in any 7 days, across all the workspace's campaigns.
- A new person is a lead this campaign contacts for the first time: a connection request, an email, or a LinkedIn or WhatsApp message.
periodisday(from midnight in the campaign'sschedule.timezone) orweek(the last 7 days, rolling). It defaults today.scopesays which campaigns' contacts are counted:campaign(the default) counts this campaign's only,workspacecounts every campaign's in the workspace, a person contacted by two campaigns once. Either way the limit only holds back this campaign's leads.- A lead over the limit is not skipped: it stays
pendingwithwaitReason: "company_limit"and awaitUntilset to the first sending time once a place frees up, and goes then. Leads at other companies go on meanwhile. - Changing or removing the limit lets the leads it holds go again at the next sending time, counted against the new limit.
pauseCompanyOnReply pauses the campaign's other leads at the same company when one of them replies:
- A reply no person wrote pauses no one, on any channel: an email marked as an automatic reply, or a reply the out-of-office check reads as automatic (an automatic out-of-office or away message, a business greeting, a receipt). Each reply in a campaign with the rule on is read once, as it arrives, for one reply analysis, or for none extra when its interest is analyzed anyway (
aiInterestEnabled, or a webhook withclassifyReplies): the same call answers both. Without credits, only an auto-reply's subject ("Automatic reply:") or a mailbox saying nobody reads it counts as automatic, so a reply a person wrote, someone saying they'll be away included, always pauses. - The other leads of that company that are still
pendingorrunningbecomepausedwithpausedReason: "company_replied", and so does any lead of that company added to the campaign later. - Resume them one at a time with
resumeLeadInCampaign. A lead resumed this way goes on, and the rule leaves it alone from then on. - Turned on in a running campaign, it pauses the colleagues of everyone who already replied in it, within moments. Only the earlier replies that have colleagues to pause are read, for one reply analysis each.
Two leads are the same company when their work-email domains match or, when either has no work email (a lead added from LinkedIn, or one with a Gmail address), when their company names match once normalized: case, accents, punctuation and legal forms such as Inc. or SAS are ignored. Personal email domains (Gmail, Outlook, Hotmail, Yahoo and the like in every country, and internet providers' mailboxes such as Orange or Bigpond) never group leads. Neither does a company name with a placeholder in it ("Freelance Consultant", "Independent", "Looking for new opportunities", "Stealth") or one made only of roles ("Founder", "CEO & Founder", "Startup", "Consultant").
Out of office
With outOfOffice on, an automatic email reply that says the lead is away holds the lead's next step until they are back, instead of following up into their vacation. It is on for new campaigns ({ "enabled": true, "fallbackDays": 7 }) and off on campaigns created before it existed.
- Only an email the lead's mail server sends as an automatic reply counts. An auto-reply that isn't about being away (a ticket receipt, an unmonitored mailbox) changes nothing.
- The reply is read for the day the lead is back, and the next step waits until the campaign's first sending time that day. Without a date, it waits
fallbackDays(1 to 30). - The lead stays
pendingwithwaitReason: "out_of_office"and thatwaitUntil; a wait that already ended later is kept. The auto-reply is not a reply, so the sequence goes on from there, and a real reply later ends it as usual. - Change the wait with
rescheduleLeadInCampaign(waitUntil, within the next 12 months), or end it withwaitUntil: nullorresumeLeadInCampaign: the lead then goes at the next sending time. Apausedlead keeps its out-of-office wait: resume it first, then change the wait. - Nothing else ends the wait early: an open, an accepted connection request or a topped-up email budget leaves the lead waiting until its return date. That includes a lead whose step already waited longer, such as days for an open: an open before the return date sends nothing.
- Turning
outOfOfficeoff lets every lead it holds go at the next sending time. - Each auto-reply read uses the credits of one reply analysis, only in campaigns with the setting on. Without credits, the reply's words decide, with no date.
An out-of-office message sent without the automatic-reply headers still ends that lead's own sequence, as any reply does; it only pauses no colleague.
Sender assignment
Leads are spread evenly across the campaign's connected, unpaused senders when they are enrolled, when the campaign is activated, and when the sender pool changes; each membership shows its sender as linkedinAccountId (and its mailbox as emailAccountId). Until a lead has started, its sender may still change to keep the pool balanced, for example onto a sender you just added; once its first step has run, it stays pinned to that sender for the rest of the sequence. Add multiple sender ids to scale beyond one account's daily cap (see Sender accounts and LinkedIn safety).
When accounts belong to senders, a lead hears from one sender on every channel: its mailbox and WhatsApp number come from the sender of its LinkedIn account. senderIds on the campaign lists those senders and can be written to add or remove a sender with all its accounts; see Senders in campaigns.
For an ai_voice_note, that sticky sender assignment also selects the voice: the executor finds the assignment containing the lead's sender id and synthesizes the script with that voice. A voice_note that records per sender resolves its recording the same way. Neither ever substitutes another sender's voice, with one exception: a note already generated and waiting for approval is sent as approved, even if the lead has moved to another sender since. If the step uses the all-senders shape, its single voiceId (or its single recording) is used instead.
Removing a sender from the pool moves its leads that have not started to the remaining senders right away; leads it already contacted stay pinned to it and wait, and carry on if it is added back. A paused sender keeps its leads, which wait, but receives no new ones; the not-yet-started leads of a disconnected sender move to the others on their own. To move a paused or failed sender's not-yet-contacted leads onto the other senders explicitly, call rebalanceCampaignSender (LinkedIn senders only); leads it already contacted stay pinned.
Provider matching
With matchEmailProvider on, a lead whose mail Google or Microsoft receives gets its emails from one of the campaign's mailboxes on the same provider: Gmail to Gmail, Outlook to Outlook, a common deliverability practice. It is off by default.
- Each lead's provider is read from its email domain's MX records before its first email:
google(Gmail and Google Workspace),microsoft(Microsoft 365, and personal Outlook, Hotmail and Live) orother. The lead in the campaign shows it asemailProvider,nulluntil it is read. - The lookups run in the background, a few domains at a time, and each domain's answer is kept 30 days. A lead whose domain hasn't been read by its first email goes with any mailbox. A lead with no email yet is read within the hour once it has one, for example from an Enrich step.
- Google leads are spread over the campaign's Google mailboxes and Microsoft leads over its Microsoft mailboxes, and a matched lead waits for its provider's mailbox when that one is at its daily cap. Every other lead (another provider, or one the campaign has no mailbox for) fills the room left on any mailbox.
- A mailbox's provider is the one it signs in with, Google or Microsoft; for an IMAP mailbox, the one its own domain's MX records name.
- It can lower sending volume when most leads use a provider the campaign has few mailboxes for. Turning it on or off spreads the leads not emailed yet again.
One sender, one person at a time
One sender does not work the same person in two campaigns at once. If a lead is enrolled in two campaigns that share the sender it lands on, whichever campaign contacts them first proceeds, and the other waits: its membership stays pending, carries a heldBy object naming the sender and the campaign it is waiting on, and starts on its own once that campaign finishes with them. It is a wait, not a skip, so the lead is never dropped.
Only a membership that has actually contacted the lead (sent an invite or a message) holds the others, and only while its campaign is active. A membership in a paused campaign does not hold, since it could stay paused indefinitely; once that campaign is active again, it holds any membership that has not contacted the lead yet. Mailboxes are not subject to this: only LinkedIn senders hold.
This matters because an inbox conversation is keyed on the sender account and the person, so two campaigns sharing a sender would share one thread, and a reply could only ever stop one of them.
Different senders are unaffected: two campaigns may work the same person concurrently as long as they do it from different sender accounts, since each gets its own conversation.
Moving a lead to another sender with rebalanceCampaignSender clears any hold recorded against the old sender; it is re-evaluated against the new one on the next run. Stopping the membership that is holding the lead releases it immediately.
This waiting is automatic and needs no configuration. It is not the same as the campaign's excludeOtherCampaigns and excludeSameSenderOtherCampaigns options, which permanently skip a lead another campaign has already contacted rather than queueing it. Those are checked first, so a campaign with either option enabled skips the lead outright and never reaches the waiting behaviour.
Lead statuses and controls
Each membership has a status you can filter on in listLeadsInCampaign:
| Status | Meaning |
|---|---|
pending | Queued, waiting for its next step or timer. Also covers a membership waiting on another campaign, which carries heldBy (see One sender, one person at a time), one waiting on the company limit, which carries waitReason: "company_limit", and one waiting for an out-of-office lead to be back, which carries waitReason: "out_of_office". |
running | The executor is working a step. |
replied | The lead replied; the sequence ended. |
done | The sequence completed. |
failed | A step failed; see the membership's error. |
paused | Frozen, resumable. Progress and timers are kept. pausedReason says why: user (paused by hand), manual_message (paused when someone messaged the lead from the inbox) or company_replied (see Company rules); it is null for a membership paused because the schedule has ended (see endDate), or paused before the field existed. |
stopped | Terminally stopped; not resumable. Set by a manual stop (from the API, the app, or a call task's "Stop sequence"), an exclusion rule, or a step that can't apply to the lead (no LinkedIn profile, no usable email address, an open or click check on an untracked email). |
Each membership also has a stage: where the lead is, as the app's Status column shows it. While status is pending or running the lead is in the sequence, and the stage says where; otherwise the stage is the status.
| Stage | Meaning |
|---|---|
pending | No step started yet. |
invited | Its connection request is sent and not accepted yet, and it waits on that invite step. |
accepted | It accepted, and no message has been sent yet. |
messaged | A message step sent its message and waits for the reply, or the invite's note went as a message once they accepted and no message step has sent since. |
call_pending | It waits in Tasks for someone to call. |
in_progress | Anything else in the sequence: between steps, on a delay or a condition, or a step being run. |
done, replied, failed, paused, stopped | The same as status. |
Finding leads in a campaign
listLeadsInCampaign lists a campaign's leads newest first, with filters you can combine, the same ones as the campaign's leads table:
stage, or the rawstatus.label: a label, ornone.currentStepId: the leads on that step now, whatever their stage ("here now" on the step).reachedStepId: the leads that reached that step, the number on the step in the app. A lead reached a step when itshistoryhas an entry for the step that went through (an email's opens and clicks don't count), or when the step's invite or message is sent and still waits for an answer.accountId: the leads assigned to that LinkedIn account, mailbox or WhatsApp number.search: a case-insensitive match on the lead's name, headline, job title, email, company or profile slug, as onlistLeads. It reads at most 25,000 of the campaign's leads, after the other filters: past that it answers400withcode: search-too-broad, so add another filter first.
Step ids are the sequence.steps[].id of getCampaign; one that isn't in the campaign's sequence is a 400. For example, ?reachedStepId=…&label=interested lists the interested leads among those a step reached, and ?stage=invited&accountId=… the connection requests from one account still waiting for an answer.
Per-lead controls: pauseLeadInCampaign freezes the sequence (to do the same when you reply by hand, send the message from the inbox with sequenceAction: "pause"; a manual message alone does not pause it), resumeLeadInCampaign continues from where it left off (and ends an out-of-office wait), rescheduleLeadInCampaign changes an out-of-office wait, stopLeadInCampaign stops it for good while keeping the history, retryLeadInCampaign re-queues a failed or stuck lead, and removeLeadFromCampaign removes the membership while keeping the underlying lead. Pause applies only to a pending or running lead, resume only to a paused one or one waiting out of office, and stop to any of those three; on any other lead the call changes nothing and still returns 200. Retry does not check the status and puts any lead back in the queue, so use it only on a failed or stuck one.
getLeadInCampaign returns full progress: the stage, the assigned linkedinAccountId and emailAccountId, the lead's senderId (Senders), currentStepId, waitUntil and waitReason, heldBy, pausedReason, invite timestamps (invitationSentAt, invitationAcceptedAt, invitationWithdrawnAt) and invitationSentWithoutNote, messageSentAt, lastReplyAt, the first emailOpenedAt and emailClickedAt, finishedAt, error, and the append-only step history. listLeadsInCampaign returns the same fields without history.
Labels
Each membership carries a label: how the person answered, as the inbox and the leads table show it.
| Label | Means |
|---|---|
interested | Open to a call, a demo, pricing or learning more. |
not_interested | A clear no to your offer, or an unsubscribe request. |
neutral | A real reply with no intent either way: "not right now", a question, an off-topic answer. |
out_of_office | Away for a while: an automatic away reply, or a message saying so. |
With the campaign's aiInterestEnabled on, AI labels each reply (labelBy: ai), at the Reply analysis price per reply read (listed in Swarmhit under Settings → Credits usage, for workspace owners and admins). Campaigns created in Swarmhit start with it on; through the API it is off unless you pass true, and a campaign you copy keeps its original's setting. An automatic away reply by email is labelled out_of_office from the out-of-office check's own reading when that runs (no second credit), else from its words, for free.
AI's label follows the latest reply, even when replies are read out of order, with two exceptions: neutral never replaces interested or not_interested ("thanks, will read" after "let's talk" stays interested), and neither does out_of_office. A real reply after an away notice replaces it.
Set a label yourself with setLeadInterest ({ "label": "out_of_office" }, or null to clear it), in the app, or with the MCP server's set_lead_label. AI never changes a label a person set; with none, AI labels the lead's next reply. Each change fires lead.label_changed and goes on the lead's history: as interest_changed when it changes interest, as before labels, else as label_changed. Filter listLeadsInCampaign with label (a label, or none).
interest (interested, not_interested or null) and interestBy stay as they were before labels: the label when it is interested or not interested, null otherwise. { "interest": ... } still sets the label that way, and null clears it. Only the label is editable on a membership; lead fields and customVariables are updated via PATCH /leads/{id} and are shared across every campaign the lead is in.
Stats
Each campaign carries a stats object: stats.total is the number of leads enrolled, while its done and failed counters are not maintained and read 0. For live progress, filter listLeadsInCampaign by stage or status. For a workspace-wide rollup, getStats breaks campaigns, lead progress, and sender accounts down by status and reports outreach totals (invitesSent, invitesAccepted, replies). Per-mailbox email results (sent, replies, bounces) are on GET /email-accounts/{id}/stats.
Reference
Campaigns reference
Create, update, duplicate, delete, and rebalance campaigns.
Leads in Campaigns reference
Enroll leads and manage each one's progress, status, and label.
Webhooks
React to replies, accepted invites, lead status changes, and finished campaigns.
Leads and lists
The workspace address book that campaigns draw from.