SendSetsDocs
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 SendSets. 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.
kindquerystringsequence or one_time. 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",
      "kind": "sequence",
      "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,
      "entry_delay_minutes": 0,
      "continuous": false,
      "idle_since": null,
      "tracking_domain": "",
      "tracking_domain_verified": false,
      "utm_tracking": true,
      "utm_source": "",
      "utm_medium": "",
      "utm_campaign": "",
      "updated_at": "2026-06-10T12:00:00Z",
      "created_at": "2026-06-01T09:00:00Z"
    }
  ],
  "pagination": {
    "total": 12,
    "next_cursor": "c1_b3BhcXVlLWN1cnNvcg",
    "has_more": true
  }
}

Campaigns overview

GET /campaigns-overview

Status-bucket counts plus per-folder totals for the organization, used to drive campaign browsing UIs. paused sums every paused variant (paused, paused_no_accounts, paused_trial_expired); one_time counts campaigns of that kind whatever their status. 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,
  "one_time": 2,
  "folders": [
    { "folder_id": "6b9c1c8e-1f2a-4d3b-8c7e-9a0b1c2d3e4f", "total": 5 }
  ]
}

Estimate a send

POST /campaigns-estimate

Project an audience against a sender pool before a campaign exists: how many contacts the segments resolve to, how many mailboxes would send, the pool's daily ceiling under the campaign limit, and the day the last send is expected to land. Nothing is written, so it needs no Idempotency-Key. The dashboard's one-time email wizard shows this on its last step. Scope READ_CAMPAIGNS · Org permission view_campaigns.

Request body

FieldTypeRequiredDescription
segment_idsstring[]yesSegments making up the audience (at most 20). A contact in several of them is counted once.
email_tag_idsstring[]noMailbox tags that resolve the pool. Omit or send [] for every active mailbox in the workspace.
daily_limitintegernoPer-mailbox campaign cap to apply (defaults to 50). Each mailbox counts the smaller of this and its own cap.
daysinteger (0-127)noWeekday bitmask of sending days, bit 0 = Monday. Defaults to weekdays.
timezonestringnoIANA timezone the days are counted in. Defaults to UTC.
start_datestring (RFC 3339)noWhen sending begins. Omit for now.

Response

sending_days and estimated_finish_at are null when the audience is empty, the pool has no capacity, or the send would take longer than two years. Today only contributes what the pool has not already sent (remaining_today).

{
  "recipients": 1000,
  "mailboxes": 4,
  "daily_capacity": 200,
  "remaining_today": 140,
  "sending_days": 5,
  "estimated_finish_at": "2026-09-09T00:00:00+02:00"
}

Create a campaign

POST /campaigns

Create a campaign. Only name is required, every other field is optional and applied only when sent (the wizard sends everything at once, a simple modal can send just {name, description} and get sane defaults). Scope WRITE_CAMPAIGNS · Org permission manage_campaigns.

Request body

FieldTypeRequiredDescription
namestringyesCampaign name.
descriptionstringnoFree-text description.
kindstringnosequence (default) or one_time. A one-time email accepts at most one entry in steps here and refuses further email steps later; it is otherwise a normal campaign. Fixed at creation.
stop_on_replybooleannoStop sending to a contact once they reply.
open_trackingbooleannoInsert the open pixel.
link_trackingbooleannoRewrite links through the tracking ticket service. Each link gets its own ticket, so clicks are attributed per link.
utm_trackingbooleannoTag every link with utm_source, utm_medium, utm_campaign and a per-link utm_content at send time. Default false. Values already on a link are kept.
utm_sourcestringnoOverrides the default sendsets. Empty means the default. Up to 128 characters.
utm_mediumstringnoOverrides the default email. Empty means the default.
utm_campaignstringnoOverrides the default, the campaign name as a slug. Empty means the default.
text_onlybooleannoSend plain text only: no HTML part, and open and click tracking are off regardless of their flags.
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), nullablenoEarliest send time. Today or later; omit or send null to start as soon as the campaign is active.
end_datestring (RFC 3339), nullablenoLatest send time. Must be in the future; omit or send null for an open-ended campaign.
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).
schedule_windowsarraynoPer-day sending windows, 7 arrays indexed by weekday (Sunday = 0) of {start, end} minute-of-day intervals. When non-empty it supersedes days, start_time and end_time.
email_tag_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. tags resolves the pool from email_tag_ids, and falls back to every active mailbox in the workspace when no tag and no explicit sender is set. explicit sends from the mailboxes in senders, plus any email_tag_ids set alongside them. It never falls back to every active mailbox, so a pool that empties out parks the campaign at paused_no_accounts instead of widening to the whole workspace.
rotation_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.
entry_delay_minutesintegernoHold a contact's first email this long after they entered the campaign. 0 (the default) sends it as soon as the schedule and mailbox limits allow; the maximum is 129600 (90 days). Follow-up spacing is unaffected: that is each step's wait_after.
continuousbooleannoKeep running for new leads: out of leads, the campaign stays active and waits instead of finishing. Linking a segment, a form or an automation that enrols leads turns it on, and so does starting a campaign whose every lead has finished. Default false.
tracking_domainstringnoCampaign-scoped tracking domain (honored only once verified).
stepsobject[]noInitial sequence steps in order (see create sequence input below). They are connected in order: each step routes unconditionally to the next, waiting that step's wait_after days. The first step's wait_after defaults to 0, follow-ups to 3. A step given no subject, or the same one as the conversation so far, defaults to thread_reply: true and is sent as a reply carrying that conversation's subject; a step with a subject of its own defaults to false and opens a new conversation. Set thread_reply explicitly to override either.
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.

A listed step can be a non-email node: kind is action or wait, and action is the node's config ({"type": "app_action", "app_connection_id", "path", "output_key", "inputs"}, {"type": "wait_for_event", "wait_event_name", "timeout_minutes"}, {"type": "wait", "wait_minutes"}, or any other action type). Listed steps are still connected in order, routed by outcome for a durable node: an app_action continues on success and stops on error; a wait_for_event continues on event and, on timeout, continues unless on_timeout is stop. A step given conditions keeps them verbatim instead. See app connections and product events.

{
  "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": "Audit", "kind": "action", "action": { "type": "app_action", "app_connection_id": "108178ff-...", "path": "/audit", "output_key": "audit", "inputs": { "domain": "{{.Company}}" } } },
    { "name": "Wait", "kind": "action", "action": { "type": "wait_for_event", "wait_event_name": "report.opened", "timeout_minutes": 4320 }, "on_timeout": "stop" },
    { "name": "Step 2", "subject": "", "body_plain": "Your score: {{.App.audit.score}}", "wait_after": 3 }
  ]
}

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. start_date and end_date additionally accept an explicit null to clear the stored date: a null start_date means "start now" and a null end_date means "run open-ended". Changing any schedule field (start_date, end_date, timezone, days, start_time, end_time, schedule_windows, entry_delay_minutes) on an active campaign reschedules its next send immediately, so clearing a future start date or shortening the entry delay takes effect right away. Notable fields: name, description, status, stop_on_reply, open_tracking, link_tracking, text_only, daily_limit, unsubscribe_header, risky_emails, cc, bcc, start_date, end_date, timezone, days, start_time, end_time, schedule_windows, email_tags, folders, contact_order_by, contact_order_dir, contact_order_field, sender_strategy, rotation_mode, ramp_enabled, ramp_start, ramp_increment, ramp_ceiling, esp_match_mode, max_new_leads_per_day, prioritize_new_leads, entry_delay_minutes, continuous, tracking_domain, utm_tracking, utm_source, utm_medium, utm_campaign.

entry_delay_minutes is anchored on when each contact entered the campaign, not on when the campaign started, so a contact enrolled by a linked segment next week waits the same amount from their own arrival. Leads that were in the campaign before the field existed count from the campaign's created_at, so turning a delay on never re-delays leads that have been enrolled for weeks.

A campaign with continuous set stays active when it runs out of leads and carries idle_since while it waits; the timestamp clears once it has something to send. A continuous campaign can be started with no leads at all; starting one that is not continuous and has never had a lead answers 400 no_leads. Starting a campaign that has at least one lead and nothing left to send, because every lead has finished the sequence, turns continuous on and waits for leads (see start a campaign). Adding a lead to a completed campaign by any path (this API, a linked segment, an automation) restarts it through the same launch checks as starting it by hand; a refused restart is written to the campaign's activity log.

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

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

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.

ParameterInTypeDescription
idpathuuidCampaign to copy.

Request body

Optional.

FieldTypeDescription
namestringName for the copy, 3 to 50 characters. Defaults to the source name with (copy) appended.
{ "name": "Q3 outbound, subject B" }

Response

201 Created with the new Campaign object (see the list 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.

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,
      "crm_task_intents": ["positive", "question", "neutral", "negative"],
      "auto_pause_on_negative": false,
      "auto_suppress_on_unsubscribe_keyword": true,
      "hold_on_out_of_office": true,
      "out_of_office_hold_days": 7
    },
    "send_time_optimization": {
      "enabled": false,
      "use_contact_timezone": true,
      "default_contact_timezone": "UTC",
      "preferred_hours": [9, 10, 11, 14, 15, 16],
      "weekend_weight_multiplier": 0.5
    },
    "preflight": {
      "enabled": true,
      "check_tracking_domain": true,
      "check_unsubscribe_header": true,
      "check_ab_variant_configured": false,
      "check_daily_limit": true,
      "check_schedule_window": true,
      "check_content_score": true,
      "min_content_score": 60
    },
    "dashboard": { "enabled": true, "show_suppression_log": true, "show_intent_summary": true, "show_dlq_stats": true }
  },
  "updated_at": "2026-06-10T12:00:00Z"
}

Update advanced settings

PATCH /campaigns/:id/advanced

Replace the campaign's advanced overrides. Scope WRITE_CAMPAIGNS · Org permission manage_settings.

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, "crm_task_intents": ["positive", "question", "neutral", "negative"], "auto_pause_on_negative": false, "auto_suppress_on_unsubscribe_keyword": true, "hold_on_out_of_office": true, "out_of_office_hold_days": 7 },
    "send_time_optimization": { "enabled": true, "use_contact_timezone": true, "default_contact_timezone": "UTC", "preferred_hours": [9, 14], "weekend_weight_multiplier": 0.5 },
    "preflight": { "enabled": true, "check_tracking_domain": true, "check_unsubscribe_header": true, "check_ab_variant_configured": false, "check_daily_limit": true, "check_schedule_window": true, "check_content_score": true, "min_content_score": 60 },
    "dashboard": { "enabled": true, "show_suppression_log": true, "show_intent_summary": true, "show_dlq_stats": true }
  }
}

Response

204 No Content with an empty body.

List A/B variants

GET /campaigns/:id/ab-variants

List the campaign's A/B variants. A variant scoped to a step_id applies to one step, a null sequence id is campaign-level. Scope READ_CAMPAIGNS · Org permission view_campaigns.

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 every attachment of the campaign. Each entry carries a short-lived presigned download url and its step_id: a file scoped to a step is sent by that step alone, and a null step_id means every step of the campaign sends it. Scope READ_CAMPAIGNS · Org permission view_campaigns.

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.sendsets.com/...signed...",
      "created_at": "2026-06-05T08:00:00Z"
    }
  ]
}

Upload an attachment

POST /campaigns/:id/attachments

Upload a file to attach to the campaign, or to one of its steps. Sent as multipart/form-data, not JSON. Scope WRITE_CAMPAIGNS · Org permission manage_campaigns.

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 of this campaign, which is then the only step that sends it. Omit it to attach the file to every step. A step of another campaign returns 404.

Response

201 Created with the created attachment object (same shape as one element of list attachments).

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": "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"
}

Test the campaign with one prospect

POST /campaigns/:id/test

Walk one prospect through the whole campaign on the real runtime: every email step goes out from the campaign's mailbox to the prospect, every app_action calls your app, and every wait_for_event holds until that event arrives for the prospect (POST /events) or its window closes. Delays, sending windows and other action kinds (tags, CRM writes, notifications) are skipped. The prospect becomes a contact of the workspace but not a lead of the campaign, so a later launch never mails it. Real mail is sent, so this sits behind the send permission. Scope SEND_CAMPAIGNS · Org permission send_campaigns.

FieldTypeRequiredDescription
prospect_emailstring (email)one ofThe address to walk through the campaign.
contact_iduuidone ofAn existing contact instead.
mailbox_iduuidnoThe mailbox to send from; default the campaign's first active sender.

Response: 202 Accepted with the run. state is running, waiting_for_event, completed, failed or cancelled; steps[] is the log (step_id, name, kind, state, outcome, detail, output, error), app_variables the captured .App object so far, and waiting_event the event a waiting run needs. A run that ends in failed carries error with run_failed and a fix. A wait that receives nothing for 24 hours (or the step's own timeout, if shorter) fails the run.

{
  "id": "dd8575d1-...",
  "campaign_id": "560eaf50-...",
  "prospect_email": "you@example.com",
  "state": "waiting_for_event",
  "waiting_event": "report.opened",
  "waiting_until": "2026-09-19T10:00:00Z",
  "app_variables": { "audit": { "score": 67 } },
  "steps": [
    { "name": "Intro", "kind": "email", "state": "completed", "outcome": "success", "detail": "sent to you@example.com" },
    { "name": "Audit", "kind": "app_action", "state": "completed", "outcome": "success", "output_key": "audit", "output": { "score": 67 } },
    { "name": "Wait", "kind": "wait_for_event", "state": "waiting", "event_name": "report.opened", "detail": "waiting for report.opened" }
  ]
}

GET /campaigns/:id/test-runs lists the campaign's runs (READ_CAMPAIGNS), GET /campaigns/:id/test-runs/:runId returns one (404 event_run_not_found), and POST /campaigns/:id/test-runs/:runId/cancel ends a waiting run (SEND_CAMPAIGNS). sendsets campaign test <id> --prospect you@example.com --wait drives the whole thing and renders it as a checklist.

Send a test email

POST /campaigns/:id/test-email

Send a one-off test of a sequence step to a chosen recipient through a chosen mailbox. The message is assembled the way a real send is: merge fields and spintax resolve for the contact, the files that step sends are attached (the campaign's unscoped attachments plus that step's own), the mailbox signature and the campaign's opt-out footer are appended, and a plain-text campaign ships without an HTML part. The subject is prefixed with [TEST]. Opens and clicks on a test are never tracked, and its opt-out link names no contact, so clicking it suppresses nobody. Defaults to the first step when step_id is omitted. Scope SEND_CAMPAIGNS · Org permission send_campaigns.

The mailbox may be any mailbox of the organization, not only one the caller connected, and it does not have to be in the campaign's sender pool. An API key with an allowed_email_accounts list can only test from those mailboxes. Passing contact_id additionally requires READ_CONTACTS (org permission view_contacts), since the rendered copy reads that contact's fields.

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.
contact_iduuidnoContact of the organization to render the copy for, including its custom fields. Omitted renders for a placeholder contact (Test Recipient at Test Company, with the recipient's address and no custom fields).
{
  "account_id": "5c7d...",
  "recipient": "me@example.com",
  "step_id": "7e3b...",
  "contact_id": "9a2c..."
}

Response

contact_id is present only when one was given.

{
  "message": "test email sent",
  "recipient": "me@example.com",
  "subject": "Quick question, {{.FirstName}}",
  "account_id": "5c7d...",
  "step_id": "7e3b...",
  "contact_id": "9a2c..."
}

Errors

StatusCodeWhen
404not_foundThe campaign, step, mailbox or contact does not belong to the caller's organization.
403forbiddencontact_id given without contact read permission, or account_id outside the API key's allowed mailboxes.
400bad_requestThe campaign has no steps.

Start a campaign

POST /campaigns/:id/start

Start (activate) the campaign so it begins sending real mail. Works from draft, any paused status, or completed (a campaign closed by a passed end date resumes once the date is extended or cleared). A campaign with nothing left to send does not finish again: the start turns continuous on if it was off, and the campaign goes active and waits for leads with idle_since set; the switch is written to its activity log. Scope SEND_CAMPAIGNS · Org permission send_campaigns.

ParameterInTypeDescription
idpathuuidCampaign id.
acknowledge_list_riskbodybooleanOptional. Launch even though the list's projected bounce rate would be refused (list_bounce_risk), for a list verified elsewhere.

A start refused because every remaining lead was refused by address verification answers leads_undeliverable and parks the campaign at paused_undeliverable; re-verify the leads or mark them deliverable with POST /contacts/verification, which resumes it.

A campaign whose email step has nothing in either body is refused with empty_step_body rather than started, because it would send a blank message to every lead it reached.

Response

{ "status": "started", "waiting_for_leads": false }

waiting_for_leads is true when the campaign started with nothing left to send and is now active with idle_since set.

Stop a campaign

POST /campaigns/:id/stop

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

ParameterInTypeDescription
idpathuuidCampaign id.

Response

{ "status": "stopped" }

Get a lead's hold

GET /campaigns/:id/leads/:contact_id/hold

Read whether one contact's flow inside this campaign is currently held. A hold parks the lead's next step without unsubscribing the contact and without removing them from the campaign; the two things that write one are an out-of-office auto-reply and a member pausing the lead by hand. Scope READ_CAMPAIGNS · Org permission view_campaigns.

ParameterInTypeDescription
idpathuuidCampaign id.
contact_idpathuuidContact id. Must already be a lead of the campaign.

Response

{
  "campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
  "contact_id": "3a5e9c71-4f2b-4d88-9a0c-1b7e5d2f6c34",
  "hold": {
    "since": "2026-09-07T09:14:00Z",
    "until": "2026-09-09T00:00:00Z",
    "reason": "back 8 Sep 2026",
    "source": "out_of_office"
  }
}

hold is absent when the lead is not held, including for a dated hold that has since expired. until is absent when the hold has no end, in which case only a resume lifts it. source is out_of_office or manual.

Errors

StatusCodeWhen
404not_foundThe campaign is not the caller's organization's, or the contact is not a lead of it.

Pause a lead

POST /campaigns/:id/leads/:contact_id/pause

Hold one contact's flow inside one campaign. The contact stays subscribed and stays a lead; the sequence picks up where it stopped when the hold lifts. This is the per-contact lever between leaving a lead alone and the two permanent ones, unsubscribing the contact and adding the address to the suppression list. Scope WRITE_CAMPAIGNS · Org permission manage_campaigns.

The request states an absolute hold rather than applying a delta, and replacing a hold that is still live keeps its original start, so a retry lands on exactly the same row and no Idempotency-Key is needed.

ParameterInTypeDescription
idpathuuidCampaign id.
contact_idpathuuidContact id. Must already be a lead of the campaign.
untilbodystringRFC 3339 timestamp the hold lifts at. Must be in the future and within a year. Omit or send null for a hold with no end, which only a resume lifts.
reasonbodystringOptional note shown next to the hold in the dashboard. Trimmed and capped at 200 characters.

A manual pause always wins: it replaces a hold an out-of-office auto-reply wrote, and a later auto-reply never shortens it or takes it over.

Request body

{ "until": "2026-09-21T17:00:00Z", "reason": "On holiday, asked to follow up later" }

Response

Same shape as get a lead's hold.

Errors

StatusCodeWhen
400bad_requestuntil is in the past, or more than a year away.
404not_foundThe campaign is not the caller's organization's, or the contact is not a lead of it.

Resume a lead

POST /campaigns/:id/leads/:contact_id/resume

Lift the hold now. The held time is dropped rather than carried, so the step returns to the schedule it would have had without the hold: the campaign's next pass when that moment has already passed, otherwise when the step's own wait elapses. The campaign's own wakeup is pulled forward with it, and a campaign that had finished while the lead was held is restarted through the usual launch checks. Scope WRITE_CAMPAIGNS · Org permission manage_campaigns.

Resuming a lead that is not held succeeds and changes nothing, so a retry is safe.

ParameterInTypeDescription
idpathuuidCampaign id.
contact_idpathuuidContact id.

Response

{
  "campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
  "contact_id": "3a5e9c71-4f2b-4d88-9a0c-1b7e5d2f6c34"
}

Errors

StatusCodeWhen
404not_foundThe campaign is not the caller's organization's, or the contact is not a lead of it.

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). An empty array clears the pool. What that means depends on the campaign's sender_strategy: a tags campaign falls back to its tags or to every active mailbox, while an explicit one falls back to its tags only, and with none it is left with no mailbox to send from and parks itself at paused_no_accounts.
{
  "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 linked segments

GET /campaigns/:id/segments

Return the segments linked to the campaign as live audience sources. Scope READ_CAMPAIGNS · Org permission view_campaigns.

ParameterInTypeDescription
idpathuuidCampaign id.

Response

A data array of link objects (no pagination wrapper). The counts are evaluated when you ask: contact_count is how many contacts the segment matches now, lead_count how many of them are leads of this campaign, and held_out_count how many are not leads because they were removed from the campaign by hand (see replace linked segments).

{
  "data": [
    {
      "segment_id": "4b9e...",
      "name": "Warm leads",
      "color": "#0ea5e9",
      "description": "Replied or clicked in the last 30 days",
      "contact_count": 412,
      "lead_count": 409,
      "held_out_count": 3,
      "linked_at": "2026-06-10T12:00:00Z"
    }
  ]
}

Replace linked segments

PUT /campaigns/:id/segments

Atomically replace the campaign's linked segments with the supplied set (up to 20). Every current member of a newly linked segment is enrolled as a lead immediately, and contacts who enter a linked segment later are enrolled automatically, within about 2 minutes.

Removing a segment from the set withdraws the leads it enrolled, so replacing one segment with another leaves the campaign holding the new audience rather than both. A lead is only withdrawn when all three hold: a linked segment enrolled it (a lead added through any other path counts as chosen by hand and is never withdrawn), it is still a current member of a segment being removed and of none that stayed, and the campaign has not written to it yet (no step dispatched or sent). Nothing is recorded as a hand-made removal, so re-linking the segment enrols those members again. A contact who merely leaves a still-linked segment keeps their lead row; within a link, enrolment stays additive. A lead removed from the campaign by hand is never re-added automatically; a manual add (or the one-shot enrol below) clears that removal record. Omitting segment_ids returns 400; send an explicit empty array to detach every segment. An active campaign is woken to send to the new leads; a completed campaign is restarted through the full launch checks when a linked segment grows. A linked segment cannot be deleted (DELETE /segments/:id returns 409) until it is removed here. Scope WRITE_CAMPAIGNS · Org permission manage_campaigns.

ParameterInTypeDescription
idpathuuidCampaign id.

Request body

FieldTypeRequiredDescription
segment_idsuuid[]yesThe full new set of linked segments, max 20. An empty array detaches them all.
{
  "segment_ids": ["4b9e...", "7c2f..."]
}

Response

The resulting links plus what the call did to the campaign's leads. added is how many leads it enrolled, and is 0 when every member was already a lead, when the segments match no contacts yet, or when the only members are held out; the per-link counts tell these apart. withdrawn is how many leads a removed segment took back out, and contacted how many of that audience stayed because the campaign had already emailed them. The links, the withdrawal and the enrolment are written in one transaction, so a failure returns an error and changes nothing rather than 200 with added: 0.

{
  "data": [
    {
      "segment_id": "4b9e...",
      "name": "Warm leads",
      "color": "#0ea5e9",
      "description": "Replied or clicked in the last 30 days",
      "contact_count": 412,
      "lead_count": 412,
      "held_out_count": 0,
      "linked_at": "2026-06-10T12:00:00Z"
    }
  ],
  "added": 397,
  "withdrawn": 58,
  "contacted": 4
}

For a one-time snapshot enrolment instead of a live link, see add a segment to a campaign.

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.

ParameterInTypeDescription
idpathuuidCampaign id.

Response

A TrackingDomainStatus object, the same shape the mailbox tracking-domain endpoints return.

{
  "tracking_domain": "track.acme.com",
  "tracking_domain_verified": true,
  "tracking_domain_verified_at": "2026-06-10T12:00:00Z",
  "cname_target": "t.sendsets.com",
  "status": "verified",
  "message": "track.acme.com points at t.sendsets.com.",
  "observed": "t.sendsets.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.

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,
    "thread_reply": true,
    "kind": "email",
    "updated_at": "2026-06-02T10:00:00Z",
    "created_at": "2026-06-02T10:00:00Z"
  }
]

Create a sequence

POST /campaigns/:id/steps

Append a new 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. Leave it empty and the send path renders one from body_html, keeping list bullets, table rows and link destinations, and leaving out the stylesheet.
body_htmlstringnoHTML body template. Sent as written, including a whole document with its own <head>. Any <style> block is inlined onto the elements it matches at send time; see the sequences guide. Send body_plain on its own and the HTML part is rendered from it, because a step with a plain body and an empty HTML one would otherwise arrive blank: every modern client prefers the HTML alternative. A step that already has an HTML body is never overwritten.
body_syncbooleannoKeep plain and HTML bodies in sync.
body_codebooleannoThe body is authored as raw HTML. The dashboard editor opens it as markup instead of parsing it into the visual editor, which keeps only what its schema can represent. It does not change what is sent.
wait_afterintegernoDays to wait before this step, counted from the contact's previous step (0 to 60). Spacing belongs to the target step, so there is no standalone wait node for email steps.
thread_replybooleannoSend this step as a reply on the conversation the contact is already in, rather than as a new email. Default true. A threading step carries the conversation's subject, so its own subject is only used once it is turned off. It has no effect on a contact's first email, which has nothing to reply to.
conditionsobjectnoThe 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.
kindstringnoemail (default), action, or wait.
actionobjectnoTyped 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.
{
  "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.

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 for a contact exactly as the send path would, and report parse errors plus any unresolved {{...}} tokens. With campaign_id and account_id the preview also goes through the rest of the send assembly: the plain-text rule, the mailbox signature and the campaign's opt-out footer are applied in send order, and the response names the sender and lists the attachments the send carries. Tracking pixels and link rewriting are left out. No side effects. Scope READ_CAMPAIGNS · Org permission view_campaigns. Passing contact_id additionally requires READ_CONTACTS (org permission view_contacts), and an API key with an allowed_email_accounts list can only name those mailboxes in account_id.

Request body

FieldTypeRequiredDescription
subjectstringnoSubject template.
body_htmlstringnoHTML body template.
body_plainstringnoPlain-text body template.
contact_iduuidnoA contact of the organization to render for, with its custom fields. Omitted uses the built-in sample contact.
contactobjectnoOverride fields on the contact being rendered for (the sample or the one from contact_id): first_name, last_name, email, company, phone, and a custom_fields map of string to string.
campaign_iduuidnoCampaign of the organization whose opt-out footer, plain-text setting and attachments apply. The opt-out link in the preview names no contact.
step_iduuidnoThe step being previewed, so attachments lists what that step sends. Without it only the campaign-wide files are listed.
account_iduuidnoMailbox of the organization whose signature is appended (when signature sync is on) and which is reported as from.
{
  "subject": "Hi {{first_name}} at {{company}}",
  "body_html": "<p>Hey {{first_name}}, I saw {{company}} is hiring. {{unknown_token}}</p>",
  "contact_id": "9a2c...",
  "campaign_id": "8f1d...",
  "account_id": "5c7d..."
}

Response

A TemplatePreview object. errors lists template parse errors that would block sending, unresolved lists literal tokens left after render. from is present when account_id was given, attachments when campaign_id was given and there are files on the send (the campaign-wide ones plus step_id's own). All four are omitted when empty.

{
  "subject": "Hi Sam at Globex",
  "body_html": "<p>Hey Sam, I saw Globex is hiring. {{unknown_token}}</p><br><br><p>Best, Ana</p><p style=\"font-size:12px;color:#64748b\">Don't want these emails? <a href=\"https://app.example.com/u/...\">Unsubscribe</a></p>",
  "body_plain": "Hey Sam, I saw Globex is hiring. {{unknown_token}}\n\nBest, Ana\n\nDon't want these emails? https://app.example.com/u/...",
  "unresolved": ["{{unknown_token}}"],
  "from": { "name": "Ana Silva", "email": "ana@globex.com" },
  "attachments": [
    { "id": "3b0e...", "filename": "deck.pdf", "size": 482113, "mime_type": "application/pdf" }
  ]
}

Errors

StatusCodeWhen
404not_foundcontact_id, campaign_id or account_id does not belong to the caller's organization.
403forbiddencontact_id given without contact read permission, or account_id outside the API key's allowed mailboxes.

Generate copy with the writing assistant

POST /generation/write

Generate outreach copy with the AI writing assistant. Gated to paid and free-trial organizations, and each call consumes one AI credit (refunded if the provider call fails). Supports Idempotency-Key so a retried request is not double-charged. Scope WRITE_CAMPAIGNS · Org permission manage_campaigns.

Request body

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