# Campaigns

> API reference for Warmerly campaigns: create and manage multi-step email and LinkedIn outbound sequences, their steps, sending accounts, and leads.

Source: https://warmerly.com/docs/campaigns

A campaign is a multi-step outbound sequence (email, LinkedIn, or a mix) sent to a list of leads on a schedule. This page covers creating and managing campaigns, their steps, sending accounts, and leads.

Campaign-list endpoints (`GET`/`POST /campaigns`) are project-scoped and require `X-Project-Id`. Everything under `/campaigns/{id}/...` is campaign-scoped, the campaign's project is resolved from `{id}` itself, so `X-Project-Id` is **not** required on those routes (access is still checked: your API key's user must belong to the campaign's workspace).

## The campaign object

```json
{
  "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f",
  "projectId": "8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a",
  "name": "Q3 outbound - agencies",
  "status": "draft",
  "assignedToUserId": null,
  "targetReplies": null,
  "targetLeads": null,
  "dailyLimit": 30,
  "stopOnReply": true,
  "stopOnAutoReply": true,
  "trackOpens": false,
  "trackClicks": true,
  "plainTextOnly": false,
  "sendingDays": [1, 2, 3, 4, 5],
  "sendingHourStart": 9,
  "sendingHourEnd": 17,
  "timezone": "UTC",
  "recipientWindowHourStart": 8,
  "recipientWindowHourEnd": 17,
  "minWaitMinutes": 10,
  "rampEnabled": false,
  "rampStartPerSender": 3,
  "rampMaxPerSender": 15,
  "rampStepPerDay": 1,
  "brief": null,
  "entryStepId": null,
  "createdAt": "2026-06-01T09:00:00.000Z",
  "updatedAt": "2026-06-01T09:00:00.000Z",
  "startedAt": null,
  "completedAt": null
}
```

`status` is one of `draft`, `active`, `paused`, `completed`, `archived`. The object is the stored campaign row, so it can carry further fields than shown (for example `linkedinAi`). `trackOpens` is `false` by default; `rampEnabled` can start as `true` on a new campaign when your mailboxes are young.

## List campaigns

```text
GET /campaigns
```

Requires `X-Project-Id`. Returns every campaign in the project with live counters joined in (lead/email totals), most recently created first, plus the project's dashboard roll-up: `summary` (counts across campaigns that are not archived), `period` (the last 30 days against the 30 before), `activity` (daily sends, replies and bounces for the last 30 days), `funnel`, `sparklines` (daily sends per campaign) and `topCampaigns`. Archived campaigns are included in `campaigns` but not counted in the roll-up.

```bash
curl https://app.warmerly.com/api/v1/campaigns \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "X-Project-Id: 8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a"
```

```json
{
  "campaigns": [
    {
      "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f",
      "name": "Q3 outbound - agencies",
      "status": "active",
      "createdAt": "2026-06-01T09:00:00.000Z",
      "startedAt": "2026-06-02T09:00:00.000Z",
      "completedAt": null,
      "assignedToUserId": null,
      "assignedToName": null,
      "assignedToEmail": null,
      "totalLeads": 240,
      "completedLeads": 51,
      "sentCount": 312,
      "repliedCount": 9,
      "bouncedCount": 4,
      "failedCount": 1,
      "recentFailedCount": 0,
      "recentSentCount": 140,
      "senderCount": 2,
      "nextRunAt": "2026-07-03T09:00:00.000Z"
    }
  ],
  "summary": { "totalCampaigns": 1, "activeCampaigns": 1, "leads": 240, "sent": 312, "replies": 9, "replyRate": 0.029 },
  "period": { "current": { "sent": 312, "replies": 9, "bounced": 4, "replyRate": 0.029 }, "previous": { "sent": 0, "replies": 0, "bounced": 0, "replyRate": 0 } },
  "activity": [ { "date": "2026-07-02", "sent": 40, "replies": 1, "bounced": 0 } ],
  "funnel": { "sent": 312, "replied": 9, "bounced": 4 },
  "sparklines": { "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f": [0, 12, 30] },
  "topCampaigns": [ { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "name": "Q3 outbound - agencies", "replies": 9 } ]
}
```

Each campaign row has no click counts or goal targets; read `GET /campaigns/{id}` for the full campaign and `GET /campaigns/{id}/stats` for clicks. `replyRate` values are fractions between 0 and 1. `nextRunAt` is the earliest scheduled send among queued and active leads, or `null`.

## Create a campaign

```text
POST /campaigns
```

Requires `X-Project-Id`. Creates a campaign in `draft` status with one empty email step seeded automatically (edit or replace it via [Steps](https://warmerly.com/docs/campaigns#steps)). The project's healthy mailboxes that can send campaigns are attached as senders automatically.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string (1-200 chars) | yes |  |
| `timezone` | string | no | IANA timezone string, default `"UTC"` |
| `dailyLimit` | integer (1-50000) | no | Default `30` |
| `assignedToUserId` | UUID or `null` | no | Must be a member of the campaign's workspace |
| `targetReplies` | integer (0-1000000) or `null` | no | Optional goal shown against live stats |
| `targetLeads` | integer (0-1000000) or `null` | no | Optional goal shown against live stats |

```bash
curl -X POST https://app.warmerly.com/api/v1/campaigns \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "X-Project-Id: 8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Q3 outbound - agencies", "dailyLimit": 50 }'
```

```json
{ "campaign": { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "name": "Q3 outbound - agencies", "status": "draft", "...": "..." } }
```

Returns `201`. Creating a campaign requires your plan to include the email channel; otherwise returns `403 channel_not_included`. An invalid body returns `400 bad_request` (`invalid_body`).

## Get a campaign

```text
GET /campaigns/{id}
```

```bash
curl https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "campaign": { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "name": "Q3 outbound - agencies", "status": "draft", "...": "..." } }
```

Returns `404 not_found` if the campaign doesn't exist and `403 forbidden` if it belongs to a workspace you are not a member of.

## Update a campaign

```text
PATCH /campaigns/{id}
```

All fields optional: send only what you want to change.

| Field | Type |
| --- | --- |
| `name` | string (1-200 chars) |
| `status` | `"draft" \\| "active" \\| "paused" \\| "completed" \\| "archived"` |
| `dailyLimit` | integer (1-50000) |
| `stopOnReply` | boolean |
| `stopOnAutoReply` | boolean |
| `trackOpens` | boolean |
| `trackClicks` | boolean |
| `plainTextOnly` | boolean |
| `sendingDays` | integer[] (0-6, 0 = Sunday) |
| `sendingHourStart` | integer (0-23) |
| `sendingHourEnd` | integer (0-23) |
| `timezone` | valid IANA timezone string |
| `recipientWindowHourStart` / `recipientWindowHourEnd` | integer (0-23) |
| `minWaitMinutes` | integer (0-1440) |
| `rampEnabled` | boolean |
| `rampStartPerSender` | integer (1-500) |
| `rampMaxPerSender` | integer (1-500) |
| `rampStepPerDay` | integer (1-100) |
| `assignedToUserId` | UUID or `null` |
| `targetReplies` | integer (0-1000000) or `null` |
| `targetLeads` | integer (0-1000000) or `null` |
| `brief` | `{ senderContext, offer, icp, tone? }` (strings, up to 2000 characters each, `tone` up to 200) or `null` |

Fields not in this table are ignored without an error. A body that fails validation returns `400 bad_request` with a message naming the field.

`status: "paused"` and `status: "active"` behave exactly like [pause](https://warmerly.com/docs/campaigns#pause-a-campaign) and [resume](https://warmerly.com/docs/campaigns#resume-a-campaign): only an active campaign can be paused and only a paused one resumed (otherwise `409 conflict`), and they must be sent without other fields (otherwise `400 bad_request`). Setting `"draft"`, `"completed"` or `"archived"` is a plain write. To launch a draft, completed or archived campaign, use `POST /campaigns/{id}/start`.

```bash
curl -X PATCH https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "dailyLimit": 75, "sendingHourStart": 8, "sendingHourEnd": 18 }'
```

```json
{ "campaign": { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "dailyLimit": 75, "sendingHourStart": 8, "sendingHourEnd": 18, "...": "..." } }
```

To change `status` directly, use `PATCH` with `{ "status": "..." }`, or prefer the dedicated [`start`](https://warmerly.com/docs/campaigns#start-a-campaign), [`pause`](https://warmerly.com/docs/campaigns#pause-a-campaign), and [`resume`](https://warmerly.com/docs/campaigns#resume-a-campaign) actions below, which also validate that the campaign is actually ready to send.

## Delete a campaign

```text
DELETE /campaigns/{id}
```

Deletes the campaign and cascades to its steps, senders, leads, and send history. This cannot be undone.

```bash
curl -X DELETE https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "ok": true }
```

## Start a campaign

```text
POST /campaigns/{id}/start
```

Validates the campaign is ready, then flips it to `active` and sets `startedAt` (on first start only, later restarts don't reset it). The workspace must not be billing-locked, and starting a draft, completed or archived campaign must fit your plan's active-campaign limit. It then runs the same checks as [`GET /campaigns/{id}/readiness`](https://warmerly.com/docs/campaigns#readiness-check), plus a live check that the AI provider is reachable when the copy uses `{{ai_opener}}`:

- At least one step exists that is a valid email step (`subject` + `bodyText`), a LinkedIn step (`linkedin_connection` / `linkedin_message`).
- Your plan includes the channel(s) the campaign actually uses.
- Your monthly campaign send limit hasn't been reached.
- At least one sending account is attached for each channel the campaign uses (email → [senders](https://warmerly.com/docs/campaigns#senders-email), LinkedIn → [linkedin-senders](https://warmerly.com/docs/campaigns#linkedin-senders)).
- At least one lead is `queued`.
- Copy checks: every merge tag exists, the campaign brief is written when the copy uses `{{ai_opener}}`, and the copy does not link to a Warmerly domain.

Checks marked `severity: "warning"` do not block, but the start is refused with `400` and `error.details.needsAcknowledgement: true` until you repeat the call with `{ "acknowledgeWarnings": true }` in the JSON body.

```bash
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/start \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "campaign": { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "status": "active", "startedAt": "2026-07-02T10:00:00.000Z", "...": "..." } }
```

Failing a readiness check returns `400 bad_request` with a human-readable `message` (e.g. `"Add at least one sending mailbox"`) and the checks in `error.details.checks`. Missing plan access returns `403 channel_not_included`; hitting the monthly send cap returns `402 payment_required`. Hitting your plan's active-campaign limit returns `402 plan_capacity`, a billing-locked workspace returns `402 billing_locked`, and a campaign whose only senders are not allowed to send campaigns at all (for example a free mailbox while sending from free mailboxes is switched off) returns `409` with code `sender_cannot_send_campaigns`.

## Pause a campaign

```text
POST /campaigns/{id}/pause
```

Sets an **active** campaign's `status` to `paused` (pausing one that is already paused is a no-op; any other status returns `409 conflict`). Queued sends stop going out until resumed.

```bash
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/pause \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "campaign": { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "status": "paused", "...": "..." } }
```

## Resume a campaign

```text
POST /campaigns/{id}/resume
```

Sets a **paused** campaign back to `active`; any other status returns `409 conflict` (use `start` instead). Re-checks the billing lock (`402 billing_locked`) and your monthly campaign send limit (returns `402 payment_required` if exhausted) but does not re-run the full `start` readiness checks.

```bash
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/resume \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "campaign": { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "status": "active", "...": "..." } }
```

## Stats

```text
GET /campaigns/{id}/stats
```

Aggregated counters and rates for the campaign.

```bash
curl https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/stats \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "leads": {
    "total": 240,
    "queued": 168,
    "active": 21,
    "completed": 51,
    "replied": 9,
    "bounced": 3,
    "unsubscribed": 2
  },
  "emails": {
    "sent": 312,
    "opened": 140,
    "clicked": 18,
    "replied": 9,
    "bounced": 4,
    "failed": 1
  },
  "linkedin": {
    "connectionsSent": 40,
    "accepted": 22,
    "messagesSent": 22,
    "replied": 5
  },
  "sendWindow": { "evaluated": 0, "fallback": 0 },
  "rates": {
    "openRate": 0.4487179487179487,
    "clickRate": 0.057692307692307696,
    "replyRate": 0.028846153846153848,
    "bounceRate": 0.01282051282051282
  },
  "variantSteps": [],
  "activity": [ { "date": "2026-07-02", "sent": 40, "replies": 1, "bounced": 0 } ]
}
```

`rates` are fractions between 0 and 1, computed from `emails.sent` (0 if nothing has sent yet). There is no `delivered` count. `opened` and `clicked` only mean something when the campaign has open and click tracking on. `sendWindow` counts queued and active leads that have had a recipient-local send-window decision, and how many of them fell back to the sender's window. `variantSteps` holds per-variant results for steps with A/B variants, and `activity` is the daily sends, replies and bounces for the last 30 days.

## Steps

Steps are the ordered sequence of touches (email, LinkedIn) leads move through, with a delay before each one.

### List steps

```text
GET /campaigns/{id}/steps
```

`steps` is the sequence in send order. This is the field to read. The response also carries `nodes`, `edges` and `entryStepId`, the same steps as a graph, which the dashboard's visual editor uses; you can ignore them.

A step's `waitDays` / `waitHours` is the delay **before** that step sends, counted from the previous step (the first step's delay is counted from when the lead enters the campaign).

```json
{
  "steps": [
    {
      "id": "c1b2a3d4-1111-4b7a-8e2a-1a2b3c4d5e6f",
      "campaignId": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f",
      "stepOrder": 1,
      "kind": "email",
      "subject": "Quick question about {{companyName}}",
      "bodyText": "Hi {{firstName}}, ...",
      "bodyHtml": null,
      "waitDays": 0,
      "waitHours": 0,
      "variants": [],
      "createdAt": "2026-06-01T09:00:00.000Z"
    },
    {
      "id": "c1b2a3d4-3333-4b7a-8e2a-1a2b3c4d5e6f",
      "campaignId": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f",
      "stepOrder": 2,
      "kind": "email",
      "subject": "Following up",
      "bodyText": "Hi {{firstName}}, just bumping this...",
      "bodyHtml": null,
      "waitDays": 3,
      "waitHours": 0,
      "variants": [],
      "createdAt": "2026-06-01T09:00:00.000Z"
    }
  ],
  "nodes": ["…the same steps…"],
  "edges": ["…how they connect…"],
  "entryStepId": "c1b2a3d4-1111-4b7a-8e2a-1a2b3c4d5e6f"
}
```

Each step is the stored step row, so it has further fields than shown (for example `aiPersonalize`, `variantMinSampleSize`, and the editor's `positionX` / `positionY`), and `variants` lists any A/B variants. `kind` is one of `email`, `linkedin_connection`, `linkedin_message`.

### Replace steps

```text
PUT /campaigns/{id}/steps
```

Replace-all: send the whole sequence, in order, as `steps` (up to 50 entries). `stepOrder` is assigned from array position, don't send it yourself.

| Field | Type | Required |
| --- | --- | --- |
| `kind` | `"email" \\| "wait" \\| "linkedin_connection" \\| "linkedin_message"` | no (default `"email"`) |
| `subject` | string or `null` | no (needed for an email step to send) |
| `bodyText` | string or `null` | no (needed for an email step to send) |
| `bodyHtml` | string or `null` | no |
| `waitDays` | integer (0-365) | no (default `0`) |
| `waitHours` | integer (0-23) | no (default `0`) |

Two ways to space steps out, and you can mix them:

- put `waitDays` / `waitHours` on the step itself, or
- insert a `{ "kind": "wait", "waitDays": 3 }` entry between two steps.

A `wait` entry is not stored as a step of its own. Its delay is added to the next step, so `GET` returns the example below as **two** steps, the second with `waitDays: 3`. A `wait` at the very end is dropped (there is nothing after it to delay).

Subjects and bodies support merge tags: `{{firstName}}`, `{{lastName}}`, `{{companyName}}`, `{{email}}`, the sender's `{{senderFirstName}}` / `{{senderName}}`, and any `customVars` key you set on the campaign's leads.

**Editing a running campaign:** the Nth email step you send keeps the id of the campaign's existing Nth email step, so rewriting the copy of a live sequence does not move leads off the step they are on. On an active or paused campaign, removing a step that leads are currently waiting at is refused with a `409` (`step_in_use`, with the step ids and lead counts in `error.details`) and nothing is changed.

```bash
curl -X PUT https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/steps   -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"   -H "Content-Type: application/json"   -d '{
    "steps": [
      { "kind": "email", "subject": "Quick question about {{companyName}}", "bodyText": "Hi {{firstName}}, ..." },
      { "kind": "wait", "waitDays": 3 },
      { "kind": "email", "subject": "Following up", "bodyText": "Hi {{firstName}}, just bumping this..." }
    ]
  }'
```

Returns the saved sequence in the same shape as `GET /campaigns/{id}/steps`.

| Status | `error.code` | When |
| --- | --- | --- |
| `400` | `bad_request` | The body is not a valid `steps` array (the details name the field) |
| `404` | `not_found` | The campaign doesn't exist or isn't yours |
| `409` | `step_in_use` | You removed a step leads are currently waiting at, on an active or paused campaign |

## Senders (email)

Mailboxes (connected [Accounts](https://warmerly.com/docs/accounts)) that send this campaign's emails, rotated in round-robin to spread volume and protect deliverability.

### List senders

```text
GET /campaigns/{id}/senders
```

`provider` and `status` are the mailbox's own values (see [the account object](https://warmerly.com/docs/accounts#the-account-object)). If the campaign is still a draft with no senders, this call first attaches the project's healthy mailboxes that can send campaigns, so the list is not always empty.

```json
{
  "senders": [
    {
      "id": "d1e2f3a4-1111-4b7a-8e2a-1a2b3c4d5e6f",
      "accountId": "a9b8c7d6-1111-4b7a-8e2a-1a2b3c4d5e6f",
      "email": "kris@yourdomain.com",
      "provider": "gmail",
      "status": "active",
      "healthScore": 92,
      "sentToday": 12,
      "sentTotal": 340,
      "lastSentAt": "2026-07-02T09:41:00.000Z"
    }
  ]
}
```

### Replace senders

```text
PUT /campaigns/{id}/senders
```

Replace-all: pass the full set of `accountIds` (UUIDs) that should send this campaign. An empty array removes every sender. The mailboxes can be in any project of the campaign's workspace; an id that is not reachable returns `400 bad_request` (`invalid_account_ids`). A sender that is not allowed to send campaigns at all (for example a free mailbox while sending from free mailboxes is switched off) is refused with `409` and code `sender_cannot_send_campaigns`, and nothing is changed.

```bash
curl -X PUT https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/senders \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "accountIds": ["a9b8c7d6-1111-4b7a-8e2a-1a2b3c4d5e6f"] }'
```

```json
{ "ok": true }
```

## LinkedIn senders

Connected LinkedIn accounts used for `linkedin_connection` / `linkedin_message` steps.

### List LinkedIn senders

```text
GET /campaigns/{id}/linkedin-senders
```

```json
{
  "senders": [
    {
      "id": "e1f2a3b4-1111-4b7a-8e2a-1a2b3c4d5e6f",
      "linkedinAccountId": "f1a2b3c4-1111-4b7a-8e2a-1a2b3c4d5e6f",
      "sentToday": 4,
      "sentTotal": 61,
      "lastSentAt": "2026-07-02T09:10:00.000Z",
      "account": { "id": "f1a2b3c4-1111-4b7a-8e2a-1a2b3c4d5e6f", "...": "..." }
    }
  ]
}
```

### Add a LinkedIn sender

```text
POST /campaigns/{id}/linkedin-senders
```

`linkedinAccountId` must be a LinkedIn account in the campaign's project (otherwise `404 not_found`, message `linkedin_account_not_found`); adding one that's already attached is a no-op (`sender` is `null` in the response).

```bash
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/linkedin-senders \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "linkedinAccountId": "f1a2b3c4-1111-4b7a-8e2a-1a2b3c4d5e6f" }'
```

```json
{ "sender": { "id": "e1f2a3b4-1111-4b7a-8e2a-1a2b3c4d5e6f", "campaignId": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "linkedinAccountId": "f1a2b3c4-1111-4b7a-8e2a-1a2b3c4d5e6f", "...": "..." } }
```

### Replace LinkedIn senders

```text
PUT /campaigns/{id}/linkedin-senders
```

Replace-all, like the email senders: pass `{ "accountIds": ["..."] }` (LinkedIn account UUIDs in the campaign's project). An id that is not reachable returns `400 bad_request` (`invalid_linkedin_account_ids`). Returns `{ "ok": true }`.

### Remove a LinkedIn sender

```text
DELETE /campaigns/{id}/linkedin-senders/{sid}
```

`{sid}` is the sender row's `id` (not the LinkedIn account's `id`).

```bash
curl -X DELETE https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/linkedin-senders/e1f2a3b4-1111-4b7a-8e2a-1a2b3c4d5e6f \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "deleted": true }
```

## Leads

### List leads

```text
GET /campaigns/{id}/leads
```

| Query param | Default | Max |
| --- | --- | --- |
| `limit` | `200` | `1000` |
| `offset` | `0` | , |
| `status` | all | One lead status; an unknown value is ignored |
| `q` | none | Search email, name or company |
| `sort` | `newest` | `newest`, `oldest` or `name` |

```bash
curl "https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads?limit=50" \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{
  "leads": [
    {
      "id": "11111111-2222-4333-8444-555555555555",
      "campaignId": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f",
      "leadId": null,
      "email": "jane@acme.com",
      "firstName": "Jane",
      "lastName": "Doe",
      "companyName": "Acme Inc",
      "linkedinProfileId": null,
      "linkedinProfileUrl": null,
      "linkedinName": null,
      "linkedinProfilePictureUrl": null,
      "linkedinHeadline": null,
      "linkedinCompany": null,
      "phone": null,
      "linkedinPostsCache": null,
      "customVars": { "role": "Head of Growth" },
      "status": "queued",
      "currentStep": 0,
      "nextRunAt": null,
      "repliedAt": null,
      "bouncedAt": null,
      "unsubscribedAt": null,
      "createdAt": "2026-06-15T12:00:00.000Z",
      "sentCount": 1,
      "lastSentAt": "2026-06-16T09:00:00.000Z",
      "openedCount": 0,
      "lastRepliedAt": null
    }
  ],
  "total": 240,
  "offset": 0,
  "limit": 50,
  "counts": { "all": 240, "queued": 168, "active": 21 },
  "research": { "researchable": 240, "researched": 12 }
}
```

`status` is one of `queued`, `active`, `completed`, `replied`, `bounced`, `unsubscribed`, `skipped`. Each lead is the stored row (so it also has fields such as `researchUrl`, `country` and `verificationStatus`) plus `sentCount`, `lastSentAt`, `openedCount` and `lastRepliedAt`. `total` is the count for the current filter, `counts` is per status across the whole campaign, and `research` counts leads that can be researched and leads that already have a website summary.

### Add leads

```text
POST /campaigns/{id}/leads
```

Bulk-insert 1 to 10,000 leads at once. Leads are deduped by lowercased `email` within the request and against existing leads in the campaign (`ON CONFLICT DO NOTHING` on `(campaignId, email)`), duplicates are silently skipped, not errored. Addresses that a free verification check proves invalid are not added and are counted in `rejected`.

| Field | Type | Required |
| --- | --- | --- |
| `leads[].email` | string (valid email) | yes |
| `leads[].firstName` | string or `null` | no |
| `leads[].lastName` | string or `null` | no |
| `leads[].companyName` | string or `null` | no |
| `leads[].customVars` | object of string → string | no |
| `leads[].researchUrl` | string (URL) or `null` | no |
| `leads[].country` | string (up to 8 characters) or `null` | no |
| `leads[].region` | string (up to 120 characters) or `null` | no |

```bash
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "leads": [
      { "email": "jane@acme.com", "firstName": "Jane", "lastName": "Doe", "companyName": "Acme Inc", "customVars": { "role": "Head of Growth" } },
      { "email": "bob@widgets.io", "firstName": "Bob" }
    ]
  }'
```

```json
{ "added": 2, "skipped": 0, "rejected": 0 }
```

A billing-locked workspace returns `402 billing_locked`. Going over your plan's leads-per-campaign ceiling refuses the whole request with `402` and code `campaign_lead_limit`.

### Remove leads

```text
DELETE /campaigns/{id}/leads
```

With no body, deletes every lead attached to the campaign. Send `{ "leadIds": ["..."] }` (1 to 10,000 UUIDs) to delete only those leads. A single lead can also be removed with `DELETE /campaigns/{id}/leads/{leadId}`, which returns `{ "ok": true }` (or `404 not_found`).

```bash
curl -X DELETE https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "deleted": 240 }
```

### Update a lead's email

```text
PATCH /campaigns/{id}/leads/{leadId}
```

Send at least one of `email` (a valid address, saved lowercased), `researchUrl` (a URL up to 2048 characters, or `null`) or `websiteSummary` (text up to 4000 characters, replaces the lead's website research summary). `email` is the usual use: fixing a bad address found via [Find emails](https://warmerly.com/docs/campaigns#find-emails-for-linkedin-leads) or manual correction.

```bash
curl -X PATCH https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads/11111111-2222-4333-8444-555555555555 \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane.doe@acme.com" }'
```

```json
{ "lead": { "id": "11111111-2222-4333-8444-555555555555", "email": "jane.doe@acme.com", "...": "..." } }
```

If another lead in the same campaign already has that email, returns `400 bad_request` (`"Another lead in this campaign already has that email"`).

### Import LinkedIn leads

```text
POST /campaigns/{id}/leads/import-linkedin
```

Bulk-imports leads from LinkedIn profile data (e.g. a Sales Navigator search or connections list). Dedupes against existing leads by `linkedinProfileId`. If the campaign already has at least one email [sender](https://warmerly.com/docs/campaigns#senders-email) attached, it immediately attempts to find email addresses for imported leads that have a `company_name` (best-effort, synchronous). Each lookup uses your email lookup allowance; when it runs short the import still succeeds and the remaining leads are counted in `emailLookupsSkipped`.

| Field | Type | Required |  |
| --- | --- | --- | --- |
| `leads[].id` | string | yes | LinkedIn provider ID (`public_identifier`'s underlying id) |
| `leads[].public_identifier` | string | yes |  |
| `leads[].name` | string | yes | Split into `firstName`/`lastName` |
| `leads[].headline` | string or `null` | no |  |
| `leads[].location` | string or `null` | no | Accepted but not stored |
| `leads[].profile_picture_url` | string or `null` | no |  |
| `leads[].company_name` | string or `null` | no | Used for the email lookup |
| `leads[].profile_url` | string | yes |  |

1 to 1,000 leads per request. Going over your plan's leads-per-campaign ceiling refuses the request with `402` and code `campaign_lead_limit`.

```bash
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads/import-linkedin \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "leads": [
      {
        "id": "ACoAAA1234567",
        "public_identifier": "jane-doe-1234",
        "name": "Jane Doe",
        "headline": "Head of Growth at Acme",
        "profile_picture_url": "https://media.licdn.com/...",
        "company_name": "Acme Inc",
        "profile_url": "https://www.linkedin.com/in/jane-doe-1234"
      }
    ]
  }'
```

```json
{ "imported": 1, "skipped": 0, "emailsFound": 1, "emailLookupsSkipped": 0 }
```

### Find emails for LinkedIn leads

```text
POST /campaigns/{id}/leads/find-emails
```

Kicks off email lookups for every lead in the campaign that has a `linkedinProfileUrl`, no `email` and no earlier lookup attempt, then returns immediately, lookups continue running server-side after the response, subject to your plan's lookup quota.

```bash
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads/find-emails \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```json
{ "started": 47, "skipped": 0 }
```

`skipped` is nonzero if your remaining lookup quota is smaller than the number of eligible leads, only the leads that fit in the remaining quota are started. If your quota is fully exhausted, returns `400 bad_request` with the quota details in `error.details`. With no eligible leads it returns `{ "started": 0, "skipped": 0 }`.

## Readiness check

```text
GET /campaigns/{id}/readiness
```

Everything that would stop this campaign sending, as a list of checks, the same set the dashboard shows before you press start, and worth calling before [`POST /campaigns/{id}/start`](https://warmerly.com/docs/campaigns#start-a-campaign) so a failure is explained rather than guessed at.

```json
{
  "ready": false,
  "checks": [
    { "key": "has_steps", "label": "Message sequence has at least one complete step", "ok": true },
    { "key": "senders.email", "label": "At least one email sender connected", "ok": true },
    { "key": "plan_gates.send_limit", "label": "Monthly campaign send limit not reached", "ok": true },
    {
      "key": "sender_identity",
      "label": "Sender postal address set for the email footer",
      "ok": false,
      "severity": "warning",
      "detail": "Campaigns send without one, but the footer won't carry it."
    },
    { "key": "leads", "label": "At least one lead queued to receive the sequence", "ok": false, "detail": "Add at least one lead" }
  ],
  "usesEmail": true,
  "usesLinkedin": false
}
```

A check with `severity: "warning"` does **not** block sending, `ready` can be `true` with warnings present (starting then needs `acknowledgeWarnings`, see [Start a campaign](https://warmerly.com/docs/campaigns#start-a-campaign)). Anything without it is a blocker. Further copy and deliverability checks appear in the list too, such as `merge_tags`, `brief_for_opener`, `lead_research`, `deliverability` and `content_quality`. Unlike `start`, this call does not run the live AI provider check. For a draft with no senders it also attaches the project's healthy mailboxes that can send campaigns. The `usesX` flags say which channels the sequence actually uses, so a LinkedIn-free campaign is never held up by a LinkedIn check.

## Preview a rendered message

```text
POST /campaigns/{id}/preview
```

Renders one step exactly as it would send, merge fields resolved against a real lead, AI opener included, sender identity and footer applied, without sending anything.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `stepOrder` | integer (1-based) | One of `stepOrder`/`stepId` | Position of the step in the sequence. Prefer this: `PUT /campaigns/{id}/steps` replaces all steps, so step ids change on every save while positions survive. |
| `stepId` | string (UUID) | One of `stepOrder`/`stepId` | A specific step. Exactly one of the two must be given. |
| `leadId` | string (UUID) | No | Render against this lead. Defaults to the next lead due out. |
| `randomLead` | boolean | No | Render against a random queued lead instead of the next one. |
| `regenerate` | boolean | No | Bypass the cached AI opener and generate a fresh one. Spends AI credits. |

```json
{
  "from": { "name": "Jane from Acme", "address": "jane@acme.com" },
  "to": "lead@example.com",
  "lead": { "id": "…", "email": "lead@example.com", "companyName": "Example Ltd", "firstName": "Sam" },
  "blankTags": [],
  "noLinkMode": false,
  "noLinkMessage": null,
  "subject": "Quick question about Example Ltd",
  "text": "…",
  "html": "<p>…</p>",
  "unsubscribeUrl": "https://…",
  "aiUsed": false,
  "openerCached": false,
  "hasResearch": false,
  "openerFailure": null
}
```

`blankTags` lists merge tags that came out empty for this lead. `openerFailure` is non-null when this step would not be sent to this lead as it stands. The step must be an email step (`400` otherwise, `404` if the step does not exist). With no usable lead or no sender it returns `400 bad_request` with `error.details.reason` `no_leads` or `no_sender`. The default lead is the oldest queued one, or any lead with an email once the queue is empty.

## Duplicate a campaign

```text
POST /campaigns/{id}/duplicate
```

Copies the campaign as a new **draft** named `<name> (copy)`: every setting (sending window, daily limit, ramp, tracking, stop-on-reply, brief), the step sequence and its branching edges, and the email, LinkedIn and WhatsApp sender assignments. Step A/B variants are not copied, and an email sender that is not allowed to send campaigns (a free mailbox, while sending from free mailboxes is switched off) is left off the copy.

**Leads and send history are not copied.** The copy starts with an empty audience and `startedAt: null`, so duplicating a running campaign cannot re-mail anyone, add leads, then start it. Returns `201` with the new campaign.

## Leads across every campaign

```text
GET /campaigns/leads
```

One flat, paged view of every lead in the project's campaigns, rather than one campaign at a time. Project-scoped (`X-Project-Id`).

| Query param | Type | Description |
| --- | --- | --- |
| `campaignId` | string (UUID) | Restrict to one campaign. |
| `status` | lead status | Filter by lead status. An unknown value is ignored. |
| `q` | string | Search name, email or company. |
| `limit` | integer | Page size, 1 to 200, default 50. |
| `offset` | integer | Rows to skip, default 0. |

```json
{
  "leads": [{ "id": "…", "email": "lead@example.com", "firstName": null, "lastName": null, "companyName": null, "linkedinName": null, "linkedinHeadline": null, "status": "queued", "createdAt": "2026-06-15T12:00:00.000Z", "campaignId": "…", "campaignName": "Q4 outbound" }],
  "total": 412,
  "byStatus": { "queued": 380, "replied": 17 },
  "campaigns": [{ "id": "…", "name": "Q4 outbound" }]
}
```

`total` and `byStatus` count every lead in the project's campaigns, not only the rows matching `campaignId`, `status` or `q`. `campaigns` is the project's own campaigns, so a UI can render the filter without a second call. A project with no campaigns returns `{ "leads": [], "total": 0, "campaigns": [] }`, not an error.

## Per-lead enrichment and history

```text
GET  /campaigns/{id}/leads/{leadId}/emails
POST /campaigns/{id}/leads/{leadId}/retry-enrichment
POST /campaigns/{id}/leads/research
```

`emails` returns `{ "emails": [...] }`: every message scheduled for that lead in this campaign, ordered by scheduled time, the audit trail for "what did we actually say to this person". Each has `id`, `stepId`, `status`, `subject`, `bodyText`, `bodyHtml`, `scheduledAt`, `sentAt`, `openedAt`, `clickedAt`, `repliedAt`, `bouncedAt` and `error`.

`retry-enrichment` re-runs enrichment (contact-info check, then the paid email-finder fallback) for one lead whose earlier attempt failed or found nothing. Automatic enrichment fires only once per lead, so this is the only way to give a failed lookup another go. It needs a lead that has a LinkedIn profile URL (`404` otherwise), uses the **email lookup** allowance and returns `402` with code `quota_exhausted` when that is exhausted. It returns `{ "started": true }` straight away and the lookup continues on the server.

`research` generates website research summaries, which feed personalisation, for up to **20 leads per call**: pass `{ "leadIds": ["…"], "force": false }` (1 to 20 UUIDs). It is idempotent per lead: leads that already have a fresh summary are skipped unless `force` is `true`. The response is `{ "results": [{ "leadId", "ok", "summary", "reason" }], "generated", "total" }`, so a partial failure is visible rather than silent. It spends AI credits ([Usage & limits](https://warmerly.com/docs/usage)).

## Errors

Common errors specific to campaign endpoints:

| Status | `code` | When |
| --- | --- | --- |
| `400` | `bad_request` | Invalid body, invalid campaign/lead id, or a `start` readiness check failed |
| `403` | `forbidden` | Authenticated user isn't a member of the campaign's workspace |
| `402` | `payment_required` | A LinkedIn campaign needs an active standalone LinkedIn slot, or the monthly campaign send limit is reached |
| `402` | `plan_capacity` | Starting would exceed your plan's active-campaign limit |
| `402` | `campaign_lead_limit` | Adding leads would exceed your plan's leads-per-campaign ceiling |
| `402` | `quota_exhausted` | A metered allowance is used up (for example email lookups) |
| `402` | `billing_locked` | The workspace's billing needs attention |
| `404` | `not_found` | Campaign, lead, or LinkedIn sender not found |
| `409` | `conflict` | Pause or resume from the wrong status |
| `409` | `sender_cannot_send_campaigns` | A sender is not allowed to send campaigns at all (a free mailbox, while free-mailbox sending is off) |

```json
{ "error": { "code": "payment_required", "message": "Purchase a $19/month LinkedIn slot before starting this campaign.", "details": { "upgradeUrl": "/settings/billing" } } }
```

## Each part of this reference has its own page (18)

- [The campaign object](https://warmerly.com/docs/campaigns/the-campaign-object)
- [List campaigns](https://warmerly.com/docs/campaigns/list-campaigns)
- [Create a campaign](https://warmerly.com/docs/campaigns/create-a-campaign)
- [Get a campaign](https://warmerly.com/docs/campaigns/get-a-campaign)
- [Update a campaign](https://warmerly.com/docs/campaigns/update-a-campaign)
- [Delete a campaign](https://warmerly.com/docs/campaigns/delete-a-campaign)
- [Start a campaign](https://warmerly.com/docs/campaigns/start-a-campaign)
- [Pause a campaign](https://warmerly.com/docs/campaigns/pause-a-campaign)
- [Resume a campaign](https://warmerly.com/docs/campaigns/resume-a-campaign)
- [Stats](https://warmerly.com/docs/campaigns/stats)
- [Steps](https://warmerly.com/docs/campaigns/steps)
- [Senders (email)](https://warmerly.com/docs/campaigns/senders-email)
- [LinkedIn senders](https://warmerly.com/docs/campaigns/linkedin-senders)
- [Leads](https://warmerly.com/docs/campaigns/leads)
- [Readiness check](https://warmerly.com/docs/campaigns/readiness-check)
- [Preview a rendered message](https://warmerly.com/docs/campaigns/preview-a-rendered-message)
- [Leads across every campaign](https://warmerly.com/docs/campaigns/leads-across-every-campaign)
- [Per-lead enrichment and history](https://warmerly.com/docs/campaigns/per-lead-enrichment-and-history)

## More in API reference

- [Accounts](https://warmerly.com/docs/accounts): API reference for Warmerly accounts (mailboxes): connect Gmail, Outlook, or custom SMTP/IMAP inboxes and…
- [Warmup](https://warmerly.com/docs/warmup): API reference for Warmerly warmup: pull mailbox health scores and the inbox-placement emails sent between…
- [Verify](https://warmerly.com/docs/verify): API reference for Warmerly's email verification: check syntax, MX records, disposable/role addresses, and…
