Campaigns and sequences

Build automated LinkedIn 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), 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. Anything omitted falls back to a default: empty sequence, 09:00-19:00 Mon-Fri schedule, and paused status.

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.
inviteSends a connection request with an optional note. Branches: "yes" = Accepted, "no" = still not accepted. withdrawEnabled plus withdrawDays (1-365, default 25) auto-withdraws an unanswered request after that many days.
messageSends a direct message (text). 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 an m4a/AAC voice-note recording made 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 in an AI or cloned voice. 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.
inmailSends an InMail (subject, text). 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, text, 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: true sends it as a reply under the campaign's first email to that lead.
if_has_emailCondition: "yes" when the lead has an email address, "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. An email with no link can only take "no".

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. Emails go out in plain text with light formatting, paced a few minutes apart per mailbox and capped by the mailbox's daily limit; a lead whose address bounced or reported spam (emailStatus on the lead) stops at the email step (see below). A reply on either channel ends the sequence. See Email deliverability before the first send.

Open and click steps make the campaign track its own emails, whatever 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: the last email was not tracked"). 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 activity shows "Stopped: no LinkedIn profile" or "Stopped: no email address". 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 lead is not an open profile and the sender has no premium InMail credits: 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}} such as {{firstName}} and any of the lead's customVariables (see Leads and lists). Whenever a body uses a variable, its fallback must be set: fallbackNote on invites, fallbackText on messages and InMails, fallbackSubject for InMail subjects. The fallback is sent verbatim to any lead missing the value. This is enforced when the campaign is activated; a draft 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) accept config.variants: up to 5 message variations 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; 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. 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, fallbackScript, voiceId, voiceProvider, and an optional speaking 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.

Directing the delivery

A script or fallbackScript may carry bracketed directions alongside its words. A direction is never read out loud — it tells the voice how to say the line that follows it:

DirectionEffect
[short pause], [long pause]A beat, roughly 0.7s and 1.8s
[excited], [curious], [thoughtful], [happy], [surprised], [whispers]Tone for the sentence that follows
[laughs], [chuckles], [sighs], [clears throat]A small non-verbal reaction
{
  "script": "[curious] Hi {{firstName}}, quick one. [short pause] I saw what you are building at {{company}} and it stuck with me."
}

How much of that survives depends on the voice. Studio voices (provider: elevenlabs) and cloned voices perform every direction. Standard voices (provider: openai) keep the pauses and have the rest removed before they speak, so a script written with directions is always safe to send on any voice — it just does less on the cheaper tier.

Directions do not count towards the script's 800-character spoken limit; the raw field accepts 1400 characters so a well-directed script is never truncated mid-sentence. Use them sparingly: two to four in a note this length reads as a person, and more reads as a performance.

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"). The root step has parent: null.

Linear chains: omit id/parent/branch on every step and they are auto-wired in array order. 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, or inmail, 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:

  • 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, or inmail: 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. The "no" branch of an invite, message, or InMail is the wait timeout; if omitted it defaults to 14 days, with a 3 hour minimum.

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.

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.

Campaign lifecycle

Campaign status is one of draft, active, paused, or finished. New campaigns start paused unless you pass a different initial status. Set active to start sending and paused to stop, via updateCampaign. A campaign finishes when outreach to every lead in it is done, which also fires the campaign.finished webhook.

  • An active campaign must keep at least one sender: an empty linkedinAccountIds while active is rejected with 400 (pause first).
  • sequence and schedule are replaced wholesale on update: send the complete object, not a partial patch of it.
  • deleteCampaign permanently deletes the campaign and every lead's membership in it. This cannot be undone.

Editing a live sequence

Once a campaign has leads in progress, 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. To rework a sequence from scratch, duplicateCampaign clones the blueprint (sequence, schedule, sender pool, exclusion rules) into a fresh paused campaign 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; caller-supplied fields win over the best-effort LinkedIn lookup, and leads already in the campaign are skipped. The response reports inserted and skipped counts, plus the listId the leads belong to.

Every campaign has its own lead list, created on the first enrollment, named "<campaign name>'s leads", 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).

Sender assignment

Each lead is stickily assigned to one sender from the campaign's pool on its first action tick: the executor picks the sender with the most remaining daily quota for the upcoming step, and once the lead is contacted 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. If the step uses the all-senders shape, its single voiceId (or its single recording) is used instead.

Removing a sender from the pool reassigns its in-flight leads to the remaining senders on their next run. To move a paused or failed sender's not-yet-contacted leads onto the other senders explicitly, call rebalanceCampaignSender; 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 sent something (an invite, message, InMail or voice note) 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; resume it and it holds again from its next send.

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

Per-lead controls: pauseLeadInCampaign freezes the sequence (sending the lead a manual message from the inbox pauses it the same way), 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.

getLeadInCampaign returns full progress: currentStepId, waitUntil, heldBy, invite timestamps (invitationSentAt, invitationAcceptedAt, invitationWithdrawnAt), messageSentAt, lastReplyAt, and the append-only step 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; set it yourself with setLeadInterest. 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 with total, done, and failed lead counts. 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