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, sequence, schedule, aiInterestEnabled, 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}}). 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.
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).
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.

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.

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; and message and inmail variants may also carry attachments (up to 5 files served from a public URL) or voice (a LinkedIn voice note whose audio is already an m4a/AAC file). The flat keys always mirror variant A, so single-variant steps look unchanged.

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 and InMail variants also accept attachments and voice; recorded voice-note variants accept voice. 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.

AI voices and sender assignments

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.

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": "Enceladus",
    "voiceProvider": "gemini"
  }
}

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": "Enceladus",
        "voiceProvider": "gemini",
        "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.

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 (max 1000 per request) or a listId to enroll a whole lead list. 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.

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. It never substitutes another sender's voice. If the step uses the all-senders shape, its single voiceId 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.

Lead statuses and controls

Each membership has a status you can filter on in listLeadsInCampaign:

StatusMeaning
pendingQueued, waiting for its next step or timer.
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.

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

Reference

On this page