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
{
"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
/campaignsRequires 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
/campaignsRequires 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
/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
/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 |
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.
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
/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
/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). 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.
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
/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
/campaigns/{id}/resumeSets 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.
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
/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,
"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
/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"
}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
/campaigns/{id}/stepsReplace-all: send the whole sequence, in order, as steps. stepOrder is assigned 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 (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. Removing a step that leads are currently waiting at is refused with a
409 (step_in_use) 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 |
Senders (email)
Mailboxes (connected Accounts) that send this campaign's emails, rotated in round-robin to spread volume and protect deliverability.
List senders
/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
/campaigns/{id}/sendersReplace-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
/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
/campaigns/{id}/linkedin-senderslinkedinAccountId 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
/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
/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
/campaigns/{id}/leadsBulk-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
/campaigns/{id}/leadsDeletes 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
/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
/campaigns/{id}/leads/import-linkedinBulk-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
/campaigns/{id}/leads/find-emailsKicks 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.
Readiness check
/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.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
/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": "…", "companyName": "Example Ltd", "firstName": "Sam" },
"subject": "Quick question about Example Ltd",
"text": "…",
"html": "<p>…</p>"
}Duplicate a campaign
/campaigns/{id}/duplicateCopies 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
/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. |
q | string | Search name, email or company. |
limit / offset | integer | Paging. |
{
"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/researchemails 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:
| 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" } } }Summarize with AI

