Files
warmbly/docs/content/docs/api/reference/campaigns.mdx
T

1499 lines
68 KiB
Plaintext

---
title: Campaigns
description: Create campaigns and sequences, manage senders, A/B variants, attachments, ramp and tracking settings, preflight checks, and start or stop sends.
---
Campaigns are the cold outreach unit in Warmbly. A campaign holds sending rules, a schedule, a sender pool, and an ordered list of sequence steps (email or action nodes). These endpoints cover campaign CRUD, the advanced outreach overrides, per-step A/B variants, attachments, the explicit sender pool, preflight and test sends, start and stop, activity logs, campaign-scoped tracking-domain verification, the nested sequence editor, the template preview helper, and the AI writing assistant.
All errors follow the shared `{error, message, code, request_id}` envelope documented in [error codes](/api/error-codes/). Authentication and the scope model are covered in [authentication](/api/authentication/) and [permissions](/api/permissions/).
## List campaigns
`GET /campaigns`
Search and page through the organization's campaigns. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `q` | query | string | Free-text filter on campaign name. Optional. |
| `folder` | query | string | Restrict to a single folder id. Optional. |
| `status` | query | string | Status bucket filter: `draft`, `active`, `paused` (matches every paused variant), or `completed`. Any other value returns `400`. Optional. |
| `cursor` | query | string | Opaque cursor from the previous page's `pagination.next_cursor`. Optional. |
| `limit` | query | string | Page size. Optional. |
### Response
A `data` plus `pagination` envelope. `next_cursor` is the campaign id to resume from (`null` on the last page), and `total` is the count matching the current `q`/`folder`/`status` filters.
```json
{
"data": [
{
"id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"user_id": "a2c4...",
"organization_id": "11111111-2222-3333-4444-555555555555",
"name": "Q3 outbound",
"description": "",
"status": "active",
"stop_on_reply": true,
"open_tracking": true,
"link_tracking": true,
"text_only": false,
"daily_limit": 50,
"unsubscribe_header": true,
"risky_emails": false,
"cc": [],
"bcc": [],
"start_date": null,
"end_date": null,
"timezone": "",
"effective_timezone": "America/New_York",
"days": 62,
"start_time": "09:00",
"end_time": "17:00",
"schedule_windows": [[],[{"start":540,"end":1020}],[],[],[],[],[]],
"email_tags": ["sales"],
"folders": [],
"contact_order_by": "created_at",
"contact_order_dir": "asc",
"sender_strategy": "tags",
"rotation_mode": "round_robin",
"ramp_enabled": false,
"ramp_start": 0,
"ramp_increment": 0,
"ramp_ceiling": 0,
"ramp_level": 0,
"esp_match_mode": "off",
"max_new_leads_per_day": 0,
"prioritize_new_leads": false,
"entry_delay_minutes": 0,
"continuous": false,
"idle_since": null,
"tracking_domain": "",
"tracking_domain_verified": false,
"utm_tracking": true,
"utm_source": "",
"utm_medium": "",
"utm_campaign": "",
"updated_at": "2026-06-10T12:00:00Z",
"created_at": "2026-06-01T09:00:00Z"
}
],
"pagination": {
"total": 12,
"next_cursor": "c1_b3BhcXVlLWN1cnNvcg",
"has_more": true
}
}
```
## Campaigns overview
`GET /campaigns-overview`
Status-bucket counts plus per-folder totals for the organization, used to drive campaign browsing UIs. `paused` sums every paused variant (`paused`, `paused_no_accounts`, `paused_trial_expired`). The path has no campaign id, so it lives beside `/campaigns` rather than under it. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
### Response
```json
{
"total": 12,
"active": 3,
"paused": 2,
"draft": 4,
"completed": 3,
"folders": [
{ "folder_id": "6b9c1c8e-1f2a-4d3b-8c7e-9a0b1c2d3e4f", "total": 5 }
]
}
```
## Estimate a send
`POST /campaigns-estimate`
Project an audience against a sender pool before a campaign exists. The campaign is simulated day by day under the scheduler's own rules: each mailbox's cap and the campaign limit, the warmup graduation ceiling as it climbs, the workspace's risk band, health bands and holds, domain authentication, cold rotation, sending behaviour profiles, the plan's daily allowance, what other campaigns already send from the same mailboxes, and the sending window with its spacing. The warmup mail each warming mailbox keeps sending shares that spacing, so it is counted too. For a sequence, due follow-ups go out before new contacts start, and the totals assume nobody replies. Nothing is written, so it needs no `Idempotency-Key`. The dashboard's new-campaign flow shows this as its launch plan. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `segment_ids` | string[] | yes | Segments making up the audience (at most 20). A contact in several of them is counted once. Send `[]` to project the pool alone: its mailboxes and capacity, with no finish date. |
| `email_tag_ids` | string[] | no | Mailbox tags that resolve the pool. Omit or send `[]` for every active mailbox in the workspace. |
| `daily_limit` | integer | no | Per-mailbox campaign cap to apply (defaults to `50`). Each mailbox counts the smaller of this and its own cap, lowered further by any clamp the scheduler applies. |
| `days` | integer (0-127) | no | Weekday bitmask of sending days, bit 0 = Monday. Defaults to weekdays. |
| `timezone` | string | no | IANA timezone the days and window are read in. Omit or send `""` to use the workspace timezone (UTC when none is set). |
| `start_date` | string (RFC 3339) | no | When sending begins. Omit for now. |
| `start_time` | string | no | Daily window start (`HH:MM`). Defaults to `08:00`, the window a new campaign gets. |
| `end_time` | string | no | Daily window end (`HH:MM`). Defaults to `18:00`. An end at or before the start returns `400`. |
| `step_waits` | integer[] | no | Each follow-up's `wait_after` in days (0 to 365), in order. Omit for a single email. At most 30. |
| `campaign_id` | string | no | Project a saved campaign of this workspace. Its leads that no linked segment enrolled count toward `recipients` together with `segment_ids` (each contact once; leads a segment link enrolled count only while that segment is in `segment_ids`, since unlinking it withdraws them). Anything not sent (`daily_limit`, `days`, `timezone`, the window, including per-day windows) is taken from the campaign, and one that sends from mailboxes picked one by one uses those. A campaign from another workspace returns `404`. |
### Response
`sending_days` and `estimated_finish_at` are `null` when the audience is empty, the pool has no capacity, or the send would take longer than two years. `daily_capacity` and `remaining_today` are the pool's ceiling today and what it has not already sent today. `steady_capacity` is a sending day's capacity once every mailbox has graduated from warmup, reached on `full_capacity_at` (`null` when it already has). Dates are midnight in the campaign's timezone.
| Field | Description |
| --- | --- |
| `steps`, `total_sends` | Emails per contact, and `recipients` times `steps`. |
| `first_touch_finish_at` | The day the last contact gets their first email. |
| `ramping`, `held` | Mailboxes still climbing their graduation ceiling, and mailboxes that send nothing today. |
| `warmup` | `{mailboxes, per_day}`: the warmup mail running alongside the campaign. |
| `other_campaigns_per_day` | What the pool's mailboxes sent for other campaigns on an average day over the last week. |
| `bottleneck` | The clamp that costs the most sends on the first full sending day: `campaign_limit`, `warmup_graduation`, `spacing`, `other_campaigns`, `health`, `held`, `workspace_risk`, `org_daily_limit` or `sending_behavior`. Empty when the mailboxes' own caps are the limit. |
| `timeline` | Up to 120 days from the start: `date`, `sending_day`, `capacity`, `sends`, `first_emails`, `follow_ups`, `warmup`. |
| `senders` | Up to 200 mailboxes: `id`, `email`, `provider`, `state` (`ready`, `ramping`, `throttled`, `health_hold`, `domain_auth`, `resting`, `no_worker`), `first_day_cap`, `steady_cap`, `warmup_per_day`, `full_cap_at`. |
```json
{
"recipients": 1000,
"mailboxes": 4,
"daily_capacity": 80,
"remaining_today": 60,
"sending_days": 14,
"estimated_finish_at": "2026-10-16T00:00:00+02:00",
"steps": 2,
"total_sends": 2000,
"first_touch_finish_at": "2026-10-09T00:00:00+02:00",
"steady_capacity": 180,
"full_capacity_at": "2026-10-06T00:00:00+02:00",
"ramping": 4,
"held": 0,
"warmup": { "mailboxes": 4, "per_day": 20 },
"other_campaigns_per_day": 0,
"bottleneck": "warmup_graduation",
"timeline": [
{ "date": "2026-09-28", "sending_day": true, "capacity": 60, "sends": 60, "first_emails": 60, "follow_ups": 0, "warmup": 20 }
],
"senders": [
{ "id": "5d0c…", "email": "anna@acme.io", "provider": "gmail", "state": "ramping", "first_day_cap": 20, "steady_cap": 45, "warmup_per_day": 5, "full_cap_at": "2026-10-06T00:00:00+02:00" }
]
}
```
## Create a campaign
`POST /campaigns`
Create a campaign. Only `name` is required, every other field is optional and applied only when sent (the wizard sends everything at once, a simple modal can send just `{name, description}` and get sane defaults). **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Campaign name. |
| `description` | string | no | Free-text description. |
| `stop_on_reply` | boolean | no | Stop sending to a contact once they reply. |
| `open_tracking` | boolean | no | Insert the open pixel. |
| `link_tracking` | boolean | no | Rewrite links through the tracking ticket service. Each link gets its own ticket, so clicks are attributed per link. |
| `utm_tracking` | boolean | no | Tag every link with `utm_source`, `utm_medium`, `utm_campaign` and a per-link `utm_content` at send time. Default `false`. Values already on a link are kept. |
| `utm_source` | string | no | Overrides the default `warmbly`. Empty means the default. Up to 128 characters. |
| `utm_medium` | string | no | Overrides the default `email`. Empty means the default. |
| `utm_campaign` | string | no | Overrides the default, the campaign name as a slug. Empty means the default. |
| `text_only` | boolean | no | Send plain text only: no HTML part, and open and click tracking are off regardless of their flags. |
| `daily_limit` | integer | no | Per-campaign daily send cap. |
| `unsubscribe_header` | boolean | no | Add the RFC 8058 one-click unsubscribe header. |
| `risky_emails` | boolean | no | Allow sending to risky/unverified addresses. |
| `cc` | string[] | no | Static CC list, on every email to every lead. An address that is suppressed, is the lead's own, or has bounced on this campaign is left off that email. To copy someone on one lead only, see [set a lead's CC](#set-a-leads-cc). |
| `bcc` | string[] | no | Static BCC list, filtered the same way. |
| `start_date` | string (RFC 3339), nullable | no | Earliest send time. Today or later; omit or send `null` to start as soon as the campaign is active. |
| `end_date` | string (RFC 3339), nullable | no | Latest send time. Must be in the future; omit or send `null` for an open-ended campaign. |
| `timezone` | string | no | IANA timezone for the schedule. Omit or send `""` to follow the workspace timezone, resolved on every read (UTC when none is set). The response carries the zone in use as `effective_timezone`. |
| `days` | integer (0-127) | no | Legacy weekday bitmask (superseded by `schedule_windows`). |
| `start_time` | string | no | Legacy daily start (`HH:MM`). |
| `end_time` | string | no | Legacy daily end (`HH:MM`). |
| `schedule_windows` | array | no | Per-day sending windows, 7 arrays indexed by weekday (Sunday = 0) of `{start, end}` minute-of-day intervals. When non-empty it supersedes `days`, `start_time` and `end_time`. |
| `email_tag_ids` | string[] | no | Mailbox tag ids that resolve the sender pool (tags strategy). |
| `folder_ids` | string[] | no | Folder ids to file the campaign under. |
| `sender_strategy` | string | no | `tags` (default) or `explicit`. `tags` resolves the pool from `email_tag_ids`, and falls back to every active mailbox in the workspace when no tag and no explicit sender is set. `explicit` sends from the mailboxes in `senders`, plus any `email_tag_ids` set alongside them. It never falls back to every active mailbox, so a pool that empties out parks the campaign at `paused_no_accounts` instead of widening to the whole workspace. |
| `rotation_mode` | string | no | How volume spreads across the chosen mailboxes. |
| `senders` | object[] | no | Explicit-strategy mailbox pool (see sender input below). |
| `ramp_enabled` | boolean | no | Enable per-campaign daily ramp-up. |
| `ramp_start` | integer | no | Ramp starting volume. |
| `ramp_increment` | integer | no | Daily ramp increment. |
| `ramp_ceiling` | integer | no | Ramp ceiling (never raises above the per-mailbox cap). |
| `esp_match_mode` | string | no | `off`, `prefer`, or `strict`. |
| `max_new_leads_per_day` | integer | no | New-lead throttle, `0` is unlimited. |
| `prioritize_new_leads` | boolean | no | Prefer new leads in each send window. |
| `entry_delay_minutes` | integer | no | Hold a contact's first email this long after they entered the campaign. `0` (the default) sends it as soon as the schedule and mailbox limits allow; the maximum is `129600` (90 days). Follow-up spacing is unaffected: that is each step's `wait_after`. |
| `continuous` | boolean | no | Keep running for new leads: out of leads, the campaign stays `active` and waits instead of finishing. Linking a segment, a form or an automation that enrols leads turns it on, and so does starting a campaign whose every lead has finished. Default `false`. |
| `tracking_domain` | string | no | Campaign-scoped tracking domain (honored only once verified). |
| `steps` | object[] | no | Initial sequence steps in order (see create sequence input below). They are connected in order: each step routes unconditionally to the next, waiting that step's `wait_after` days. The first step's `wait_after` defaults to `0`, follow-ups to `3`. A step given no `subject`, or the same one as the conversation so far, defaults to `thread_reply: true` and is sent as a reply carrying that conversation's subject; a step with a subject of its own defaults to `false` and opens a new conversation. Set `thread_reply` explicitly to override either. |
| `variants` | object[] | no | A/B variants for the first step (same shape as create A/B variant). |
| `advanced_overrides` | object | no | Advanced outreach overrides, see [advanced settings](#get-advanced-settings). |
`schedule_windows` may also be supplied as a 7-element array (indexed by `time.Weekday`, Sunday = 0) of `{start, end}` minute intervals. When non-empty it supersedes `days`/`start_time`/`end_time`.
```json
{
"name": "Q3 outbound",
"description": "Founders in fintech",
"stop_on_reply": true,
"open_tracking": true,
"link_tracking": true,
"daily_limit": 40,
"unsubscribe_header": true,
"timezone": "America/New_York",
"email_tag_ids": ["3b0a...", "9d2c..."],
"sender_strategy": "tags",
"rotation_mode": "round_robin",
"ramp_enabled": true,
"ramp_start": 10,
"ramp_increment": 2,
"ramp_ceiling": 40,
"steps": [
{ "name": "Step 1", "subject": "Quick question, {{first_name}}", "body_plain": "Hi {{first_name}}...", "wait_after": 0 },
{ "name": "Step 2", "subject": "", "body_plain": "Just bumping this...", "wait_after": 3 }
]
}
```
### Response
The created `Campaign` object (same shape as one element of the [list](#list-campaigns) `data` array).
## Get a campaign
`GET /campaigns/:id`
Fetch a single campaign by id. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A `Campaign` object (see the [list](#list-campaigns) shape).
## Update a campaign
`PATCH /campaigns/:id`
Patch any subset of campaign fields. Omitted fields are left unchanged. The explicit sender list is edited through [replace senders](#replace-senders), only the `sender_strategy`/`rotation_mode` toggles ride this PATCH. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Request body
Every field is optional. Scalar fields use nullable pointers, so any field you send is applied. `timezone` accepts `""` to make the campaign follow the workspace timezone. `start_date` and `end_date` additionally accept an explicit `null` to clear the stored date: a null `start_date` means "start now" and a null `end_date` means "run open-ended". Changing any schedule field (`start_date`, `end_date`, `timezone`, `days`, `start_time`, `end_time`, `schedule_windows`, `entry_delay_minutes`) on an active campaign reschedules its next send immediately, so clearing a future start date or shortening the entry delay takes effect right away. Notable fields: `name`, `description`, `status`, `stop_on_reply`, `open_tracking`, `link_tracking`, `text_only`, `daily_limit`, `unsubscribe_header`, `risky_emails`, `cc`, `bcc`, `start_date`, `end_date`, `timezone`, `days`, `start_time`, `end_time`, `schedule_windows`, `email_tags`, `folders`, `contact_order_by`, `contact_order_dir`, `contact_order_field`, `sender_strategy`, `rotation_mode`, `ramp_enabled`, `ramp_start`, `ramp_increment`, `ramp_ceiling`, `esp_match_mode`, `max_new_leads_per_day`, `prioritize_new_leads`, `entry_delay_minutes`, `continuous`, `tracking_domain`, `utm_tracking`, `utm_source`, `utm_medium`, `utm_campaign`.
`entry_delay_minutes` is anchored on when each contact entered the campaign, not on when the campaign started, so a contact enrolled by a linked segment next week waits the same amount from their own arrival. Leads that were in the campaign before the field existed count from the campaign's `created_at`, so turning a delay on never re-delays leads that have been enrolled for weeks.
A campaign with `continuous` set stays `active` when it runs out of leads and carries `idle_since` while it waits; the timestamp clears once it has something to send. A continuous campaign can be started with no leads at all; starting one that is not continuous and has never had a lead answers `400` `no_leads`. Starting a campaign that has at least one lead and nothing left to send, because every lead has finished the sequence, turns `continuous` on and waits for leads (see [start a campaign](#start-a-campaign)). Adding a lead to a `completed` campaign by any path (this API, a linked segment, an automation) restarts it through the same launch checks as starting it by hand; a refused restart is written to the campaign's activity log.
```json
{
"name": "Q3 outbound (renamed)",
"daily_limit": 35,
"stop_on_reply": true,
"schedule_windows": [[],[{"start":540,"end":1020}],[{"start":540,"end":1020}],[],[],[],[]]
}
```
### Response
The updated `Campaign` object.
## Delete a campaign
`DELETE /campaigns/:id`
Permanently delete a campaign. Any member of the workspace with the permission can delete any of its campaigns, not only the ones they created. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
A running campaign does not need to be paused first: its pending tasks (the parked wakeup and any send not yet handed to a worker) are cancelled in the same transaction that removes the campaign, so nothing keeps sending for it. A send already in a worker's hands finishes, and its result is discarded.
What is removed with the campaign: steps, leads and their progress, the activity log, senders, A/B variants, advanced settings, daily counters, preflight reports and attachment files. What stays: contacts, emails already sent (still in the inbox and in reply threads), suppression entries, deals and bookings (their campaign link is cleared), and tracked links, so a recipient who clicks a link in an email sent earlier is still redirected.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
`204 No Content` with an empty body.
## Duplicate a campaign
`POST /campaigns/:id/duplicate`
Create a new draft campaign from an existing campaign's configuration. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`. Counts against the same daily new-campaign throttle as [create](#create-a-campaign).
Copied: name (suffixed with ` (copy)` unless you pass one), description, every sending, tracking, schedule, rotation, ramp, ESP-matching and auto-pause setting, the steps with their subjects, bodies, waits, canvas positions and branch graph (rewired onto the new step ids), email tags, folders, the explicit sender list, A/B variants, advanced settings and attachments.
Not copied: leads, progress, sent/open/click/reply statistics, the activity log, daily counters, tasks, the ramp level, an auto-pause trip, and any start or end date already in the past (a past end date would finish the copy the moment it starts). Sender rotation cursors start from zero. The copy is owned by the caller and starts as `draft`; it never sends until it is started.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign to copy. |
### Request body
Optional.
| Field | Type | Description |
| --- | --- | --- |
| `name` | string | Name for the copy, 3 to 50 characters. Defaults to the source name with ` (copy)` appended. |
```json
{ "name": "Q3 outbound, subject B" }
```
### Response
`201 Created` with the new `Campaign` object (see the [list](#list-campaigns) shape), including `senders`.
## Get advanced settings
`GET /campaigns/:id/advanced`
Return the campaign's advanced outreach overrides (bounce pipeline, task reliability, A/B testing, reply intent, send-time optimization, preflight, dashboard). **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A `CampaignAdvancedSettings` object: the campaign id, the `overrides` block, and `updated_at`.
```json
{
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"overrides": {
"bounce_pipeline": {
"enabled": true,
"auto_suppress_on_bounce": true,
"auto_suppress_on_complaint": true,
"auto_suppress_on_unsubscribe": true,
"auto_pause_campaign_on_spike": true,
"pause_bounce_rate_threshold": 8,
"pause_complaint_rate_threshold": 1.5
},
"task_reliability": { "enabled": true, "dlq_enabled": true, "max_attempts": 5, "execution_window_seconds": 300 },
"ab_testing": { "enabled": true, "default_winning_rule": "reply_rate", "auto_promote_winner": false, "min_sample_size": 30 },
"reply_intent": {
"enabled": true,
"positive_keywords": ["interested", "pricing"],
"negative_keywords": ["not interested", "unsubscribe"],
"out_of_office_keywords": ["out of office", "vacation"],
"question_keywords": ["?", "how", "price"],
"auto_create_crm_task": true,
"crm_task_intents": ["positive", "question", "neutral", "negative"],
"auto_pause_on_negative": false,
"auto_suppress_on_unsubscribe_keyword": true,
"hold_on_out_of_office": true,
"out_of_office_hold_days": 7
},
"send_time_optimization": {
"enabled": false,
"use_contact_timezone": true,
"default_contact_timezone": "UTC",
"preferred_hours": [9, 10, 11, 14, 15, 16],
"weekend_weight_multiplier": 0.5
},
"preflight": {
"enabled": true,
"check_tracking_domain": true,
"check_unsubscribe_header": true,
"check_ab_variant_configured": false,
"check_daily_limit": true,
"check_schedule_window": true,
"check_content_score": true,
"min_content_score": 60
},
"dashboard": { "enabled": true, "show_suppression_log": true, "show_intent_summary": true, "show_dlq_stats": true }
},
"updated_at": "2026-06-10T12:00:00Z"
}
```
## Update advanced settings
`PATCH /campaigns/:id/advanced`
Replace the campaign's advanced overrides. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `settings` | object | yes | A full `AdvancedOutreachSettings` block (same shape as `overrides` above). |
```json
{
"settings": {
"bounce_pipeline": { "enabled": true, "auto_suppress_on_bounce": true, "auto_suppress_on_complaint": true, "auto_suppress_on_unsubscribe": true, "auto_pause_campaign_on_spike": true, "pause_bounce_rate_threshold": 8, "pause_complaint_rate_threshold": 1.5 },
"task_reliability": { "enabled": true, "dlq_enabled": true, "max_attempts": 5, "execution_window_seconds": 300 },
"ab_testing": { "enabled": true, "default_winning_rule": "reply_rate", "auto_promote_winner": false, "min_sample_size": 30 },
"reply_intent": { "enabled": true, "positive_keywords": [], "negative_keywords": [], "out_of_office_keywords": [], "question_keywords": [], "auto_create_crm_task": true, "crm_task_intents": ["positive", "question", "neutral", "negative"], "auto_pause_on_negative": false, "auto_suppress_on_unsubscribe_keyword": true, "hold_on_out_of_office": true, "out_of_office_hold_days": 7 },
"send_time_optimization": { "enabled": true, "use_contact_timezone": true, "default_contact_timezone": "UTC", "preferred_hours": [9, 14], "weekend_weight_multiplier": 0.5 },
"preflight": { "enabled": true, "check_tracking_domain": true, "check_unsubscribe_header": true, "check_ab_variant_configured": false, "check_daily_limit": true, "check_schedule_window": true, "check_content_score": true, "min_content_score": 60 },
"dashboard": { "enabled": true, "show_suppression_log": true, "show_intent_summary": true, "show_dlq_stats": true }
}
}
```
### Response
`204 No Content` with an empty body.
## List A/B variants
`GET /campaigns/:id/ab-variants`
List the campaign's A/B variants. A variant scoped to a `step_id` applies to one step, a `null` sequence id is campaign-level. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
<Callout type="info" title="The Original is a weighted arm">
For a step-scoped test the step's own email is the Original (control) arm. By default it carries an even share, but you can give it an explicit share by creating one `is_control: true` variant on that `step_id` whose `weight` is the Original's share. That row's `subject`/`body` are ignored; when the control wins, the step's own content is sent. Control rows are excluded from the A/B analysis variant list.
</Callout>
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A `data` array of `CampaignABVariant` objects (no pagination wrapper).
```json
{
"data": [
{
"id": "c1a2...",
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"step_id": "7e3b...",
"name": "Subject B",
"weight": 50,
"subject": "Worth a look, {{first_name}}?",
"body_html": "<p>Hi {{first_name}}...</p>",
"body_plain": "Hi {{first_name}}...",
"is_control": false,
"is_active": true,
"created_at": "2026-06-02T10:00:00Z",
"updated_at": "2026-06-02T10:00:00Z"
}
]
}
```
## Create an A/B variant
`POST /campaigns/:id/ab-variants`
Add a variant to the campaign (or to one step via `step_id`). **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Variant label. |
| `step_id` | uuid | no | Step to scope the variant to, omit for campaign-level. |
| `weight` | integer | no | Relative selection weight (1-100). Shares are these weights normalized across the active arms, so two arms at equal weight split evenly. |
| `subject` | string | no | Variant subject template. |
| `body_html` | string | no | Variant HTML body. |
| `body_plain` | string | no | Variant plain-text body. |
| `is_control` | boolean | no | Mark this as the step's control arm. For a step-scoped test, create one `is_control` row to set the Original's share; its `weight` is the Original's share and its content is ignored (the step's own email is sent when the control wins). |
| `is_active` | boolean | no | Whether the variant participates in the split. |
| `metadata` | object | no | Free-form metadata. |
```json
{
"name": "Subject B",
"step_id": "7e3b...",
"weight": 50,
"subject": "Worth a look, {{first_name}}?",
"body_plain": "Hi {{first_name}}...",
"is_control": false,
"is_active": true
}
```
### Response
`201 Created` with the created `CampaignABVariant` object (see the [list variants](#list-ab-variants) shape).
## Update an A/B variant
`PATCH /campaigns/:id/ab-variants/:variantId`
Patch a variant. Omitted fields are unchanged. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `variantId` | path | uuid | Variant id. |
### Request body
All fields optional: `name`, `weight`, `subject`, `body_html`, `body_plain`, `is_control`, `is_active`, `metadata`.
```json
{ "weight": 70, "is_active": true }
```
### Response
The updated `CampaignABVariant` object.
## Delete an A/B variant
`DELETE /campaigns/:id/ab-variants/:variantId`
Remove a variant. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_settings`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `variantId` | path | uuid | Variant id. |
### Response
`204 No Content` with an empty body.
## Get A/B analysis
`GET /campaigns/:id/ab-analysis`
Return per-variant engagement stats plus the computed winner for the campaign. **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
An `ABWinnerAnalysis` object.
```json
{
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"variants": [
{
"variant_id": "c1a2...",
"variant_name": "Subject A",
"total_sent": 120,
"opened": 78,
"clicked": 21,
"replied": 9,
"bounced": 2,
"open_rate": 65.0,
"click_rate": 17.5,
"reply_rate": 7.5,
"bounce_rate": 1.7
}
],
"winner_id": "c1a2...",
"winner_name": "Subject A",
"winning_rule": "reply_rate",
"confidence": "low"
}
```
## List attachments
`GET /campaigns/:id/attachments`
List every attachment of the campaign. Each entry carries a short-lived presigned download `url` and its `step_id`: a file scoped to a step is sent by that step alone, and a `null` `step_id` means every step of the campaign sends it. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A `data` array of attachment objects (no pagination wrapper).
```json
{
"data": [
{
"id": "a9f0...",
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"step_id": null,
"filename": "one-pager.pdf",
"size": 248192,
"mime_type": "application/pdf",
"url": "https://storage.warmbly.com/...signed...",
"created_at": "2026-06-05T08:00:00Z"
}
]
}
```
## Upload an attachment
`POST /campaigns/:id/attachments`
Upload a file to attach to the campaign, or to one of its steps. Sent as `multipart/form-data`, not JSON. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `file` | form (multipart) | file | Required. The file to upload (max 15 MB). Executable and script types are rejected. |
| `step_id` | form (multipart) | uuid | Optional. Scope the attachment to one sequence step of this campaign, which is then the only step that sends it. Omit it to attach the file to every step. A step of another campaign returns `404`. |
### Response
`201 Created` with the created attachment object (same shape as one element of [list attachments](#list-attachments)).
## Delete an attachment
`DELETE /campaigns/:id/attachments/:attachmentId`
Delete a campaign attachment and its stored object. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `attachmentId` | path | uuid | Attachment id. |
### Response
`204 No Content` with an empty body.
## Run preflight
`POST /campaigns/:id/preflight`
Run the campaign's preflight validation checks (tracking domain, unsubscribe header, daily limit, schedule window, A/B configuration, and more) and return a scored report. No mail is sent. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A `PreflightReport` object.
```json
{
"id": "f2b1...",
"organization_id": "11111111-2222-3333-4444-555555555555",
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"passed": false,
"score": 80,
"checks": [
{
"key": "tracking_domain",
"passed": false,
"severity": "warning",
"message": "2 sender account(s) have an unverified tracking domain.",
"remediation": "Set a tracking domain on every sender account used by this campaign and verify its CNAME."
}
],
"recommendations": ["Verify your tracking domain to improve link attribution."],
"created_at": "2026-06-10T12:00:00Z"
}
```
## Send a test email
`POST /campaigns/:id/test-email`
Send a one-off test of a sequence step to a chosen recipient through a chosen mailbox. The message is assembled the way a real send is: merge fields and spintax resolve for the contact, the files that step sends are attached (the campaign's unscoped attachments plus that step's own), the mailbox signature and the campaign's opt-out footer are appended, and a plain-text campaign ships without an HTML part. The subject is prefixed with `[TEST]`. Opens and clicks on a test are never tracked, and its opt-out link names no contact, so clicking it suppresses nobody. Defaults to the first step when `step_id` is omitted. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`.
The mailbox may be any mailbox of the organization, not only one the caller connected, and it does not have to be in the campaign's sender pool. An API key with an `allowed_email_accounts` list can only test from those mailboxes. Passing `contact_id` additionally requires `READ_CONTACTS` (org permission `view_contacts`), since the rendered copy reads that contact's fields.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `account_id` | uuid | yes | Sending mailbox id. |
| `recipient` | string (email) | yes | Where to send the test. |
| `step_id` | uuid | no | Step to render and send, defaults to the first step. |
| `contact_id` | uuid | no | Contact of the organization to render the copy for, including its custom fields. Omitted renders for a placeholder contact (`Test Recipient` at `Test Company`, with the recipient's address and no custom fields). |
```json
{
"account_id": "5c7d...",
"recipient": "me@example.com",
"step_id": "7e3b...",
"contact_id": "9a2c..."
}
```
### Response
`contact_id` is present only when one was given.
```json
{
"message": "test email sent",
"recipient": "me@example.com",
"subject": "Quick question, {{.FirstName}}",
"account_id": "5c7d...",
"step_id": "7e3b...",
"contact_id": "9a2c..."
}
```
### Errors
| Status | Code | When |
| --- | --- | --- |
| `404` | `not_found` | The campaign, step, mailbox or contact does not belong to the caller's organization. |
| `403` | `forbidden` | `contact_id` given without contact read permission, or `account_id` outside the API key's allowed mailboxes. |
| `400` | `bad_request` | The campaign has no steps. |
## Start a campaign
`POST /campaigns/:id/start`
Start (activate) the campaign so it begins sending real mail. Works from `draft`, any paused status, or `completed` (a campaign closed by a passed end date resumes once the date is extended or cleared). A campaign with nothing left to send does not finish again: the start turns `continuous` on if it was off, and the campaign goes `active` and waits for leads with `idle_since` set; the switch is written to its activity log. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `acknowledge_list_risk` | body | boolean | Optional. Launch even though the list's projected bounce rate would be refused (`list_bounce_risk`), for a list verified elsewhere. |
A start refused because every remaining lead was refused by address verification answers `leads_undeliverable` and parks the campaign at `paused_undeliverable`; re-verify the leads or mark them deliverable with [`POST /contacts/verification`](/api/reference/contacts/#verify-or-override-contacts), which resumes it.
A campaign whose email step has nothing in either body is refused with `empty_step_body` rather than started, because it would send a blank message to every lead it reached.
### Response
```json
{ "status": "started", "waiting_for_leads": false }
```
`waiting_for_leads` is `true` when the campaign started with nothing left to send and is now `active` with `idle_since` set.
## Stop a campaign
`POST /campaigns/:id/stop`
Stop (pause) an active campaign. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
```json
{ "status": "stopped" }
```
## Get a lead's hold
`GET /campaigns/:id/leads/:contact_id/hold`
Read whether one contact's flow inside this campaign is currently held. A hold parks the lead's next step without unsubscribing the contact and without removing them from the campaign; the two things that write one are an out-of-office auto-reply and a member pausing the lead by hand. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. Must already be a lead of the campaign. |
### Response
```json
{
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"contact_id": "3a5e9c71-4f2b-4d88-9a0c-1b7e5d2f6c34",
"hold": {
"since": "2026-09-07T09:14:00Z",
"until": "2026-09-09T00:00:00Z",
"reason": "back 8 Sep 2026",
"source": "out_of_office"
}
}
```
`hold` is absent when the lead is not held, including for a dated hold that has since expired. `until` is absent when the hold has no end, in which case only a resume lifts it. `source` is `out_of_office`, `inbox_tagging`, `manual`, or `cc` while the contact is [copied on another lead's emails](#set-a-leads-cc) in this campaign; a `cc` hold's `reason` is that lead's address.
### Errors
| Status | Code | When |
| --- | --- | --- |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
## Pause a lead
`POST /campaigns/:id/leads/:contact_id/pause`
Hold one contact's flow inside one campaign. The contact stays subscribed and stays a lead; the sequence picks up where it stopped when the hold lifts. This is the per-contact lever between leaving a lead alone and the two permanent ones, unsubscribing the contact and adding the address to the suppression list. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
The request states an absolute hold rather than applying a delta, and replacing a hold that is still live keeps its original start, so a retry lands on exactly the same row and no `Idempotency-Key` is needed.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. Must already be a lead of the campaign. |
| `until` | body | string | RFC 3339 timestamp the hold lifts at. Must be in the future and within a year. Omit or send `null` for a hold with no end, which only a resume lifts. |
| `reason` | body | string | Optional note shown next to the hold in the dashboard. Trimmed and capped at 200 characters. |
A manual pause always wins: it replaces a hold an out-of-office auto-reply wrote, and a later auto-reply never shortens it or takes it over.
### Request body
```json
{ "until": "2026-09-21T17:00:00Z", "reason": "On holiday, asked to follow up later" }
```
### Response
Same shape as [get a lead's hold](#get-a-leads-hold).
### Errors
| Status | Code | When |
| --- | --- | --- |
| `400` | `bad_request` | `until` is in the past, or more than a year away. |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
## Resume a lead
`POST /campaigns/:id/leads/:contact_id/resume`
Lift the hold now. The held time is dropped rather than carried, so the step returns to the schedule it would have had without the hold: the campaign's next pass when that moment has already passed, otherwise when the step's own wait elapses. The campaign's own wakeup is pulled forward with it, and a campaign that had finished while the lead was held is restarted through the usual launch checks. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
Resuming a lead that is not held succeeds and changes nothing, so a retry is safe.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. |
### Response
```json
{
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"contact_id": "3a5e9c71-4f2b-4d88-9a0c-1b7e5d2f6c34"
}
```
### Errors
| Status | Code | When |
| --- | --- | --- |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
## Get a lead's CC
`GET /campaigns/:id/leads/:contact_id/cc`
List the contacts copied on every email this campaign sends one lead. See [copying colleagues on one lead](/guides/campaigns/#copying-colleagues-on-one-lead). **Scope** `READ_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `view_campaigns` and `view_contacts`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. Must already be a lead of the campaign. |
### Response
```json
{
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"contact_id": "3a5e9c71-4f2b-4d88-9a0c-1b7e5d2f6c34",
"cc": [
{
"contact_id": "b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59",
"email": "jonas@acme.example",
"first_name": "Jonas",
"last_name": "Weber",
"company": "Acme GmbH",
"status": "active"
}
]
}
```
`status` says whether the next email copies them: `active` does; `unsubscribed` (opted out or suppressed), `bounced` (bounced on this thread or on a campaign email of their own) and `undeliverable` (failed verification under the campaign's rules) are left off until that changes. `bounced_at` is set when a bounce was attributed to this copy on this lead's thread. The list is in the order it was set.
### Errors
| Status | Code | When |
| --- | --- | --- |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
## Set a lead's CC
`PUT /campaigns/:id/leads/:contact_id/cc`
Replace the contacts copied on every email this campaign sends one lead, follow-ups included. An empty list removes them all. **Scope** `WRITE_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `manage_campaigns` and `view_contacts`.
The body is the whole list, so a retry lands on the same state and no `Idempotency-Key` is needed.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. Must already be a lead of the campaign. |
| `contact_ids` | body | array of uuid | Contacts of the workspace to copy, at most `2`. Duplicates are ignored. |
A copied contact who is also a lead of this campaign has their own sequence held with `source` `cc` for as long as any lead copies them, so they never get two threads from one campaign. Removing the copy releases the hold.
### Request body
```json
{ "contact_ids": ["b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59"] }
```
### Response
Same shape as [get a lead's CC](#get-a-leads-cc).
### Errors
| Status | Code | When |
| --- | --- | --- |
| `400` | `bad_request` | A `contact_ids` entry is not a uuid. |
| `400` | `lead_cc_limit` | More than `2` contacts. |
| `400` | `lead_cc_self` | The lead is in its own list. |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
| `404` | `lead_cc_contact_not_found` | A contact to copy is not a contact of the workspace. |
| `409` | `lead_cc_lead_is_copied` | The lead is copied on another lead in this campaign, so it sends nothing of its own to copy anyone on. |
| `409` | `lead_cc_has_copies` | A contact to copy has copies of their own in this campaign. |
## Suggest colleagues to CC
`GET /campaigns/:id/leads/:contact_id/cc/suggestions`
Up to eight contacts who look like the lead's colleagues: the same company name, or the same email domain when that domain belongs to a company rather than a personal mail service. Unsubscribed contacts and ones already copied are left out. **Scope** `READ_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `view_campaigns` and `view_contacts`.
### Response
```json
{
"data": [
{
"contact_id": "b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59",
"email": "jonas@acme.example",
"first_name": "Jonas",
"last_name": "Weber",
"company": "Acme GmbH",
"reason": "company"
}
]
}
```
`reason` is `company` when the company names match and `domain` when only the email domain does. Company matches come first.
## Get campaign logs
`GET /campaigns/:id/logs`
Page through the campaign's activity log (status changes, send events, errors). **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `cursor` | query | string | Opaque cursor from the previous page. Optional. |
| `limit` | query | integer | Page size, 1 to 100 (default 50). Optional. |
### Response
A `data` plus `pagination` envelope. Here `pagination` carries only `next_cursor` (a string, `null` on the last page) and `has_more`.
```json
{
"data": [
{
"id": "9b2c...",
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"event_type": "campaign_started",
"message": "Campaign started",
"metadata": {},
"created_at": "2026-06-10T12:00:00Z"
}
],
"pagination": {
"next_cursor": "9b2c...",
"has_more": true
}
}
```
## Today's sending plan
`GET /campaigns/:id/send-plan`
What the campaign sends today and every limit that decided it, worked out on the spot through the scheduler's own gates. Nothing is stored and nothing is written. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
The arithmetic adds up: `configured_ceiling` minus every `limits[].emails` minus `sent_today` is `expected_remaining`, and `projected_today` is `sent_today` plus `expected_remaining`. `limits` lists only the clamps that removed something, in the order the scheduler applies them; `bottleneck` names the one that decides the number (`""` when nothing binds, `budget_spent` when every mailbox has used its day). `day` is the budget day in UTC (every daily counter resets at midnight UTC, whatever the campaign's timezone); the window's times are in the campaign's timezone. `leads.waiting_on_sender` counts due steps whose own mailbox has nothing left today, since each contact keeps the address they first heard from. `organization` is present only when the workspace's plan has a daily allowance.
```json
{
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"status": "active",
"day": "2026-09-16",
"timezone": "Europe/Paris",
"computed_at": "2026-09-16T12:30:00Z",
"configured_ceiling": 150,
"projected_today": 20,
"sent_today": 8,
"expected_remaining": 12,
"bottleneck": "warmup_graduation",
"limits": [
{ "kind": "warmup_graduation", "emails": 130, "mailboxes": 3 }
],
"window": {
"sending_day": true,
"open_now": true,
"closes_at": "2026-09-16T15:00:00Z",
"minutes_left": 150
},
"leads": {
"due_now": 340,
"due_later_today": 12,
"new_leads_due_today": 300,
"waiting_on_step": 200,
"waiting_on_condition": 0,
"held": 3,
"waiting_on_sender": 0,
"new_leads_started_today": 8,
"max_new_leads_per_day": 0
},
"mailboxes": [
{
"id": "1c2d...",
"email": "sam@acme.com",
"provider": "gmail",
"configured_cap": 50,
"cap_today": 10,
"limited_by": "warmup_graduation",
"sent_today": 4,
"sent_by_other_campaigns": 0,
"expected_remaining": 6,
"state": "sending",
"min_gap_seconds": 600,
"graduation": { "ceiling": 10, "mailbox_cap": 50, "days_to_full_cap": 8, "held": false }
}
],
"next_wake_at": "2026-09-16T12:41:00Z"
}
```
`limits[].kind` is one of `campaign_daily_limit`, `campaign_ramp`, `warmup_graduation`, `workspace_risk`, `domain_auth`, `resting`, `warmup_health_hold`, `other_campaigns`, `warmup_health_pace`, `mailbox_hours`, `sending_behavior`, `spacing`, `sending_window`, `not_running`, `org_daily_limit`, `new_lead_cap` or `leads`. `mailboxes[].state` is one of `sending`, `budget_spent`, `hours_closed`, `no_working_day`, `domain_auth`, `resting`, `health_hold`, `no_worker` (no running sending worker holds the mailbox right now; its day is still counted, since it is placed on one again within minutes) or `window_closed`; `mailboxes[].limited_by` names the clamp that set `cap_today`. See the [campaigns guide](/guides/campaigns/#todays-sending-plan) for what each limit means.
## List campaign senders
`GET /campaigns/:id/senders`
Return the campaign's explicit sender pool (used when `sender_strategy` is `explicit`). **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A `data` array of sender objects (no pagination wrapper).
```json
{
"data": [
{
"email_account_id": "5c7d...",
"weight": 1,
"last_sent_at": "2026-06-10T11:55:00Z",
"enabled": true
}
]
}
```
## Replace senders
`PUT /campaigns/:id/senders`
Atomically replace the campaign's explicit sender pool with the supplied list. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `senders` | object[] | yes | The full new sender pool. Each item: `email_account_id` (uuid, required), `weight` (integer, optional), `enabled` (boolean, optional). An empty array clears the pool. What that means depends on the campaign's `sender_strategy`: a `tags` campaign falls back to its tags or to every active mailbox, while an `explicit` one falls back to its tags only, and with none it is left with no mailbox to send from and parks itself at `paused_no_accounts`. |
```json
{
"senders": [
{ "email_account_id": "5c7d...", "weight": 2, "enabled": true },
{ "email_account_id": "6d8e...", "weight": 1, "enabled": true }
]
}
```
### Response
A `data` array of the resulting sender objects (same shape as [list senders](#list-campaign-senders)).
## List linked segments
`GET /campaigns/:id/segments`
Return the segments linked to the campaign as live audience sources. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A `data` array of link objects (no pagination wrapper). The counts are evaluated when you ask: `contact_count` is how many contacts the segment matches now, `lead_count` how many of them are leads of this campaign, and `held_out_count` how many are not leads because they were removed from the campaign by hand (see [replace linked segments](#replace-linked-segments)).
```json
{
"data": [
{
"segment_id": "4b9e...",
"name": "Warm leads",
"color": "#0ea5e9",
"description": "Replied or clicked in the last 30 days",
"contact_count": 412,
"lead_count": 409,
"held_out_count": 3,
"linked_at": "2026-06-10T12:00:00Z"
}
]
}
```
## Replace linked segments
`PUT /campaigns/:id/segments`
Atomically replace the campaign's linked segments with the supplied set (up to 20). Every current member of a newly linked segment is enrolled as a lead immediately, and contacts who enter a linked segment later are enrolled automatically, within about 2 minutes.
Removing a segment from the set withdraws the leads it enrolled, so replacing one segment with another leaves the campaign holding the new audience rather than both. A lead is only withdrawn when all three hold: a linked segment enrolled it (a lead added through any other path counts as chosen by hand and is never withdrawn), it is still a current member of a segment being removed and of none that stayed, and the campaign has not written to it yet (no step dispatched or sent). Nothing is recorded as a hand-made removal, so re-linking the segment enrols those members again. A contact who merely leaves a still-linked segment keeps their lead row; within a link, enrolment stays additive. A lead removed from the campaign by hand is never re-added automatically; a manual add (or the one-shot enrol below) clears that removal record. Omitting `segment_ids` returns `400`; send an explicit empty array to detach every segment. An active campaign is woken to send to the new leads; a completed campaign is restarted through the full launch checks when a linked segment grows. A linked segment cannot be deleted (`DELETE /segments/:id` returns `409`) until it is removed here. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `segment_ids` | uuid[] | yes | The full new set of linked segments, max 20. An empty array detaches them all. |
```json
{
"segment_ids": ["4b9e...", "7c2f..."]
}
```
### Response
The resulting links plus what the call did to the campaign's leads. `added` is how many leads it enrolled, and is `0` when every member was already a lead, when the segments match no contacts yet, or when the only members are held out; the per-link counts tell these apart. `withdrawn` is how many leads a removed segment took back out, and `contacted` how many of that audience stayed because the campaign had already emailed them. The links, the withdrawal and the enrolment are written in one transaction, so a failure returns an error and changes nothing rather than `200` with `added: 0`.
```json
{
"data": [
{
"segment_id": "4b9e...",
"name": "Warm leads",
"color": "#0ea5e9",
"description": "Replied or clicked in the last 30 days",
"contact_count": 412,
"lead_count": 412,
"held_out_count": 0,
"linked_at": "2026-06-10T12:00:00Z"
}
],
"added": 397,
"withdrawn": 58,
"contacted": 4
}
```
For a one-time snapshot enrolment instead of a live link, see [add a segment to a campaign](/api/reference/contacts/#add-to-a-campaign).
## Verify campaign tracking domain
`POST /campaigns/:id/tracking-domain/verify`
Resolve the campaign-scoped tracking domain against this install's tracking host and flip `tracking_domain_verified` to `true` on success. A record that does not resolve stays unverified with a reason rather than erroring, and the campaign falls back to the mailbox's domain (or the shared host). **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A `TrackingDomainStatus` object, the same shape the [mailbox tracking-domain endpoints](/api/reference/mailboxes/#update-the-tracking-domain) return.
```json
{
"tracking_domain": "track.acme.com",
"tracking_domain_verified": true,
"tracking_domain_verified_at": "2026-06-10T12:00:00Z",
"cname_target": "t.warmbly.com",
"status": "verified",
"message": "track.acme.com points at t.warmbly.com.",
"observed": "t.warmbly.com",
"tracking_host_unresolvable": false
}
```
## List sequences
`GET /campaigns/:id/steps`
Return the campaign's sequence steps in order. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Response
A bare array of `Sequence` objects (no envelope).
```json
[
{
"id": "7e3b...",
"name": "Step 1",
"subject": "Quick question, {{first_name}}",
"body_plain": "Hi {{first_name}}...",
"body_html": "<p>Hi {{first_name}}...</p>",
"body_sync": true,
"body_code": false,
"wait_after": 0,
"position": 0,
"thread_reply": true,
"kind": "email",
"updated_at": "2026-06-02T10:00:00Z",
"created_at": "2026-06-02T10:00:00Z"
}
]
```
## Create a sequence
`POST /campaigns/:id/steps`
Append a new sequence step to the campaign. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
### Request body
Optional. It takes the same fields as [update a sequence](#update-a-sequence), applied to the new step, so a step can be created with its copy in one request. Without a body, or with `{}`, the step is created with defaults. A body the update refuses creates nothing and answers the update's error.
```json
{
"subject": "Quick question",
"body_plain": "Hi {{first_name}}, ...",
"wait_after": 3
}
```
### Response
The created `Sequence` object (see the [list sequences](#list-sequences) shape).
## Update a sequence
`PATCH /campaigns/:id/steps/:sid`
Patch a sequence step: its copy, spacing, node kind, branching tree, or action config. Omitted fields are unchanged. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `sid` | path | uuid | Sequence (step) id. |
### Request body
All fields optional.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | no | Step label. |
| `subject` | string | no | Subject template. |
| `body_plain` | string | no | Plain-text body template. Leave it empty and the send path renders one from `body_html`, keeping list bullets, table rows and link destinations, and leaving out the stylesheet. |
| `body_html` | string | no | HTML body template. Sent as written, including a whole document with its own `<head>`. Any `<style>` block is inlined onto the elements it matches at send time; see [the sequences guide](/guides/sequences/). Send `body_plain` on its own and the HTML part is rendered from it, because a step with a plain body and an empty HTML one would otherwise arrive blank: every modern client prefers the HTML alternative. A step that already has an HTML body is never overwritten. |
| `body_sync` | boolean | no | Keep plain and HTML bodies in sync. |
| `body_code` | boolean | no | The body is authored as raw HTML. The dashboard editor opens it as markup instead of parsing it into the visual editor, which keeps only what its schema can represent. It does not change what is sent. |
| `wait_after` | integer | no | Days to wait before this step, counted from the contact's previous step (`0` to `60`). Spacing belongs to the target step, so there is no standalone wait node for email steps. |
| `thread_reply` | boolean | no | Send this step as a reply on the conversation the contact is already in, rather than as a new email. Default `true`. A threading step carries the conversation's subject, so its own `subject` is only used once it is turned off. It has no effect on a contact's first email, which has nothing to reply to. |
| `conditions` | object | no | The connections out of this step (`{branches: [...]}`), evaluated in order; a branch with no `conditions` is a plain "go there next" link. Routing follows connections only: a step with `{}` or no branches has no outgoing path and ends the flow for the contact. |
| `kind` | string | no | `email` (default), `action`, or `wait`. |
| `action` | object | no | Typed config for non-email nodes. `type` is the switch (`wait`, `add_tag`, `remove_tag`, `add_to_segment`, `remove_from_segment` (each with a `segment_id`), `unsubscribe`, `notify`, `create_task`, `create_deal`, `move_deal_stage`, `run_automation`, `end`), the remaining fields are type-scoped. |
```json
{
"name": "Step 2",
"subject": "Following up, {{first_name}}",
"body_plain": "Just bumping this...",
"wait_after": 2,
"conditions": {
"branches": [
{
"branch_id": "b1",
"target_step_id": null,
"conditions": [{ "field": "replied", "operator": "ever", "value": null }]
}
]
}
}
```
### Response
The updated `Sequence` object.
## Delete a sequence
`DELETE /campaigns/:id/steps/:sid`
Delete a sequence step. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `sid` | path | uuid | Sequence (step) id. |
### Response
`200 OK` with an empty body.
## Preview a template
`POST /campaign-template-preview`
Render subject and body templates for a contact exactly as the send path would, and report parse errors plus any unresolved `{{...}}` tokens. With `campaign_id` and `account_id` the preview also goes through the rest of the send assembly: the plain-text rule, the mailbox signature and the campaign's opt-out footer are applied in send order, and the response names the sender and lists the attachments the send carries. Tracking pixels and link rewriting are left out. No side effects. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`. Passing `contact_id` additionally requires `READ_CONTACTS` (org permission `view_contacts`), and an API key with an `allowed_email_accounts` list can only name those mailboxes in `account_id`.
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subject` | string | no | Subject template. |
| `body_html` | string | no | HTML body template. |
| `body_plain` | string | no | Plain-text body template. |
| `contact_id` | uuid | no | A contact of the organization to render for, with its custom fields. Omitted uses the built-in sample contact. |
| `contact` | object | no | Override fields on the contact being rendered for (the sample or the one from `contact_id`): `first_name`, `last_name`, `email`, `company`, `phone`, and a `custom_fields` map of string to string. |
| `campaign_id` | uuid | no | Campaign of the organization whose opt-out footer, plain-text setting and attachments apply. The opt-out link in the preview names no contact. |
| `step_id` | uuid | no | The step being previewed, so `attachments` lists what that step sends. Without it only the campaign-wide files are listed. |
| `account_id` | uuid | no | Mailbox of the organization whose signature is appended (when signature sync is on) and which is reported as `from`. |
```json
{
"subject": "Hi {{first_name}} at {{company}}",
"body_html": "<p>Hey {{first_name}}, I saw {{company}} is hiring. {{unknown_token}}</p>",
"contact_id": "9a2c...",
"campaign_id": "8f1d...",
"account_id": "5c7d..."
}
```
### Response
A `TemplatePreview` object. `errors` lists template parse errors that would block sending, `unresolved` lists literal tokens left after render. `from` is present when `account_id` was given, `attachments` when `campaign_id` was given and there are files on the send (the campaign-wide ones plus `step_id`'s own). All four are omitted when empty.
```json
{
"subject": "Hi Sam at Globex",
"body_html": "<p>Hey Sam, I saw Globex is hiring. {{unknown_token}}</p><br><br><p>Best, Ana</p><p style=\"font-size:12px;color:#64748b\">Don't want these emails? <a href=\"https://app.example.com/u/...\">Unsubscribe</a></p>",
"body_plain": "Hey Sam, I saw Globex is hiring. {{unknown_token}}\n\nBest, Ana\n\nDon't want these emails? https://app.example.com/u/...",
"unresolved": ["{{unknown_token}}"],
"from": { "name": "Ana Silva", "email": "ana@globex.com" },
"attachments": [
{ "id": "3b0e...", "filename": "deck.pdf", "size": 482113, "mime_type": "application/pdf" }
]
}
```
### Errors
| Status | Code | When |
| --- | --- | --- |
| `404` | `not_found` | `contact_id`, `campaign_id` or `account_id` does not belong to the caller's organization. |
| `403` | `forbidden` | `contact_id` given without contact read permission, or `account_id` outside the API key's allowed mailboxes. |
## Generate copy with the writing assistant
`POST /generation/write`
Generate outreach copy with the AI writing assistant. Gated to paid and free-trial organizations, and each call consumes one AI credit (refunded if the provider call fails). Supports `Idempotency-Key` so a retried request is not double-charged. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `prompt` | string | yes | The instruction to generate from (max 8000 characters). |
| `tone` | string | no | Desired tone (for example `friendly`, `direct`). |
```json
{ "prompt": "Write a 3-line cold intro to a fintech founder about our deliverability tooling.", "tone": "direct" }
```
### Response
```json
{
"text": "Hi {{first_name}},\n\nNoticed {{company}} is scaling outbound...\n\nWorth a quick chat?",
"credits_remaining": 248,
"model": "claude-..."
}
```
When the organization is out of credits the endpoint returns `402` with `code: "insufficient_credits"` and the standard envelope. A depleted balance is checked before any provider call, so no completion is ever burned on a `402`.
## Preview an AI variable
`POST /generation/ai-variable`
Generate the recipient-specific snippet a per-recipient AI variable block would produce, for the campaign editor's preview. The prompt is rendered against a supplied contact (must belong to the organization) or a sample contact, then generated with the same framing the send path uses, including the surrounding email so the fragment fits. The charge is metered by usage (the model and tokens the snippet uses, plus any web lookup), refunded if the provider call fails, and `Idempotency-Key` is honored so a retried request is not double-charged. See the [AI variables](/guides/ai-variables/) guide. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns` and `use_ai`.
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mode` | string | no | `instant` (default) for a single fast completion, or `research` for the deeper, higher-cost path. |
| `prompt` | string | yes | The block instruction, itself a template rendered against the contact (max 8000 characters). |
| `tone` | string | no | Desired tone. |
| `web_search` | boolean | no | Allow one bounded web lookup about the contact's company to enrich context. Implied by `research`. |
| `contact_id` | string | no | A contact in the organization to render the prompt against. Omit to use a sample contact. |
| `context_before` | string | no | The email text immediately before the block, so the fragment fits the sentence it lands in. |
| `context_after` | string | no | The email text immediately after the block. |
```json
{ "mode": "instant", "prompt": "One line noting something specific about {{company}}.", "contact_id": "4f6c..." }
```
### Response
```json
{
"text": "Saw Acme just shipped its new billing API.",
"credits_remaining": 246,
"credits_charged": 1,
"tokens_used": 180,
"model": "claude-..."
}
```
A `contact_id` that is not in the organization returns `404`. When the organization is out of credits the endpoint returns `402` with `code: "insufficient_credits"`, checked before any provider call.