WarmblyDocs
Endpoint reference

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.

ParameterInTypeDescription
qquerystringFree-text filter on campaign name. Optional.
folderquerystringRestrict to a single folder id. Optional.
statusquerystringStatus bucket filter: draft, active, paused (matches every paused variant), or completed. Any other value returns 400. Optional.
cursorquerystringOpaque cursor from the previous page's pagination.next_cursor. Optional.
limitquerystringPage 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.

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

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

FieldTypeRequiredDescription
namestringyesCampaign name.
descriptionstringnoFree-text description.
stop_on_replybooleannoStop sending to a contact once they reply.
open_trackingbooleannoInsert the open pixel.
link_trackingbooleannoRewrite links through the tracking ticket service.
text_onlybooleannoSend plain text only.
daily_limitintegernoPer-campaign daily send cap.
unsubscribe_headerbooleannoAdd the RFC 8058 one-click unsubscribe header.
risky_emailsbooleannoAllow sending to risky/unverified addresses.
ccstring[]noStatic CC list.
bccstring[]noStatic BCC list.
start_datestring (RFC 3339)noEarliest send time.
end_datestring (RFC 3339)noLatest send time.
timezonestringnoIANA timezone for the schedule.
daysinteger (0-127)noLegacy weekday bitmask (superseded by schedule_windows).
start_timestringnoLegacy daily start (HH:MM).
end_timestringnoLegacy daily end (HH:MM).
email_tag_idsstring[]noMailbox tag ids that resolve the sender pool (tags strategy).
folder_idsstring[]noFolder ids to file the campaign under.
sender_strategystringnotags (default) or explicit.
rotation_modestringnoHow volume spreads across the chosen mailboxes.
sendersobject[]noExplicit-strategy mailbox pool (see sender input below).
ramp_enabledbooleannoEnable per-campaign daily ramp-up.
ramp_startintegernoRamp starting volume.
ramp_incrementintegernoDaily ramp increment.
ramp_ceilingintegernoRamp ceiling (never raises above the per-mailbox cap).
esp_match_modestringnooff, prefer, or strict.
max_new_leads_per_dayintegernoNew-lead throttle, 0 is unlimited.
prioritize_new_leadsbooleannoPrefer new leads in each send window.
tracking_domainstringnoCampaign-scoped tracking domain (honored only once verified).
sequencesobject[]noInitial sequence steps in order (see create sequence input below).
variantsobject[]noA/B variants for the first step (same shape as create A/B variant).
advanced_overridesobjectnoAdvanced 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign id.

Request body

FieldTypeRequiredDescription
settingsobjectyesA 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign id.

Request body

FieldTypeRequiredDescription
namestringyesVariant label.
step_iduuidnoStep to scope the variant to, omit for campaign-level.
weightintegernoRelative selection weight (1-100). Shares are these weights normalized across the active arms, so two arms at equal weight split evenly.
subjectstringnoVariant subject template.
body_htmlstringnoVariant HTML body.
body_plainstringnoVariant plain-text body.
is_controlbooleannoMark 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_activebooleannoWhether the variant participates in the split.
metadataobjectnoFree-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.

ParameterInTypeDescription
idpathuuidCampaign id.
variantIdpathuuidVariant 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.

ParameterInTypeDescription
idpathuuidCampaign id.
variantIdpathuuidVariant 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign id.
fileform (multipart)fileRequired. The file to upload (max 15 MB). Executable and script types are rejected.
step_idform (multipart)uuidOptional. 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.

ParameterInTypeDescription
idpathuuidCampaign id.
attachmentIdpathuuidAttachment 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign id.

Request body

FieldTypeRequiredDescription
account_iduuidyesSending mailbox id.
recipientstring (email)yesWhere to send the test.
step_iduuidnoStep 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.

ParameterInTypeDescription
idpathuuidCampaign id.

Response

{ "status": "started" }

Stop a campaign

POST /campaigns/:id/stop

Stop (pause) an active campaign. Scope SEND_CAMPAIGNS · Org permission send_campaigns.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign id.
cursorquerystringOpaque cursor from the previous page. Optional.
limitqueryintegerPage 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign id.

Request body

FieldTypeRequiredDescription
sendersobject[]yesThe 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign 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.

ParameterInTypeDescription
idpathuuidCampaign id.
sidpathuuidSequence (step) id.

Request body

All fields optional.

FieldTypeRequiredDescription
namestringnoStep label.
subjectstringnoSubject template.
body_plainstringnoPlain-text body template.
body_htmlstringnoHTML body template.
body_syncbooleannoKeep plain and HTML bodies in sync.
body_codebooleannoTreat the body as raw code (no auto-formatting).
wait_afterintegernoMinutes to wait after this step before the next (the spacing model, there is no standalone wait node for email steps).
conditionsobjectnoBranching tree ({branches: [...]}). Send {} or empty branches to clear branching and fall back to linear progression.
kindstringnoemail (default), action, or wait.
actionobjectnoTyped 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.

ParameterInTypeDescription
idpathuuidCampaign id.
sidpathuuidSequence (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

FieldTypeRequiredDescription
subjectstringnoSubject template.
body_htmlstringnoHTML body template.
body_plainstringnoPlain-text body template.
contactobjectnoOverride 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

FieldTypeRequiredDescription
promptstringyesThe instruction to generate from (max 8000 characters).
tonestringnoDesired 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.

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 guide. Scope WRITE_CAMPAIGNS · Org permission manage_campaigns and use_ai.

Request body

FieldTypeRequiredDescription
modestringnoinstant (default) for a single fast completion, or research for the deeper, higher-cost path.
promptstringyesThe block instruction, itself a template rendered against the contact (max 8000 characters).
tonestringnoDesired tone.
web_searchbooleannoAllow one bounded web lookup about the contact's company to enrich context. Implied by research.
contact_idstringnoA contact in the organization to render the prompt against. Omit to use a sample contact.
context_beforestringnoThe email text immediately before the block, so the fragment fits the sentence it lands in.
context_afterstringnoThe email text immediately after the block.
{ "mode": "instant", "prompt": "One line noting something specific about {{company}}.", "contact_id": "4f6c..." }

Response

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

On this page