Warmerlydocs
API reference

Campaigns

A campaign is a multi-step outbound sequence (email, LinkedIn, or a mix) sent to a list of leads on a schedule. WhatsApp steps can be added to a sequence, but WhatsApp sending is not available for campaigns yet: the launch checklist blocks a campaign that contains one until you remove it (see WhatsApp). 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

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": 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.

Shell
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,
      "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
Shell
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.

Get a campaign

GET/campaigns/{id}
Shell
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", "...": "..." } }

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

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. To launch a draft, completed or archived campaign, use POST /campaigns/{id}/start.

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

Delete a campaign

DELETE/campaigns/{id}

Deletes the campaign and cascades to its steps, senders, leads, and send history. This cannot be undone.

Shell
curl -X DELETE https://app.warmerly.com/api/v1/campaigns/b6a1e7b0-2f2b-4b7a-8e2a-1a2b3c4d5e6f \
  -H "X-Api-Key: wmv_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
JSON
{ "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). A WhatsApp step counts as a step, but WhatsApp sending is not available for campaigns yet, so its sender check fails.
  • 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.
Shell
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"). 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 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.

Shell
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", "...": "..." } }

Resume a campaign

POST/campaigns/{id}/resume

Sets a paused campaign back to active; any other status returns 409 conflict (use start instead). Re-checks your monthly campaign send limit (returns 402 payment_required if exhausted) but does not re-run the full start readiness checks.

Shell
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", "...": "..." } }

Stats

GET/campaigns/{id}/stats

Aggregated counters and rates for the campaign.

Shell
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,
    "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, LinkedIn) leads move through, with a delay before each one.

List steps

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

kind is one of email, linkedin_connection, linkedin_message, whatsapp_message. whatsapp_message is accepted and saved, but a campaign containing one fails the launch checklist (WhatsApp sending is not available for campaigns yet), so remove it before launching.

Replace steps

PUT/campaigns/{id}/steps

Replace-all: send the whole sequence, in order, as steps. stepOrder is assigned from array position — don't send it yourself.

FieldTypeRequired
kind"email" | "wait" | "linkedin_connection" | "linkedin_message" | "whatsapp_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. Removing a step that leads are currently waiting at is refused with a 409 (step_in_use) and nothing is changed.

Shell
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

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
JSON
{
  "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.

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

LinkedIn senders

Connected LinkedIn accounts used for linkedin_connection / linkedin_message steps.

List LinkedIn senders

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

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

Shell
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", "...": "..." } }

Remove a LinkedIn sender

DELETE/campaigns/{id}/linkedin-senders/{sid}

{sid} is the sender row's id (not the LinkedIn account's id).

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

Leads

List leads

GET/campaigns/{id}/leads
Query paramDefaultMax
limit2001000
offset0—
Shell
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"
    }
  ]
}

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

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

Shell
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

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.

Shell
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

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.

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

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.

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

Readiness check

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.queued", "label": "At least one lead queued", "ok": false }
  ],
  "usesEmail": true,
  "usesLinkedin": false,
  "usesWhatsapp": false
}

A check with severity: "warning" does not block sending — ready can be true with warnings present. Anything without it is a blocker. 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}/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": "…", "companyName": "Example Ltd", "firstName": "Sam" },
  "subject": "Quick question about Example Ltd",
  "text": "…",
  "html": "<p>…</p>"
}

Duplicate a campaign

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), the full step sequence and its branching edges, and the email/LinkedIn sender assignments.

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/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.
qstringSearch name, email or company.
limit / offsetintegerPaging.
JSON
{
  "leads": [{ "id": "…", "email": "lead@example.com", "status": "queued", "campaignId": "…", "campaignName": "Q4 outbound" }],
  "total": 412,
  "byStatus": { "queued": 380, "replied": 17 },
  "campaigns": [{ "id": "…", "name": "Q4 outbound" }]
}

campaigns is the project's own campaigns, so a UI can render the filter without a second call. A project with no campaigns returns empty arrays, 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/research

emails returns every message sent to that lead in this campaign, in order — the audit trail for "what did we actually say to this person".

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 debits the email lookup allowance and returns 402 payment_required when that is exhausted.

research generates website research summaries, which feed personalisation, for up to 20 leads per call — pass { "leadIds": ["…"], "force": false }. It is idempotent per lead: leads that already have a fresh summary are skipped unless force is true. The response reports a per-lead result so a partial failure is visible rather than silent. It spends AI credits (Usage & limits).

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
JSON
{ "error": { "code": "payment_required", "message": "Purchase a $50/month LinkedIn slot before starting this campaign.", "details": { "upgradeUrl": "/settings/billing" } } }

Summarize with AI

Markdown version for LLMsllms.txt

Something unclear or out of date? Ask Warmi in the chat, or email support@warmerly.com.