<!-- Campaigns — https://docs.warmerly.com/campaigns -->

# Campaigns

A campaign is a multi-step outbound sequence (email, LinkedIn, or a mix) sent to a list
of leads on a schedule. WhatsApp steps can be added to a sequence, but WhatsApp sending is not
available for campaigns yet: the launch checklist blocks a campaign that contains one until
you remove it (see [WhatsApp](https://docs.warmerly.com/help/whatsapp-outreach)). 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": true,
  "trackClicks": true,
  "sendingDays": [1, 2, 3, 4, 5],
  "sendingHourStart": 9,
  "sendingHourEnd": 17,
  "timezone": "UTC",
  "minWaitMinutes": 10,
  "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`.

## List campaigns

```
GET /campaigns
```

Requires `X-Project-Id`. Returns every campaign in the project with live counters
joined in (lead/email totals), most recently created first.

```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,
      "targetReplies": null,
      "targetLeads": null,
      "totalLeads": 240,
      "completedLeads": 51,
      "sentCount": 312,
      "clickCount": 18,
      "repliedCount": 9,
      "opportunityCount": 9
    }
  ]
}
```

## Create a campaign

```
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](#steps)).

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

## Get a campaign

```
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", "...": "..." } }
```

## Update a campaign

```
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-1000) |
| `stopOnReply` | boolean |
| `stopOnAutoReply` | boolean |
| `trackOpens` | boolean |
| `trackClicks` | boolean |
| `sendingDays` | integer[] (0-6, 0 = Sunday) |
| `sendingHourStart` | integer (0-23) |
| `sendingHourEnd` | integer (0-23) |
| `timezone` | string |
| `minWaitMinutes` | integer (0-1440) |
| `assignedToUserId` | UUID or `null` |
| `targetReplies` | integer or `null` |
| `targetLeads` | integer or `null` |

`status: "paused"` and `status: "active"` behave exactly like
[pause](#pause-a-campaign) and [resume](#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. 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`](#start-a-campaign), [`pause`](#pause-a-campaign), and
[`resume`](#resume-a-campaign) actions below, which also validate that the campaign
is actually ready to send.

## Delete a campaign

```
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

```
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). Checks performed:

- At least one step exists that is a valid email step (`subject` + `bodyText`), a
  LinkedIn step (`linkedin_connection` / `linkedin_message`). A WhatsApp step counts as a
  step, but WhatsApp sending is not available for campaigns yet, so its sender check fails.
- 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](#senders-email), LinkedIn → [linkedin-senders](#linkedin-senders)).
- At least one lead is `queued`.

```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"`). Missing plan access returns
`403 channel_not_included`; hitting the monthly send cap returns
`402 payment_required`.

## Pause a campaign

```
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

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

Sets a **paused** campaign back to `active`; any other status returns
`409 conflict` (use `start` instead). Re-checks 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

```
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,
    "delivered": 305,
    "opened": 140,
    "clicked": 18,
    "replied": 9,
    "bounced": 4,
    "failed": 1
  },
  "linkedin": {
    "connectionsSent": 40,
    "accepted": 22,
    "messagesSent": 22,
    "replied": 5
  },
  "rates": {
    "openRate": 0.4487179487179487,
    "clickRate": 0.057692307692307696,
    "replyRate": 0.028846153846153848,
    "bounceRate": 0.01282051282051282
  }
}
```

`rates` are computed from `emails.sent` (0 if nothing has sent yet).

## Steps

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

### List steps

```
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"
}
```

`kind` is one of `email`, `linkedin_connection`, `linkedin_message`, `whatsapp_message`.
`whatsapp_message` is accepted and saved, but a campaign containing one fails the launch
checklist (WhatsApp sending is not available for campaigns yet), so remove it before launching.

### Replace steps

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

Replace-all: send the whole sequence, in order, as `steps`. `stepOrder` is assigned from array
position — don't send it yourself.

| Field | Type | Required |
| --- | --- | --- |
| `kind` | `"email" \| "wait" \| "linkedin_connection" \| "linkedin_message" \| "whatsapp_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. Removing a step that leads are currently waiting at is refused with a
`409` (`step_in_use`) 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 |

## Senders (email)

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

### List senders

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

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

### Replace senders

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

Replace-all: pass the full set of `accountId`s that should send this campaign.

```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

```
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

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

`linkedinAccountId` must belong to the authenticated user; 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", "...": "..." } }
```

### Remove a LinkedIn sender

```
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

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

| Query param | Default | Max |
| --- | --- | --- |
| `limit` | `200` | `1000` |
| `offset` | `0` | — |

```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"
    }
  ]
}
```

`status` is one of `queued`, `active`, `completed`, `replied`, `bounced`,
`unsubscribed`, `skipped`.

### Add leads

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

Bulk-insert up 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.

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

```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 }
```

### Remove all leads

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

Deletes every lead attached to the campaign (no per-lead delete endpoint exists —
use this to clear the list, then re-add).

```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

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

The only mutable field on an individual lead is `email` — used to fix a bad address
found via [Find emails](#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

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

Bulk-imports leads from LinkedIn profile data (e.g. a Sales Navigator search or
connections list pulled via [Accounts](https://docs.warmerly.com/accounts) LinkedIn integration). Dedupes
against existing leads by `linkedinProfileId`. If the campaign already has at least
one email [sender](#senders-email) attached, it immediately attempts to find email
addresses for the imported leads (best-effort, synchronous).

| 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 | |
| `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 | |

Up to 1,000 leads per request.

```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 }
```

### Find emails for LinkedIn leads

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

Kicks off email lookups for every lead in the campaign that has a
`linkedinProfileUrl` but no `email`, 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`.

## Readiness check

```
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`](#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.queued", "label": "At least one lead queued", "ok": false }
  ],
  "usesEmail": true,
  "usesLinkedin": false,
  "usesWhatsapp": false
}
```

A check with `severity: "warning"` does **not** block sending — `ready` can be `true` with
warnings present. Anything without it is a blocker. 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

```
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": "…", "companyName": "Example Ltd", "firstName": "Sam" },
  "subject": "Quick question about Example Ltd",
  "text": "…",
  "html": "<p>…</p>"
}
```

## Duplicate a campaign

```
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), the full step sequence and its branching edges,
and the email/LinkedIn sender assignments.

**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

```
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. |
| `q` | string | Search name, email or company. |
| `limit` / `offset` | integer | Paging. |

```json
{
  "leads": [{ "id": "…", "email": "lead@example.com", "status": "queued", "campaignId": "…", "campaignName": "Q4 outbound" }],
  "total": 412,
  "byStatus": { "queued": 380, "replied": 17 },
  "campaigns": [{ "id": "…", "name": "Q4 outbound" }]
}
```

`campaigns` is the project's own campaigns, so a UI can render the filter without a second
call. A project with no campaigns returns empty arrays, not an error.

## Per-lead enrichment and history

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

`emails` returns every message sent to that lead in this campaign, in order — the audit
trail for "what did we actually say to this person".

`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
debits the **email lookup** allowance and returns `402 payment_required` when that is
exhausted.

`research` generates website research summaries, which feed personalisation, for up to **20
leads per call** — pass `{ "leadIds": ["…"], "force": false }`. It is idempotent per lead:
leads that already have a fresh summary are skipped unless `force` is `true`. The response
reports a per-lead result so a partial failure is visible rather than silent. It spends AI
credits ([Usage & limits](https://docs.warmerly.com/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 |
| `404` | `not_found` | Campaign, lead, or LinkedIn sender not found |
| `402` | `payment_required` | Monthly campaign send limit reached |

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