Campaigns
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. Authentication and the scope model are covered in authentication and 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. |
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 unfiltered count.
{
"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
}
}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) | no | Earliest send time. |
end_date | string (RFC 3339) | no | Latest send time. |
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). |
sequences | object[] | no | Initial sequence steps in order (see create sequence input below). |
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. |
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.
{
"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,
"sequences": [
{ "name": "Step 1", "subject": "Quick question, {{first_name}}", "body_plain": "Hi {{first_name}}...", "wait_after": 0 }
]
}Response
The created Campaign object (same shape as one element of the list 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 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, 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. 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.
{
"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. Scope WRITE_CAMPAIGNS · Org permission manage_campaigns.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | uuid | Campaign id. |
Response
204 No Content with an empty body.
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.
{
"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": true,
"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
},
"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). |
{
"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 },
"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.
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.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | uuid | Campaign id. |
Response
A data array of CampaignABVariant objects (no pagination wrapper).
{
"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. |
{
"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 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.
{ "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.
{
"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).
{
"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).
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.
{
"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": "Tracking domain is not verified.",
"remediation": "Verify the campaign tracking domain before sending."
}
],
"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. |
{
"account_id": "5c7d...",
"recipient": "[email protected]",
"step_id": "7e3b..."
}Response
{
"message": "test email sent",
"recipient": "[email protected]",
"subject": "Quick question, Alex",
"account_id": "5c7d..."
}Start a campaign
POST /campaigns/:id/start
Start (activate) the campaign so it begins sending real mail. Scope SEND_CAMPAIGNS · Org permission send_campaigns.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | uuid | Campaign id. |
Response
{ "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
{ "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.
{
"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).
{
"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). |
{
"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).
Verify campaign tracking domain
POST /campaigns/:id/tracking-domain/verify
Resolve the campaign-scoped tracking domain's CNAME and flip tracking_domain_verified to true on success. Scope WRITE_CAMPAIGNS · Org permission manage_campaigns.
| Parameter | In | Type | Description |
|---|---|---|---|
id | path | uuid | Campaign id. |
Response
A TrackingDomainStatus object.
{
"tracking_domain": "track.acme.com",
"tracking_domain_verified": true,
"tracking_domain_verified_at": "2026-06-10T12:00:00Z"
}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).
[
{
"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 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 | Minutes to wait after this step before the next (the spacing model, there is no standalone wait node for email steps). |
conditions | object | no | Branching tree ({branches: [...]}). Send {} or empty branches to clear branching and fall back to linear progression. |
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, unsubscribe, notify, create_task, create_deal, move_deal_stage, run_automation, end), the remaining fields are type-scoped. |
{
"name": "Step 2",
"subject": "Following up, {{first_name}}",
"body_plain": "Just bumping this...",
"wait_after": 2880,
"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. |
{
"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.
{
"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). |
{ "prompt": "Write a 3-line cold intro to a fintech founder about our deliverability tooling.", "tone": "direct" }Response
{
"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.