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
{
"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
GET /campaignsRequires 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.
curl https://app.warmerly.com/api/v1/campaigns \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-Project-Id: 8f14e45f-ceea-4c6a-8c8d-6b1a5b5c9c1a"{
"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
POST /campaignsRequires 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.
| 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 |
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 }'{ "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
GET /campaigns/{id}curl https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{ "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
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 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.
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 }'{ "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.
#Delete a campaign
DELETE /campaigns/{id}Deletes the campaign and cascades to its steps, senders, leads, and send history. This cannot be undone.
curl -X DELETE https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{ "ok": true }#Start a campaign
POST /campaigns/{id}/startValidates 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.
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/start \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{ "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
POST /campaigns/{id}/pauseSets 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.
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/pause \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{ "campaign": { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "status": "paused", "...": "..." } }#Resume a campaign
POST /campaigns/{id}/resumeSets 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.
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/resume \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{ "campaign": { "id": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "status": "active", "...": "..." } }#Stats
GET /campaigns/{id}/statsAggregated counters and rates for the campaign.
curl https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/stats \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{
"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
GET /campaigns/{id}/stepssteps 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).
{
"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
PUT /campaigns/{id}/stepsReplace-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/waitHourson 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.
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) that send this campaign's emails, rotated in round-robin to spread volume and protect deliverability.
#List senders
GET /campaigns/{id}/sendersprovider 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.
{
"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
PUT /campaigns/{id}/sendersReplace-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.
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"] }'{ "ok": true }#LinkedIn senders
Connected LinkedIn accounts used for linkedin_connection / linkedin_message steps.
#List LinkedIn senders
GET /campaigns/{id}/linkedin-senders{
"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-senderslinkedinAccountId 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).
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" }'{ "sender": { "id": "e1f2a3b4-1111-4b7a-8e2a-1a2b3c4d5e6f", "campaignId": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f", "linkedinAccountId": "f1a2b3c4-1111-4b7a-8e2a-1a2b3c4d5e6f", "...": "..." } }#Replace LinkedIn senders
PUT /campaigns/{id}/linkedin-sendersReplace-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
DELETE /campaigns/{id}/linkedin-senders/{sid}{sid} is the sender row's id (not the LinkedIn account's id).
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"{ "deleted": true }#Leads
#List leads
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 |
curl "https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads?limit=50" \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{
"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
POST /campaigns/{id}/leadsBulk-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 |
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" }
]
}'{ "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
DELETE /campaigns/{id}/leadsWith 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).
curl -X DELETE https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{ "deleted": 240 }#Update a lead's email
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.
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" }'{ "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-linkedinBulk-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.
| 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.
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"
}
]
}'{ "imported": 1, "skipped": 0, "emailsFound": 1, "emailLookupsSkipped": 0 }#Find emails for LinkedIn leads
POST /campaigns/{id}/leads/find-emailsKicks 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.
curl -X POST https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f/leads/find-emails \
-H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"{ "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
GET /campaigns/{id}/readinessEverything 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.
{
"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.
#Preview a rendered message
POST /campaigns/{id}/previewRenders 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. |
{
"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
POST /campaigns/{id}/duplicateCopies 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
GET /campaigns/leadsOne 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. |
{
"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
GET /campaigns/{id}/leads/{leadId}/emails
POST /campaigns/{id}/leads/{leadId}/retry-enrichment
POST /campaigns/{id}/leads/researchemails 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).
#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) |
{ "error": { "code": "payment_required", "message": "Purchase a $19/month LinkedIn slot before starting this campaign.", "details": { "upgradeUrl": "/settings/billing" } } }