# Campaigns and sequences (/smart-api/campaigns)



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.

<Callout type="info">
  Campaign endpoints live under the [Campaigns](/smart-api/reference/campaigns/listCampaigns) and [Leads in Campaigns](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) reference sections. This guide covers the concepts; the reference covers every field.
</Callout>

## Create a campaign [#create-a-campaign]

[`createCampaign`](/smart-api/reference/campaigns/createCampaign) accepts everything up front: name, sender pool (`linkedinAccountIds`, plus `emailAccountIds` when the sequence has email steps), sequence, schedule, `aiInterestEnabled` ([labels](#labels); off unless you pass `true`), the email `unsubscribe` line, `tracking` and `matchEmailProvider` ([provider matching](#provider-matching)), exclusion rules (`excludeListIds`, `excludeOtherCampaigns`, `excludeOtherSenders`, `excludeSameSenderOtherCampaigns`), the [company rules](#company-rules) (`companyLimit`, `pauseCompanyOnReply`), the [out-of-office wait](#out-of-office) (`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](#campaign-lifecycle)).

```bash
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`](/smart-api/reference/leads-in-campaigns/addLeadsToCampaign) and set `status` to `active` with [`updateCampaign`](/smart-api/reference/campaigns/updateCampaign).

## The sequence [#the-sequence]

A sequence is a tree of steps. Each lead walks the tree from the root, one step at a time.

### Step types [#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](/smart-api/reference/approvals/listPendingComments) 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](#notes-on-free-linkedin-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](/smart-api/reference/approvals/listPendingVoiceNotes) 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](/smart-api/direct-actions#call-tasks), and [`call_task.pending`](/smart-api/webhooks#call_taskpending) 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](/smart-api/database#credits-and-errors)). 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`](/smart-api/reference/email-accounts/listEmailAccounts). 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](/smart-api/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 /`](/smart-api/reference/general/getApiInfo)): 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](/smart-api/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](/smart-api/webhooks).

A step never answers a question it could not ask. A lead added by email alone (no LinkedIn profile, see [Leads](/smart-api/leads-and-lists)) 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](/smart-api/leads-and-lists#finding-emails-and-phone-numbers): 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 [#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`](/smart-api/reference/leads-in-campaigns/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_sent` entry in the lead's `history` and 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 [#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](/smart-api/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:

```text
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](/smart-api/email-deliverability#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 [#ab-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 [#updating-one-step-or-variant]

For a copy or media change to an existing step, use [`updateCampaignStep`](/smart-api/reference/campaigns/updateCampaignStep). First read the campaign with [`getCampaign`](/smart-api/reference/campaigns/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.

```bash
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`](/smart-api/reference/campaigns/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`](/smart-api/reference/campaigns/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 [#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`](/smart-api/reference/reference/listVoices). 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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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 [#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:

```json
{
  "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 [#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 [#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`](/smart-api/reference/reference/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-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`](/smart-api/reference/campaigns/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](/smart-api/webhooks). 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 required variable has its fallback; every `voice_note` has its recordings and every `ai_voice_note` variant a script; every `email` variant has a body, and a subject unless the step is a follow-up (a subject made only of optional variables doesn't count); 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`](/smart-api/reference/campaigns/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 [#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`](/smart-api/reference/campaigns/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 [#leads-in-a-campaign]

[`addLeadsToCampaign`](/smart-api/reference/leads-in-campaigns/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](/smart-api/leads-and-lists#lead-imports-from-a-linkedin-search) 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, blocklisted, listId }`: `excluded` counts leads an exclusion rule kept out (see below), `blocklisted` the leads on the [blocklist](/smart-api/blocklist), and `listId` is the campaign's own list. Skipped items are not counted.
* A `listId` import returns `{ inserted, skipped, excluded, blocklisted, 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`](/smart-api/reference/lists/addListLeads) enrolls them (exclusion rules apply, leads already in the campaign are skipped), removing a lead from the list with [`removeListLead`](/smart-api/reference/lists/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 [#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.
* `period` is `day` (from midnight in the campaign's `schedule.timezone`) or `week` (the last 7 days, rolling). It defaults to `day`.
* `scope` says which campaigns' contacts are counted: `campaign` (the default) counts this campaign's only, `workspace` counts 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 `pending` with `waitReason: "company_limit"` and a `waitUntil` set 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](#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 with `classifyReplies`): 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 `pending` or `running` become `paused` with `pausedReason: "company_replied"`, and so does any lead of that company added to the campaign later.
* Resume them one at a time with [`resumeLeadInCampaign`](/smart-api/reference/leads-in-campaigns/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 [#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 `pending` with `waitReason: "out_of_office"` and that `waitUntil`; 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`](/smart-api/reference/leads-in-campaigns/rescheduleLeadInCampaign) (`waitUntil`, within the next 12 months), or end it with `waitUntil: null` or [`resumeLeadInCampaign`](/smart-api/reference/leads-in-campaigns/resumeLeadInCampaign): the lead then goes at the next sending time. A `paused` lead 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 `outOfOffice` off 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 [#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](/smart-api/sender-accounts) and [LinkedIn safety](/smart-api/linkedin-safety)).

When accounts belong to [senders](/smart-api/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](/smart-api/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`](/smart-api/reference/campaigns/rebalanceCampaignSender) (LinkedIn senders only); leads it already contacted stay pinned.

### Provider matching [#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) or `other`. The lead in the campaign shows it as `emailProvider`, `null` until 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-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`](/smart-api/reference/campaigns/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 [#lead-statuses-and-controls]

Each membership has a `status` you can filter on in [`listLeadsInCampaign`](/smart-api/reference/leads-in-campaigns/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-sender-one-person-at-a-time)), one waiting on the [company limit](#company-rules), which carries `waitReason: "company_limit"`, and one waiting for an [out-of-office](#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](#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 [#finding-leads-in-a-campaign]

[`listLeadsInCampaign`](/smart-api/reference/leads-in-campaigns/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 raw `status`.
* `label`: a [label](#labels), or `none`.
* `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 its `history` has 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 on [`listLeads`](/smart-api/reference/leads/listLeads). It reads at most 25,000 of the campaign's leads, after the other filters: past that it answers `400` with `code: search-too-broad`, so add another filter first.

Step ids are the `sequence.steps[].id` of [`getCampaign`](/smart-api/reference/campaigns/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`](/smart-api/reference/leads-in-campaigns/pauseLeadInCampaign) freezes the sequence (to do the same when you reply by hand, send the message from the [inbox](/smart-api/inbox) with `sequenceAction: "pause"`; a manual message alone does not pause it), [`resumeLeadInCampaign`](/smart-api/reference/leads-in-campaigns/resumeLeadInCampaign) continues from where it left off (and ends an out-of-office wait), [`rescheduleLeadInCampaign`](/smart-api/reference/leads-in-campaigns/rescheduleLeadInCampaign) changes an out-of-office wait, [`stopLeadInCampaign`](/smart-api/reference/leads-in-campaigns/stopLeadInCampaign) stops it for good while keeping the history, [`retryLeadInCampaign`](/smart-api/reference/leads-in-campaigns/retryLeadInCampaign) re-queues a failed or stuck lead, and [`removeLeadFromCampaign`](/smart-api/reference/leads-in-campaigns/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`](/smart-api/reference/leads-in-campaigns/getLeadInCampaign) returns full progress: the `stage`, the assigned `linkedinAccountId` and `emailAccountId`, the lead's `senderId` ([Senders](/smart-api/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`](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) returns the same fields without `history`.

### Labels [#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](#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`](/smart-api/reference/leads-in-campaigns/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`](/smart-api/webhooks#leadlabel_changed) and goes on the lead's `history`: as `interest_changed` when it changes `interest`, as before labels, else as `label_changed`. Filter [`listLeadsInCampaign`](/smart-api/reference/leads-in-campaigns/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 [#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`](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) by `stage` or `status`. For a workspace-wide rollup, [`getStats`](/smart-api/reference/general/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`](/smart-api/reference/email-accounts/getEmailAccountStats).

## Reference [#reference]

<Cards>
  <Card title="Campaigns reference" href="/smart-api/reference/campaigns/listCampaigns" description="Create, update, duplicate, delete, and rebalance campaigns." />

  <Card title="Leads in Campaigns reference" href="/smart-api/reference/leads-in-campaigns/listLeadsInCampaign" description="Enroll leads and manage each one's progress, status, and label." />

  <Card title="Webhooks" href="/smart-api/webhooks" description="React to replies, accepted invites, lead status changes, and finished campaigns." />

  <Card title="Leads and lists" href="/smart-api/leads-and-lists" description="The workspace address book that campaigns draw from." />
</Cards>
