# Leads

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

Source: https://warmerly.com/docs/campaigns/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/leads#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/leads#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 }`.

## More from Campaigns

- [LinkedIn senders](https://warmerly.com/docs/campaigns/linkedin-senders): Connected LinkedIn accounts used for linkedinconnection / linkedinmessage steps.
- [Readiness check](https://warmerly.com/docs/campaigns/readiness-check): Everything that would stop this campaign sending, as a list of checks, the same set the dashboard shows…
- [Preview a rendered message](https://warmerly.com/docs/campaigns/preview-a-rendered-message): Renders one step exactly as it would send, merge fields resolved against a real lead, AI opener included,…
