Less boo. More booked. Halloween offer: 30 days free on any paid plan. Claim 30 days freeEvery new account gets one free mailbox for warmup and replies. No domain to buy. See how it works

Skip to content

Campaigns

API reference23 min read20 sections

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

API reference

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

API reference

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

API reference

#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). The project's healthy mailboxes that can send campaigns are attached as senders automatically.

FieldTypeRequiredNotes
namestring (1-200 chars)yes
timezonestringnoIANA timezone string, default "UTC"
dailyLimitinteger (1-50000)noDefault 30
assignedToUserIdUUID or nullnoMust be a member of the campaign's workspace
targetRepliesinteger (0-1000000) or nullnoOptional goal shown against live stats
targetLeadsinteger (0-1000000) or nullnoOptional 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).

API reference

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

API reference

#Update a campaign

text
PATCH /campaigns/{id}

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

FieldType
namestring (1-200 chars)
status"draft" \| "active" \| "paused" \| "completed" \| "archived"
dailyLimitinteger (1-50000)
stopOnReplyboolean
stopOnAutoReplyboolean
trackOpensboolean
trackClicksboolean
plainTextOnlyboolean
sendingDaysinteger[] (0-6, 0 = Sunday)
sendingHourStartinteger (0-23)
sendingHourEndinteger (0-23)
timezonevalid IANA timezone string
recipientWindowHourStart / recipientWindowHourEndinteger (0-23)
minWaitMinutesinteger (0-1440)
rampEnabledboolean
rampStartPerSenderinteger (1-500)
rampMaxPerSenderinteger (1-500)
rampStepPerDayinteger (1-100)
assignedToUserIdUUID or null
targetRepliesinteger (0-1000000) or null
targetLeadsinteger (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 and resume: 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, pause, and resume actions below, which also validate that the campaign is actually ready to send.

API reference

#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 }
API reference

#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, 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, LinkedIn → 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.

API reference

#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", "...": "..." } }
API reference

#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", "...": "..." } }
API reference

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

API reference

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

FieldTypeRequired
kind"email" \| "wait" \| "linkedin_connection" \| "linkedin_message"no (default "email")
subjectstring or nullno (needed for an email step to send)
bodyTextstring or nullno (needed for an email step to send)
bodyHtmlstring or nullno
waitDaysinteger (0-365)no (default 0)
waitHoursinteger (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.

Statuserror.codeWhen
400bad_requestThe body is not a valid steps array (the details name the field)
404not_foundThe campaign doesn't exist or isn't yours
409step_in_useYou removed a step leads are currently waiting at, on an active or paused campaign
API reference

#Senders (email)

Mailboxes (connected 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). 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 }
API reference

#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 }
API reference

#Leads

#List leads

text
GET /campaigns/{id}/leads
Query paramDefaultMax
limit2001000
offset0,
statusallOne lead status; an unknown value is ignored
qnoneSearch email, name or company
sortnewestnewest, 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.

FieldTypeRequired
leads[].emailstring (valid email)yes
leads[].firstNamestring or nullno
leads[].lastNamestring or nullno
leads[].companyNamestring or nullno
leads[].customVarsobject of string → stringno
leads[].researchUrlstring (URL) or nullno
leads[].countrystring (up to 8 characters) or nullno
leads[].regionstring (up to 120 characters) or nullno
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 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 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.

FieldTypeRequired
leads[].idstringyesLinkedIn provider ID (public_identifier's underlying id)
leads[].public_identifierstringyes
leads[].namestringyesSplit into firstName/lastName
leads[].headlinestring or nullno
leads[].locationstring or nullnoAccepted but not stored
leads[].profile_picture_urlstring or nullno
leads[].company_namestring or nullnoUsed for the email lookup
leads[].profile_urlstringyes

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

API reference

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

API reference

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

FieldTypeRequiredDescription
stepOrderinteger (1-based)One of stepOrder/stepIdPosition 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.
stepIdstring (UUID)One of stepOrder/stepIdA specific step. Exactly one of the two must be given.
leadIdstring (UUID)NoRender against this lead. Defaults to the next lead due out.
randomLeadbooleanNoRender against a random queued lead instead of the next one.
regeneratebooleanNoBypass 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.

API reference

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

API reference

#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 paramTypeDescription
campaignIdstring (UUID)Restrict to one campaign.
statuslead statusFilter by lead status. An unknown value is ignored.
qstringSearch name, email or company.
limitintegerPage size, 1 to 200, default 50.
offsetintegerRows 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.

API reference

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

API reference

#Errors

Common errors specific to campaign endpoints:

StatuscodeWhen
400bad_requestInvalid body, invalid campaign/lead id, or a start readiness check failed
403forbiddenAuthenticated user isn't a member of the campaign's workspace
402payment_requiredA LinkedIn campaign needs an active standalone LinkedIn slot, or the monthly campaign send limit is reached
402plan_capacityStarting would exceed your plan's active-campaign limit
402campaign_lead_limitAdding leads would exceed your plan's leads-per-campaign ceiling
402quota_exhaustedA metered allowance is used up (for example email lookups)
402billing_lockedThe workspace's billing needs attention
404not_foundCampaign, lead, or LinkedIn sender not found
409conflictPause or resume from the wrong status
409sender_cannot_send_campaignsA 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" } } }
Was this page helpful?