Campaigns
Create, preview, schedule and send a campaign to an audience, run A/B tests, and follow each recipient's outcome.
A campaign (the API resource is /v1/broadcasts) mails one audience, or a segment of it, from a marketing sending domain. It is created as a draft, edited while it is one, previewed, and then sent or scheduled. The send runs in the background: the API answers 202 straight away, and you follow progress by reading the campaign. Once a send has started it cannot be recalled.
For the reasoning behind previews, frozen audiences, A/B tests and pauses, read the Campaigns guide. Contacts and lists are managed through audiences and segments, and per-topic opt-outs through topics.
The campaign object
Every field is always present. A field with no value is null, and a list with nothing in it is [], so a campaign has the same keys whichever endpoint returns it. The one exception is List campaigns, whose items leave out html.
| Field | Type | Description |
|---|---|---|
id | string | The campaign's id (a UUID). |
audience_id | string | The audience it mails. |
identity_id | string | The marketing sending domain it goes out on. |
name | string | Your name for the campaign. Not shown to recipients. |
subject | string | The subject line. An A/B variant can override it. |
html | string | The body. Not included in list items. Every recipient gets the same HTML: nothing is substituted per recipient. The unsubscribe footer and, when tracking is live, rewritten links and an open pixel are added at send time. |
from_name | string | null | Display name on the From header, or null when never set. The address is derived from the sending domain: its first label at the domain, so news.example.com sends as news@news.example.com. |
segment | object | null | The inline filter: opened_within_days, clicked_within_days, exclude_unengaged_days and engaged_within_days, each an integer or null, and tags and exclude_tags, each an array ([] when not given). null when no filter was given. |
segment_id | string | null | The saved segment it targets, or null. A saved segment wins over segment when both are set. |
topic_key | string | null | The topic it is sent under, or null. |
status | string | Where the campaign is in its life. See Statuses. |
scheduled_at | string (ISO 8601) | null | When it is due to start. null until the campaign is scheduled, and again after it is returned to draft. |
started_at | string (ISO 8601) | null | When a run last claimed the campaign. null until the first run. Rewritten each time a run starts, including every resume. |
completed_at | string (ISO 8601) | null | When it finished as sent or failed. |
pause_reason | string | null | Why it paused, in words you can act on. Set only while the campaign is paused: a run that picks it up again clears it, and a new pause writes a new one. |
resume_after | string (ISO 8601) | null | Set while the campaign is paused on its sending domain's warm-up allowance: the moment that allowance renews (the next 00:00 UTC). Neither the worker nor Resume a campaign picks it up earlier. |
failure_reason | string | null | Why it failed: the reputation gate, the plan allowance, an A/B sample too small, or a sending domain that is not verified or not marketing. Set on every failed campaign; a campaign stopped by an unexpected error carries a generic message. |
total_recipients | integer | Size of the frozen audience, after suppressions and topic opt-outs. 0 until the first run freezes it. |
sent_count | integer | Messages accepted for delivery so far. |
failed_count | integer | Messages the provider refused. A message the provider throttled, or one held back because sending is suspended or the workspace has no postal address, is not counted: its address goes back to pending and the campaign pauses (see Pauses). |
created_at | string (ISO 8601) | When it was created. |
variants | array of object | The A/B variants, [] on a campaign that is not a test. Each has key (string), subject (string | null), from_name (string | null) and send_at (string (ISO 8601) | null), the last three null when the variant does not set them. |
ab_test | object | null | A/B tests only, null otherwise. See The ab_test object. |
progress | object | null | Counts from the frozen audience, while the campaign is sending, paused or testing: on retrieve, the list, and the send, resume and winner actions. null in every other status, and on create and update. See The progress object. |
warnings | array of string | Settings that will not do what they say yet, worked out from the stored campaign on every response. The only one today: an A/B test with metric: "click" carries a reminder that click tracking must be live on the sending domain. [] otherwise. |
Statuses
| Status | Meaning |
|---|---|
draft | Created and editable. Nothing has been sent. |
scheduled | Waiting for scheduled_at. A send-time A/B test also returns here between its variants. |
sending | A run has claimed it and is mailing the frozen audience. |
paused | Stopped with addresses still pending (possibly before any were sent). See Pauses for the reasons and what continues each. |
testing | An A/B test whose sample has gone out (for a send-time test, every variant has gone out) and whose winner is not yet decided, or decided but not yet handed on. |
sent | Every address in the frozen audience has been dealt with (sent, failed or skipped). |
failed | Refused before sending (reputation gate, plan allowance, A/B sample too small, sending domain not verified or not marketing) or stopped by an unexpected error. See failure_reason. |
There is no canceled status. Deleting a campaign removes it, and updating a scheduled campaign with scheduled_at: null returns it to draft.
The ab_test object
| Field | Type | Description |
|---|---|---|
metric | "open" | "click" | What decides the winner. |
sample_share | number | Share of the audience in the test sample, 0.05 to 1. Always 1 for a send-time test. |
decide_after_minutes | integer | How long after the sample is fully sent the winner is decided. |
sample_completed_at | string (ISO 8601) | null | When the run that finished sending the sample ended. Set once. |
decide_at | string (ISO 8601) | null | When the worker will decide on its own. Counted from sample_completed_at, not from the start of the send. |
winner | string | null | The winning variant's key. |
decided_by | "auto" | "manual" | null | Whether the worker or a call to decide the winner picked it. |
decided_at | string (ISO 8601) | null | When the winner was recorded. |
results | array of object | Per variant: key, sent, opened, clicked (integers) and open_rate, click_rate (numbers from 0 to 1). opened and clicked count unique recipients, not events, so one reader opening five times counts once. |
Which figures results holds depends on the endpoint. Retrieve, the list, and the send, resume and winner actions compute them live once the campaign has left draft. Create and update return the snapshot taken when the winner was decided, which is an empty array before that.
Pauses
A campaign pauses rather than failing when it cannot continue for now, with its remaining addresses still pending, and pause_reason says why:
- Warm-up. A sending domain that is new to sending gets a daily marketing ceiling that grows each day, counted from its first send (200 on day 1, 500 on day 2, 1,000 on day 3, and so on for fourteen days). The ceiling counts every marketing message the domain sent that UTC day, automation steps and marketing API sends included. A campaign that reaches it pauses and continues the next day at the next, larger step. This is the warm-up working, not a failure: a large first campaign from a new domain is meant to take several days.
resume_aftergives the moment the allowance renews (00:00 UTC). The worker picks the campaign up after that, and Resume a campaign refuses before it. - Sending quota. The account's 24-hour sending quota ran out. The worker continues the campaign when the window frees up. Resume a campaign does not check the quota: called while it is still used up, it answers
202and the campaign pauses again with the same reason. - Throttling. The email provider throttled sending. The addresses it turned away go back to pending, and the worker continues on its next check.
- No postal address. Marketing mail carries the workspace's postal address in its footer, and a campaign never goes out without it. A campaign whose workspace has none, including one whose address is removed part way through a send, pauses before its next batch. The worker continues it once an address is set.
- Suspended. Sending was suspended for the workspace. The campaign pauses before its next batch and stays paused while the suspension lasts. Once it is lifted the worker continues the campaign on its next check, like any other pause, and Resume a campaign works too. Everything a run checks is checked again, including suppressions and topic opt-outs at each address's turn.
- Interrupted. The run sending the campaign stopped unexpectedly, for example in a restart. After 30 minutes with no progress the worker returns the campaign to
pausedand continues it. Addresses the stopped run had claimed are settled from the email log: one whose message the provider accepted is marked sent and not mailed again, one whose message the provider refused is marked failed, and one whose message is stillqueued, or that has no message, is retried. A message the provider accepted in the instant before the run stopped cannot be told apart from one it never received, so in that narrow case a recipient can get the campaign twice.
A background worker checks every 30 seconds. On each check it recovers interrupted runs, then resumes one paused campaign that can make progress, then decides one A/B test that is due, then starts one due scheduled campaign, one after another. A paused campaign that cannot send yet (warm-up not renewed, no postal address, a suspended workspace) is passed over without being claimed, so it never holds up decisions, scheduled starts, or other paused campaigns. Campaigns in a suspended workspace are not considered at all until the suspension is lifted, whatever they paused for. Resuming and deciding wait while the account has no 24-hour quota left. Every run, a resume included, checks the reputation gate and the sending domain again, so a paused campaign can still end failed if either stops passing.
The audience is frozen the first time a campaign runs: one row per address, with A/B arms assigned at the same moment. The freeze finishes before anything is sent, and a run that stops part way through it completes it on the next run, adding only the rows that are missing. Every later run, whether a resume, the second phase of an A/B test or the next send-time variant, drains only what is still pending from that snapshot. It is never re-resolved, so nobody who aged out of a segment mid-send is silently dropped, and nobody is mailed twice. Permission is not frozen: suppressions and topic opt-outs are checked again at each address's turn, and an address that opted out since the freeze is marked skipped.
Keys with guardrails
A campaign respects the guardrails of the API key that sends it. They apply to the calls that put a campaign's mail on its way: Send a campaign (starting, scheduling or rescheduling), a new scheduled_at through Update a campaign, Resume a campaign and Decide an A/B test winner. Creating, editing, previewing, reading, unscheduling (scheduled_at: null) and deleting a campaign are not affected.
- A key with
requires_approvalcannot make any of those calls:403 forbidden. A campaign cannot be held for approval the way a single email can. - A key with
allowed_recipientscannot make any of them either:403 forbidden. An audience cannot be held to an allowlist without silently becoming a different audience. - A key with a
daily_send_limitis charged once for each campaign it puts on its way, whoever created or scheduled it. Sending or scheduling adraftcounts the preview'srecipient_count, the audience as it resolves at the moment of the call, after suppressions and topic opt-outs, including a campaign scheduled for a later day. Starting ascheduledcampaign now, moving it to an earlier time, resuming it and deciding its A/B winner count what the campaign still has to send (its whole audience if it has never started, otherwise the addresses still pending), unless this key already paid for the campaign, in which case they count nothing. So a campaign scheduled from the dashboard or by another key counts against the key that starts it early, and a campaign this key scheduled does not count twice. Moving a campaign to a later time counts nothing. When the recipients do not fit what is left of the key's limit today the call is refused with429 daily_limitand nothing changes. Returning a campaign todraftclears its charge, so sending or scheduling it again counts again.
The 403 comes before anything about the campaign itself. Once the campaign is found, a key with requires_approval or allowed_recipients is refused before its status is checked and before the body is validated, so it hears 403 forbidden rather than 409 invalid_state for a campaign that is already sending or 422 invalid_request for a bad scheduled_at. Only a campaign id that does not exist in the workspace answers first, with 404. The daily limit is different: it depends on the audience, so it is checked last.
OAuth access tokens and keys without guardrails are not affected, and neither is anyone working in the dashboard.
List campaigns
GET /v1/broadcasts · requires broadcasts:read
Returns a cursor-paged list of campaigns, newest first. Each item is the campaign object without html: a list is for finding a campaign, and Retrieve a campaign returns its body.
Every other field is what retrieve returns for the same campaign, including live A/B results and, for a campaign that is sending, paused or testing, its progress.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Page size, at most 100. A larger value is lowered to 100; zero, a negative or a non-number gives 50. |
cursor | string | none | The next_cursor from the previous page. |
Example
Response
200 OK
Create a campaign
POST /v1/broadcasts · requires broadcasts:write
Creates a campaign as a draft. Nothing is sent until you call Send a campaign, and nothing about the audience is resolved yet: run Preview a campaign to see who it would reach.
Every reference is checked against your workspace before the campaign exists. audience_id, identity_id and segment_id take ids, not names: passing an audience's name, a domain name, or a segment's name or key answers 422 with the id to use. The sending domain must have risk_class "marketing" (see domains); a transactional one is refused, so a campaign's complaints can never touch password resets. Whether the domain is verified is not checked here. An unverified domain makes the send fail later.
Narrow the audience with a saved segment_id (it must filter the same audience as audience_id) or an inline segment. When both are given the saved segment is used and the inline one is ignored. Contacts marked unsubscribed are never included.
topic_key sends the campaign under a topic: recipients opted out of it are left out (for a topic that is off by default, only people who opted in are included), and the footer's unsubscribe link opts people out of that topic only. The key must name one of your topics; an unknown key is refused with 422 unknown_topic, because it would count everyone as opted out and the campaign would reach nobody.
Pass variants to make the campaign an A/B test over subject, from name or send time. Anything a variant leaves unset falls back to the campaign's own value. A subject or from-name test mails sample_share of the audience split evenly across the variants, waits decide_after_minutes after the sample is fully sent, and sends the rest to the variant with the best unique open rate (or click rate). Ties, and a test where nothing was opened or clicked, go to the first variant, so put your control first. Each address's arm is a hash of the campaign id and the address, so it never changes. A send-time test sets send_at on every variant: there is no sample, the whole audience is split across the variants, and each mails its share at its own time. After the last variant has gone out the campaign still enters testing, waits decide_after_minutes, records a winner from the figures (which mails nobody, since every address already had a variant), and only then becomes sent.
Fields the schema does not list are ignored. In particular scheduled_at belongs on the send call, not here.
Body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
audience_id | string | Yes | The audience's id from GET /v1/audiences. | |
identity_id | string | Yes | A marketing sending domain's id from GET /v1/domains. | |
name | string | Yes | 1 to 200 characters. | |
subject | string | Yes | 1 to 998 characters. | |
html | string | Yes | The body. At least 1 character. | |
from_name | string | No | none | Display name on the From header. Surrounding whitespace is trimmed, then it must be 1 to 100 characters with no <, >, " or line break. |
segment_id | string | No | none | A saved segment's id from GET /v1/segments, on the same audience. Surrounding whitespace is ignored. |
segment | object | No | none | Inline filter. Any of opened_within_days, clicked_within_days, exclude_unengaged_days and engaged_within_days, each a positive integer up to 3650, and tags and exclude_tags, each up to 20 tags of 1 to 60 characters. Every rule given must hold. Other keys are ignored. See below. |
topic_key | string | No | none | The key of an existing topic to send under. Surrounding whitespace is ignored. |
variants | array of object | No | none | 2 to 10 variants. Makes the campaign an A/B test. |
variants[].key | string | Yes | Your name for the variant, unique within the campaign: lowercase letters, digits, - and _, starting with a letter or digit, up to 32 characters. | |
variants[].subject | string | No | campaign's subject | 1 to 998 characters, not only whitespace. |
variants[].from_name | string | No | campaign's from_name | Same rules as from_name. |
variants[].send_at | string (ISO 8601) | No | none | Send-time test only. UTC with a Z suffix, for example 2026-09-16T09:00:00.000Z. Set on every variant or on none. |
ab_test | object | No | see below | Test settings. Only accepted with variants. |
ab_test.metric | "open" | "click" | No | "open" | What decides the winner. |
ab_test.sample_share | number | No | 0.2 | Share of the audience in the sample, 0.05 to 1. Ignored and stored as 1 for a send-time test. |
ab_test.decide_after_minutes | integer | No | 240 | 15 to 10080 (seven days). |
The inline segment filters on engagement, counted back from the moment the audience is resolved. opened_within_days keeps contacts who opened a message within that many days, and clicked_within_days those who clicked one. exclude_unengaged_days leaves out contacts who have been mailed but neither opened nor clicked within the window; a contact never mailed, or added within the window, is kept. engaged_within_days keeps only contacts who opened, clicked, or were active in your product (last_active_at) within the window, with no such allowance.
tags keeps only contacts carrying every one of the listed tags, and exclude_tags leaves out anyone carrying any of them; an exclusion beats an inclusion. Tags are normalised as contact tags are, so "Audio Player" matches audio-player. This is how to mail part of an audience that holds everyone: "audience_id" your whole list, "segment": { "tags": ["audio-player"], "exclude_tags": ["premium"] } for the free users of one product. The same rules on a saved segment are the reusable form, and segment_id wins when both are given.
Every variant needs at least 100 recipients in the sample when the campaign is sent, or the send fails with the shortfall in failure_reason. Preview reports the number beforehand.
metric: "click" needs click tracking live on the sending domain (click_tracking.status "active" on GET /v1/domains). It is accepted either way, with a warnings entry saying so: without tracking every variant records zero clicks and the first one wins.
Example
An A/B test on the subject line, with the control first:
Response
201 Created
The new campaign object, with exactly the fields a read returns: anything not given is null, and progress is null on a draft. warnings is [] unless a setting will not do what it says yet; only metric: "click" produces one.
For the A/B example, variants and ab_test are filled in instead of [] and null:
Errors
| Status | Type | When |
|---|---|---|
| 422 | invalid_request | A field fails the schema (missing, wrong type, out of range, or an invalid from_name). The message is Validation failed and details lists each problem; for from_name it names the rule broken. |
| 422 | invalid_request | audience_id, identity_id or segment_id is not an id in your workspace. The message starts with the field name and, when the value is a name, gives the id. |
| 422 | unknown_topic | topic_key matches no topic in your workspace. The message starts topic_key:. |
| 422 | invalid_request | identity_id is a transactional sending domain. |
| 422 | invalid_request | segment_id filters a different audience than audience_id. |
| 422 | invalid_request | The A/B settings break a rule: ab_test without variants, a bad or repeated variant key, an empty variant subject, send_at on some variants but not all, sample_share outside 0.05 to 1, or decide_after_minutes outside 15 to 10080. The message names the rule. |
Retrieve a campaign
GET /v1/broadcasts/{id} · requires broadcasts:read
One campaign. This is the call to poll after a send.
While the campaign is sending, paused or testing it carries a progress object counted from the frozen audience, which is how you tell a campaign still draining from one that is waiting. For an A/B test that has left draft, ab_test.results holds live figures.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
The progress object
| Field | Type | Description |
|---|---|---|
pending | integer | Addresses not yet dealt with, including any a run has claimed and not finished. |
sent | integer | Addresses whose message was accepted. |
failed | integer | Addresses whose message the provider refused. Throttled addresses, and addresses held back by a suspension or a missing postal address, stay in pending. |
skipped | integer | Addresses suppressed or opted out of the topic between the freeze and their turn, or on a domain that cannot receive mail. |
by_variant | array of object | null | A/B tests only, null otherwise. One entry per variant, in the campaign's order, each with key and the same four counts, then a last entry with key: null counting the addresses outside the sample that are waiting for the winner. Every variant is listed, with zeros when it has no rows. |
All counts are 0 in the moment between a run claiming the campaign and freezing its audience, and on a campaign that paused before its audience was frozen (the workspace had no postal address or was suspended when the campaign started). That campaign freezes its audience when it next runs.
Example
Response
200 OK
A campaign object. This example is an A/B test in testing: the sample of 2,000 is out and 8,000 addresses wait for the winner.
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace. When the value is a campaign's name, the message gives its id. |
Update a campaign
PATCH /v1/broadcasts/{id} · requires broadcasts:write
Edits a draft, or moves or unschedules a scheduled campaign. Once a campaign has started it cannot be edited.
On a scheduled campaign the body may hold only scheduled_at. A new time moves the campaign to it (refused for a key with requires_approval or allowed_recipients, see Keys with guardrails); null returns it to draft, where it can be edited and then sent or scheduled again. Any other field on a scheduled campaign answers 409 invalid_state: unschedule it first. A campaign that has already run cannot be unscheduled, which covers a send-time A/B test waiting between its variants with part of its audience mailed. On a draft, scheduled_at is refused: schedule a draft with Send a campaign.
For a draft, only the fields below can change. The audience, sending domain, segment and topic are fixed at creation; to change them, delete the draft and create another. Fields outside the list are ignored.
variants and ab_test travel together. Sending only ab_test applies it over the stored variants, and merges over the stored settings, so changing sample_share keeps decide_after_minutes. Sending only variants replaces the variant list and keeps the stored test settings. A send-time test stores sample_share as 1, so turning one into a subject or from-name test by sending only variants keeps a sample of the whole audience; send ab_test.sample_share with the variants. A plain campaign becomes an A/B test by sending variants. There is no way to remove a test, or clear from_name, through the API.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
Body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | No | unchanged | 1 to 200 characters. |
subject | string | No | unchanged | 1 to 998 characters. |
html | string | No | unchanged | At least 1 character. |
from_name | string | No | unchanged | Same rules as on create. |
variants | array of object | No | unchanged | 2 to 10 variants, same shape and rules as on create. Replaces the whole list. |
ab_test | object | No | unchanged | metric, sample_share, decide_after_minutes, as on create. Merged over the stored settings. |
scheduled_at | string (ISO 8601) | null | No | unchanged | Scheduled campaigns only, and alone in the body. A UTC timestamp with a Z suffix moves the campaign; null returns it to draft. A new time is not accepted for a send-time test. |
Example
Response
200 OK
The updated campaign object, read back after the change. After a scheduled_at change it shows the new status and scheduled_at.
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace, including one deleted while the change was being applied. |
| 403 | forbidden | A scheduled_at other than null from a key with requires_approval or allowed_recipients, whatever the campaign's status and whether or not the value is valid. See Keys with guardrails. scheduled_at: null is allowed. |
| 409 | invalid_state | The campaign is not a draft, and is not a scheduled campaign given only scheduled_at. The message names its status. |
| 409 | invalid_state | scheduled_at: null on a campaign that has already run, or that started or was deleted while the change was being made. |
| 409 | invalid_state | A new scheduled_at on a campaign that a run claimed while the change was being made. The message names its status. |
| 422 | invalid_request | A field fails the schema. The message is Validation failed and details lists each problem. |
| 422 | invalid_request | scheduled_at on a draft, or a new scheduled_at on a send-time A/B test. |
| 422 | invalid_request | The A/B settings break a rule, as on create. ab_test alone on a campaign with no variants is refused with ab_test needs variants. |
| 429 | daily_limit | A new scheduled_at earlier than the campaign's current time, from a key with a daily_send_limit that has not paid for the campaign, when what it still has to send would exceed the key's limit today. Nothing changes. See Keys with guardrails. |
Delete a campaign
DELETE /v1/broadcasts/{id} · requires broadcasts:write
Deletes the campaign and its frozen audience. It works in every status except sending: a draft, a scheduled campaign (which is removed, not returned to draft; update it with scheduled_at: null for that), and also a paused, testing, sent or failed one. Deleting is the way to abandon a paused campaign; there is no separate cancel.
Deleting a paused or testing campaign discards everyone still pending, and it cannot be resumed or recreated with the same progress. For an A/B test in testing that is the whole remainder waiting for the winner. Messages already sent stay in the email log, but the campaign's recipient list and clicked links are no longer reachable.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
Example
Response
200 OK
| Field | Type | Description |
|---|---|---|
id | string | The deleted campaign's id. |
deleted | boolean | Always true. |
recipients_discarded | integer | Rows of the frozen audience removed, in any state. 0 for a campaign that never ran. |
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace. |
| 409 | invalid_state | The campaign is sending, including one a run claimed while the delete was being made. Delete it once it has finished or paused. |
Preview a campaign
GET /v1/broadcasts/{id}/preview · requires broadcasts:read
Who the campaign would reach if it started now, and whether it would be allowed to. Run it before every send: it is the only way to learn the size of a campaign without starting it.
The audience is resolved live from the current contacts, segment and topic preferences, the same way the first run freezes it. On a campaign that has already started, the preview therefore describes today's audience, not the frozen one; use Retrieve a campaign for that.
can_send covers two checks: the reputation gate, and for an A/B test whether each variant gets at least 100 recipients in the sample. Once the workspace has sent 1,000 or more messages in the last 14 days, a bounce rate above 4% or a complaint rate above 0.08% over that window blocks the send. The bounce rate counts permanent bounces only, as AWS does; transient ones such as a full mailbox never block a campaign. can_send does not cover the plan allowance, whether the sending domain is verified, warm-up, a missing postal address or a suspended workspace; the send call refuses the last two with a 422. Check usage for the plan allowance and domains for verification. The API does not report a domain's warm-up state.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
sample | integer | 20 | How many addresses to return in sample: a whole number from 1 to 100, a fraction rounded down, a larger value capped at 100. 0, a negative value or anything that is not a number gives the default. |
Example
Response
200 OK
| Field | Type | Description |
|---|---|---|
id | string | The campaign's id. |
recipient_count | integer | Addresses that would be mailed: resolved_count less suppressed_count. |
resolved_count | integer | Subscribed contacts matching the audience and segment, with topic opt-outs already removed. |
suppressed_count | integer | Of those, addresses suppressed for marketing mail (unsubscribes, hard bounces, complaints). |
sample | array of string | The first few addresses from recipient_count. |
can_send | boolean | false when the reputation gate or the A/B sample floor would stop the send. |
blocked_reason | string | null | When the reputation gate blocks, which rate and what to do; null otherwise. |
ab_test | object | null | A/B tests only, null otherwise. See below. |
ab_test holds variants (array of the variant keys), metric, sample_size (integer), per_variant (integer), minimum_per_variant (integer, 100), ok (boolean) and blocked_reason (string | null), which names the shortfall when ok is false and is null when it is true. For a send-time test sample_size is the whole audience.
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace. |
List a campaign's recipients
GET /v1/broadcasts/{id}/recipients · requires broadcasts:read
Every message the campaign produced, with its outcome: who got the August newsletter, and what happened to it. Rows come from the email log, so an address that was skipped at its turn (suppressed, opted out, or on a domain that cannot receive mail) has no message and does not appear here; its count is in the campaign's progress.
This endpoint pages differently from the rest of the API. Rows are ordered by message id, not by time, the cursor is a message id, the default and maximum page size is 200, and after is accepted as another name for cursor.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 200 | Rows per page: a whole number from 1 to 200, a fraction rounded down, a larger value capped at 200. 0, a negative value or anything that is not a number gives the default. |
cursor | string | none | The next_cursor from the previous page. |
after | string | none | The same as cursor, used only when cursor is not given. |
Example
Response
200 OK
| Field | Type | Description |
|---|---|---|
message_id | string | The message's id, for GET /v1/emails/{id}. |
to | string | The recipient's address. |
status | string | The message's status: "queued", "sent", "delivered", "bounced", "complained", "failed" and the other email statuses. See emails. |
sent_at | string (ISO 8601) | null | When the provider accepted it. null if it never was. |
variant | string | null | The A/B variant the recipient got. null on a campaign that is not a test. |
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace. |
List a campaign's clicked links
GET /v1/broadcasts/{id}/links/clicked · requires broadcasts:read
The links recipients clicked, most-clicked first, up to 100 of them. The list is not paged.
Clicks are recorded only while click tracking is live on the sending domain, which needs its link. CNAME published. A security scanner's automated clicks are not counted here, in the campaign's figures or in A/B results; see Automated clicks and opens. An empty list means either that nobody has clicked yet or that clicks are not being tracked. To tell which, retrieve the domain named by the campaign's identity_id: only click_tracking.status "active" records clicks.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
Example
Response
200 OK
The list envelope. The list is not paged: next_cursor is always null, and has_more is true only when the 100-link cap was reached, meaning further links may exist that this endpoint cannot return.
| Field | Type | Description |
|---|---|---|
data[].url | string | The destination URL. |
data[].clicks | integer | Every click, including repeats by the same person. |
data[].unique_recipients | integer | Distinct recipients who clicked it. |
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace. |
Decide an A/B test winner
POST /v1/broadcasts/{id}/winner · requires broadcasts:write
Decides an A/B test now instead of waiting for decide_at, then sends the rest of the audience to the winner. Only a campaign in testing accepts it: before that the sample is still going out, and after it the remainder is already on its way.
Pass variant to choose the winner yourself, or send an empty body to let the metric decide on the figures so far (highest unique rate, ties and all-zero results going to the first variant). There is no significance test; read the live ab_test.results on the campaign first, because a few opens either way is not a result. The decision cannot be changed.
The decision is idempotent. If the worker has already recorded a winner, that winner stands and your variant is ignored: the response carries the worker's decision, with ab_test.decided_by "auto". That holds even when the worker got there in the moment between your call's checks and its decision and has already started sending the remainder: the call still answers 202, and status is where the campaign actually is.
The response is the campaign object with status "sending", but the remainder is sent in the background and the same budgets apply: it can pause on warm-up or quota like any other send.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
Body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
variant | string | No | decided by the metric | The key of the variant to send the remainder to. |
Example
Response
202 Accepted
The campaign object, with the decision in ab_test: winner, decided_by, decided_at, and the live results it was made on.
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace. |
| 403 | forbidden | The key has requires_approval or allowed_recipients. See Keys with guardrails. |
| 409 | invalid_state | The campaign is not an A/B test. |
| 409 | invalid_state | The campaign is not in testing, or left it without a winner while the call was being made. The message names its status. |
| 422 | invalid_request | variant is not a non-empty string (Bad variant), or names a key the campaign does not have. |
| 429 | daily_limit | The key has a daily_send_limit, has not paid for the campaign, and the addresses still pending would exceed its limit today. No winner is decided. See Keys with guardrails. |
Resume a campaign
POST /v1/broadcasts/{id}/resume · requires broadcasts:write
Continues a paused campaign now rather than waiting for the worker. It mails only addresses still pending in the frozen audience, and each address is claimed before it is sent, so calling it twice, or while the worker is also resuming, cannot mail anyone twice. An A/B test paused during its sample resumes the sample; one paused after the decision resumes the winner.
Check pause_reason first. A campaign paused on the warm-up limit cannot be resumed before its resume_after: the call answers 409 invalid_state naming that time, and the worker resumes the campaign on its own once it has passed. While the workspace is suspended or has no postal address the call is refused with the same 422 Send a campaign gives. Once either is fixed you do not need this call: the worker continues the campaign on its own. The account's sending quota is not checked: a campaign paused on quota that is resumed while the quota is still used up answers 202, sends nothing and pauses again with the same pause_reason.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
Example
Response
202 Accepted
The campaign object with status "sending": the run picks it up in the background, so poll Retrieve a campaign for what actually happens. progress.pending is what is left to send, including any addresses a run in progress has claimed. For an A/B test paused during its sample it includes the remainder waiting for the winner, which this run does not send, and it is 0 for a campaign that paused before its audience was frozen, whose run freezes it first.
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace. |
| 403 | forbidden | The key has requires_approval or allowed_recipients. See Keys with guardrails. |
| 409 | invalid_state | The campaign is not paused. The message names its status. |
| 409 | invalid_state | The campaign is paused on warm-up and its allowance has not renewed. The message gives the time it does. |
| 422 | no_postal_address | The workspace has no postal address. Add one in Settings. |
| 422 | workspace_suspended | Sending is suspended for the workspace. |
| 429 | daily_limit | The key has a daily_send_limit, has not paid for the campaign, and the addresses still pending would exceed its limit today. The campaign stays paused. See Keys with guardrails. |
Send a campaign
POST /v1/broadcasts/{id}/send · requires broadcasts:write
Starts a draft or scheduled campaign now, schedules a draft, or moves a scheduled campaign to a new time.
A send cannot be recalled once it starts, and it mails everyone the preview counts. Run Preview a campaign first.
A key with requires_approval or allowed_recipients cannot send, schedule or reschedule a campaign (403 forbidden). A key with a daily_send_limit that has not already paid for the campaign can send or schedule a draft, start a scheduled campaign now, or move it earlier, only when the recipients fit what is left of that limit today (429 daily_limit). See Keys with guardrails.
The call is refused with 422 no_postal_address when the workspace has no postal address, and 422 workspace_suspended when sending is suspended for it, whether the campaign would start now or be scheduled. Marketing mail carries the postal address and a visible unsubscribe link in its footer, and a campaign never goes out without them. Both are checked again when the campaign starts and before every batch, so a campaign whose address is removed or whose workspace is suspended part way through pauses rather than continuing (see Pauses).
With an empty body the campaign starts immediately and the call answers 202 with the campaign object in status: "sending". The fan-out runs in the background, so the response says nothing about whether it will succeed. Poll Retrieve a campaign. These checks run when the send starts and, if they fail, leave the campaign failed rather than returning an error here:
- the reputation gate (see Preview a campaign), with the reason in
failure_reason; - the sending domain must be verified and marketing, with the reason in
failure_reason; - billing: on Free, a campaign larger than what remains of the month's 3,000 emails is refused whole, nothing is sent, and
failure_reasongives the numbers; the same check refuses a campaign from a workspace with no verified payment method, one whose payment has failed for good, or one that has reached its own monthly budget (see usage). A paid workspace's volume is priced, never refused; - for an A/B test, at least 100 recipients per variant in the sample, with the shortfall in
failure_reason.
The plan allowance and the sample floor are checked when the audience is frozen, which is the first run (or the next one, if a run stopped before the freeze finished); the reputation gate and the sending domain are checked on every run.
After those pass, the audience is frozen, suppressed addresses are left out, and messages go out paced against the account's sending rate. The campaign ends sent, or testing once an A/B sample is out, or paused if warm-up or quota runs out (see Pauses).
With scheduled_at the campaign becomes scheduled and the worker starts it once the time has passed. The worker checks every 30 seconds and starts one due campaign per check, after resuming a paused campaign and deciding an A/B test on the same check, so a start can come later than scheduled_at while those runs send (see Pauses). A time in the past is accepted and starts on the next check. Calling send on a campaign that is already scheduled with an empty body starts it now (except a send-time test, below). With a new scheduled_at it moves the campaign to that time and answers with the campaign at its new scheduled_at. Update a campaign with scheduled_at does the same, and with null returns the campaign to draft.
A send-time A/B test is scheduled by its variants: the call takes no scheduled_at, sets the campaign's scheduled_at to the earliest send_at, and answers with the campaign at that time. Each variant mails its share when its time comes, and the campaign returns to scheduled between them. Calling send again on a send-time test that is already scheduled changes nothing and answers 202 with the campaign as stored, whose scheduled_at between variants is the next variant's time.
Path parameters
| Parameter | Description |
|---|---|
id | The campaign's id. |
Body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
scheduled_at | string (ISO 8601) | No | send now | When to start, in UTC with a Z suffix, for example 2026-09-16T09:00:00.000Z. An offset such as +02:00 and relative phrases like in 2 hours are refused. Not accepted for a send-time test. |
Example
Response
202 Accepted
The campaign object, as Retrieve a campaign returns it. Started now, status is "sending" and progress counts from zero while the run freezes the audience in the background; a run that has already finished or failed shows where it ended. Scheduled, status is "scheduled" with the stored scheduled_at.
Scheduled with scheduled_at:
Started now:
Errors
| Status | Type | When |
|---|---|---|
| 404 | not_found | No campaign with this id in your workspace. |
| 403 | forbidden | The key has requires_approval or allowed_recipients. Checked before the campaign's status and the body, so it answers even for a campaign that is already sending. See Keys with guardrails. |
| 409 | invalid_state | The campaign is not draft or scheduled, or a run claimed it while it was being scheduled. The message is Broadcast is already followed by its status. |
| 422 | no_postal_address | The workspace has no postal address. Add one in Settings. |
| 422 | workspace_suspended | Sending is suspended for the workspace. |
| 422 | invalid_request | scheduled_at is not a UTC ISO 8601 timestamp (Bad scheduled_at). |
| 422 | invalid_request | scheduled_at was given for a send-time A/B test. |
| 429 | daily_limit | The campaign's recipients would take the key past its daily_send_limit today: sending or scheduling a draft, or, when the key has not already paid for the campaign, starting a scheduled campaign now or moving it earlier. The message gives the recipient count and what the key has already sent. Nothing changes. |