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

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

FieldType
namestring (1-200 chars)
status"draft" | "active" | "paused" | "completed" | "archived"
dailyLimitinteger (1-1000)
stopOnReplyboolean
stopOnAutoReplyboolean
trackOpensboolean
trackClicksboolean
sendingDaysinteger[] (0-6, 0 = Sunday)
sendingHourStartinteger (0-23)
sendingHourEndinteger (0-23)
timezonestring
minWaitMinutesinteger (0-1440)
assignedToUserIdUUID or null
targetRepliesinteger or null
targetLeadsinteger 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).

FieldTypeRequired
kind"email" | "wait" | "linkedin_connection" | "linkedin_message" | "whatsapp_message"no (default "email")
subjectstring or nullno
bodyTextstring or nullno
bodyHtmlstring or nullno
waitDaysinteger (0-365)no (default 0)
waitHoursinteger (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 paramDefaultMax
limit2001000
offset0
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.

FieldTypeRequired
leads[].emailstring (valid email)yes
leads[].firstNamestring or nullno
leads[].lastNamestring or nullno
leads[].companyNamestring or nullno
leads[].customVarsobject of string → stringno
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).

FieldTypeRequired
leads[].idstringyes
leads[].public_identifierstringyes
leads[].namestringyes
leads[].headlinestring or nullno
leads[].locationstring or nullno
leads[].profile_picture_urlstring or nullno
leads[].company_namestring or nullno
leads[].profile_urlstringyes

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:

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
404not_foundCampaign, lead, or LinkedIn sender not found
402payment_requiredMonthly campaign send limit reached
{ "error": { "code": "payment_required", "message": "Purchase a $50/month LinkedIn slot before starting this campaign.", "details": { "upgradeUrl": "/settings/billing" } } }