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, the email unsubscribe line and tracking, exclusion rules (excludeListIds, excludeOtherCampaigns, excludeOtherSenders, excludeSameSenderOtherCampaigns), 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

TypeWhat it does
visit_profileViews the lead's LinkedIn profile.
follow_profileFollows the lead.
like_postLikes the lead's most recent post. Takes no config.
comment_postWrites 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.
inviteSends a connection request with an optional note (up to 300 characters; free senders' invites go out without it). 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.
messageSends 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_noteSends 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_noteSpeaks 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.
inmailSends 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_connectedCondition: "yes" branch when the lead is already connected, "no" otherwise.
if_open_profileCondition: branch on whether the lead is an open profile.
emailSends 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 still required and is used when there is no earlier email.
if_has_emailCondition: "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_linkedinCondition: "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_openedWaits 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_clickedThe 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".

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.

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.

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). Whenever a body uses a 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.

In an email step's text and fallbackText, {{unsubscribeLink}} places the campaign's unsubscribe line (unsubscribe on the campaign, see Opting out). It always has a value, so it never makes a fallback required, and it is the one variable a fallback fills in.

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), and message variants may carry voice (a LinkedIn voice note whose audio is already an m4a/AAC file). LinkedIn does not deliver voice notes on InMails, so an inmail step drops voice. 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; and prompt for an AI post comment. Message variants also accept attachments and voice, InMail variants accept attachments only, 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 invite step 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, or email: a reply ends the sequence.
  • Once the lead is connected (the "yes" branch of an invite or an if_connected condition), the invite, inmail, if_connected, and if_open_profile steps 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

  • nextDelayHours on 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 for if_email_opened and if_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 that activeHours and activeDays are read in. List valid values with listTimezones; treat id as the stable value since offset shifts with daylight saving.
  • activeHours: daily send window in 24-hour HH:MM; from must be earlier than to.
  • activeDays: ISO weekdays that may send (1 = Mon ... 7 = Sun); must be non-empty.
  • startDate / endDate: optional ISO 8601 bounds; null means no bound. Once no send window is left before endDate, leads still in the sequence are set to paused as 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 are email steps and conditions that need no sender), and a mailbox in emailAccountIds if it has an email step.
  • Every body that uses a variable has its fallback; every voice_note has its recordings and every ai_voice_note variant a script; every email variant has a subject and a body, and every inmail variant a body.
  • Every if_email_opened and if_email_clicked sits below an email step.
  • Any voiceAssignments cover 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 active campaign must keep its pools: an empty linkedinAccountIds (unless the campaign is email-only) or an empty emailAccountIds (when it has an email step) is rejected with 400 (pause first).
  • sequence and schedule are replaced wholesale on update: send the complete object, not a partial patch of it. unsubscribe and tracking are merged per key.
  • deleteCampaign permanently 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 leads array returns { inserted, excluded, listId }: excluded counts leads an exclusion rule kept out (see below), and listId is the campaign's own list. Skipped items are not counted.
  • A listId import returns { inserted, skipped, excluded, listId, listName }: skipped counts leads already in the campaign, and listId / listName name 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.

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

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.

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:

StatusMeaning
pendingQueued, 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).
runningThe executor is working a step.
repliedThe lead replied; the sequence ended.
doneThe sequence completed.
failedA step failed; see the membership's error.
pausedFrozen, resumable. Progress and timers are kept. Also set when the schedule has ended (see endDate).
stoppedTerminally stopped; not resumable. Set by a manual stop, 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).

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, 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, 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 assigned linkedinAccountId and emailAccountId, currentStepId, waitUntil, heldBy, invite timestamps (invitationSentAt, invitationAcceptedAt, invitationWithdrawnAt), messageSentAt, lastReplyAt, the first emailOpenedAt and emailClickedAt, finishedAt, error, and the append-only step history. listLeadsInCampaign returns the same fields without history.

Interest

Each membership carries an interest (interested, not_interested, or null) and interestBy (manual or ai). With the campaign's aiInterestEnabled on, interest can be classified automatically from replies; set it yourself with setLeadInterest, and the AI never overwrites a value you set. Only interest 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 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

On this page