mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 08:03:38 +00:00
1016 lines
37 KiB
Plaintext
1016 lines
37 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": "UTC",
|
|
"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,
|
|
"tracking_domain": "",
|
|
"tracking_domain_verified": false,
|
|
"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 }
|
|
]
|
|
}
|
|
```
|
|
|
|
## 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. |
|
|
| `text_only` | boolean | no | Send plain text only. |
|
|
| `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. |
|
|
| `bcc` | string[] | no | Static BCC list. |
|
|
| `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. |
|
|
| `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`). |
|
|
| `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`. |
|
|
| `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. |
|
|
| `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`. |
|
|
| `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. `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`) on an active campaign reschedules its next send immediately, so clearing a future start date 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`, `tracking_domain`.
|
|
|
|
```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,
|
|
"auto_pause_on_negative": false,
|
|
"auto_suppress_on_unsubscribe_keyword": true
|
|
},
|
|
"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, "auto_pause_on_negative": false, "auto_suppress_on_unsubscribe_keyword": true },
|
|
"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 the campaign's attachments. Each entry carries a short-lived presigned download `url`. **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 one step). 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. |
|
|
|
|
### 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 preview of a sequence step to a chosen recipient through a chosen mailbox. Defaults to the first step when `step_id` is omitted. **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`.
|
|
|
|
| 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. |
|
|
|
|
```json
|
|
{
|
|
"account_id": "5c7d...",
|
|
"recipient": "me@example.com",
|
|
"step_id": "7e3b..."
|
|
}
|
|
```
|
|
|
|
### Response
|
|
|
|
```json
|
|
{
|
|
"message": "test email sent",
|
|
"recipient": "me@example.com",
|
|
"subject": "Quick question, Alex",
|
|
"account_id": "5c7d..."
|
|
}
|
|
```
|
|
|
|
## 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; one with nothing left to send re-completes with a `400` explaining why). **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`.
|
|
|
|
| Parameter | In | Type | Description |
|
|
| --- | --- | --- | --- |
|
|
| `id` | path | uuid | Campaign id. |
|
|
|
|
### Response
|
|
|
|
```json
|
|
{ "status": "started" }
|
|
```
|
|
|
|
## 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 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
|
|
}
|
|
}
|
|
```
|
|
|
|
## 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). |
|
|
|
|
```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)).
|
|
|
|
## 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,
|
|
"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 empty sequence step to the campaign. The step is created with defaults, then edited with PATCH. **Scope** `WRITE_CAMPAIGNS` · **Org permission** `manage_campaigns`.
|
|
|
|
| Parameter | In | Type | Description |
|
|
| --- | --- | --- | --- |
|
|
| `id` | path | uuid | Campaign id. |
|
|
|
|
This endpoint takes no request body.
|
|
|
|
### 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. |
|
|
| `body_html` | string | no | HTML body template. |
|
|
| `body_sync` | boolean | no | Keep plain and HTML bodies in sync. |
|
|
| `body_code` | boolean | no | Treat the body as raw code (no auto-formatting). |
|
|
| `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. |
|
|
| `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 against a sample (or supplied) contact exactly as the send path would, and report parse errors plus any unresolved `{{...}}` tokens. No side effects, no campaign id. **Scope** `READ_CAMPAIGNS` · **Org permission** `view_campaigns`.
|
|
|
|
### 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` | object | no | Override fields on the built-in sample contact: `first_name`, `last_name`, `email`, `company`, `phone`, and a `custom_fields` map of string to string. |
|
|
|
|
```json
|
|
{
|
|
"subject": "Hi {{first_name}} at {{company}}",
|
|
"body_plain": "Hey {{first_name}}, I saw {{company}} is hiring. {{unknown_token}}",
|
|
"contact": { "first_name": "Sam", "company": "Globex" }
|
|
}
|
|
```
|
|
|
|
### Response
|
|
|
|
A `TemplatePreview` object. `errors` lists template parse errors that would block sending, `unresolved` lists literal tokens left after render. Both are omitted when empty.
|
|
|
|
```json
|
|
{
|
|
"subject": "Hi Sam at Globex",
|
|
"body_html": "",
|
|
"body_plain": "Hey Sam, I saw Globex is hiring. {{unknown_token}}",
|
|
"unresolved": ["{{unknown_token}}"]
|
|
}
|
|
```
|
|
|
|
## 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.
|