Campaigns
A campaign is a multi-step outbound sequence — email, LinkedIn, WhatsApp, 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": 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.
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,
"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).
| 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 |
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.
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", "...": "..." } }
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 |
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}/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), or a WhatsApp step. - 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.
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"). 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 status to paused. 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}/resume
Sets status back to active. Re-checks 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}/stats
Aggregated 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,
"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, wait, LinkedIn, WhatsApp) leads move through.
List steps
GET /campaigns/{id}/steps
{
"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,
"createdAt": "2026-06-01T09:00:00.000Z"
},
{
"id": "c1b2a3d4-2222-4b7a-8e2a-1a2b3c4d5e6f",
"campaignId": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f",
"stepOrder": 2,
"kind": "wait",
"subject": null,
"bodyText": null,
"bodyHtml": null,
"waitDays": 3,
"waitHours": 0,
"createdAt": "2026-06-01T09:00:00.000Z"
},
{
"id": "c1b2a3d4-3333-4b7a-8e2a-1a2b3c4d5e6f",
"campaignId": "b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f",
"stepOrder": 3,
"kind": "email",
"subject": "Following up",
"bodyText": "Hi {{firstName}}, just bumping this...",
"bodyHtml": null,
"waitDays": 0,
"waitHours": 0,
"createdAt": "2026-06-01T09:00:00.000Z"
}
]
}
kind is one of email, wait, linkedin_connection, linkedin_message,
whatsapp_message.
Replace steps
PUT /campaigns/{id}/steps
Replace-all: this deletes every existing step for the campaign and inserts the
array you send, in order (stepOrder is assigned 1..n 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 |
bodyText | string or null | no |
bodyHtml | string or null | no |
waitDays | integer (0-365) | no (default 0) |
waitHours | integer (0-23) | no (default 0) |
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 new step list in the same shape as GET /campaigns/{id}/steps.
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}/senders
{
"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 accountIds that should send this campaign.
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-senders
linkedinAccountId must belong to the authenticated user; 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", "...": "..." } }
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 | — |
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"
}
]
}
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 |
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 }
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).
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}
The only mutable field on an individual lead is email — used to fix 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-linkedin
Bulk-imports leads from LinkedIn profile data (e.g. a Sales Navigator search or
connections list pulled via Accounts LinkedIn integration). 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 the imported leads (best-effort, synchronous).
| Field | Type | Required |
|---|---|---|
leads[].id | string | yes |
leads[].public_identifier | string | yes |
leads[].name | string | yes |
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 |
leads[].profile_url | string | yes |
Up to 1,000 leads per request.
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 }
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.
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.
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 |
{ "error": { "code": "payment_required", "message": "Purchase a $50/month LinkedIn slot before starting this campaign.", "details": { "upgradeUrl": "/settings/billing" } } }
