Documentation

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.

FieldTypeDescription
idstringThe campaign's id (a UUID).
audience_idstringThe audience it mails.
identity_idstringThe marketing sending domain it goes out on.
namestringYour name for the campaign. Not shown to recipients.
subjectstringThe subject line. An A/B variant can override it.
htmlstringThe 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_namestring | nullDisplay 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.
segmentobject | nullThe 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_idstring | nullThe saved segment it targets, or null. A saved segment wins over segment when both are set.
topic_keystring | nullThe topic it is sent under, or null.
statusstringWhere the campaign is in its life. See Statuses.
scheduled_atstring (ISO 8601) | nullWhen it is due to start. null until the campaign is scheduled, and again after it is returned to draft.
started_atstring (ISO 8601) | nullWhen a run last claimed the campaign. null until the first run. Rewritten each time a run starts, including every resume.
completed_atstring (ISO 8601) | nullWhen it finished as sent or failed.
pause_reasonstring | nullWhy 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_afterstring (ISO 8601) | nullSet 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_reasonstring | nullWhy 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_recipientsintegerSize of the frozen audience, after suppressions and topic opt-outs. 0 until the first run freezes it.
sent_countintegerMessages accepted for delivery so far.
failed_countintegerMessages 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_atstring (ISO 8601)When it was created.
variantsarray of objectThe 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_testobject | nullA/B tests only, null otherwise. See The ab_test object.
progressobject | nullCounts 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.
warningsarray of stringSettings 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

StatusMeaning
draftCreated and editable. Nothing has been sent.
scheduledWaiting for scheduled_at. A send-time A/B test also returns here between its variants.
sendingA run has claimed it and is mailing the frozen audience.
pausedStopped with addresses still pending (possibly before any were sent). See Pauses for the reasons and what continues each.
testingAn 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.
sentEvery address in the frozen audience has been dealt with (sent, failed or skipped).
failedRefused 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

FieldTypeDescription
metric"open" | "click"What decides the winner.
sample_sharenumberShare of the audience in the test sample, 0.05 to 1. Always 1 for a send-time test.
decide_after_minutesintegerHow long after the sample is fully sent the winner is decided.
sample_completed_atstring (ISO 8601) | nullWhen the run that finished sending the sample ended. Set once.
decide_atstring (ISO 8601) | nullWhen the worker will decide on its own. Counted from sample_completed_at, not from the start of the send.
winnerstring | nullThe winning variant's key.
decided_by"auto" | "manual" | nullWhether the worker or a call to decide the winner picked it.
decided_atstring (ISO 8601) | nullWhen the winner was recorded.
resultsarray of objectPer 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_after gives 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 202 and 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 paused and 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 still queued, 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_approval cannot make any of those calls: 403 forbidden. A campaign cannot be held for approval the way a single email can.
  • A key with allowed_recipients cannot 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_limit is charged once for each campaign it puts on its way, whoever created or scheduled it. Sending or scheduling a draft counts the preview's recipient_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 a scheduled campaign 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 with 429 daily_limit and nothing changes. Returning a campaign to draft clears 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

ParameterTypeDefaultDescription
limitinteger50Page size, at most 100. A larger value is lowered to 100; zero, a negative or a non-number gives 50.
cursorstringnoneThe next_cursor from the previous page.

Example

curl https://api.sendraven.ai/v1/broadcasts \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

Response

200 OK

{
  "object": "list",
  "data": [
    {
      "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
      "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
      "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
      "name": "September update",
      "subject": "What's new this month",
      "from_name": "Ana at Example",
      "segment": {
        "opened_within_days": null,
        "clicked_within_days": null,
        "exclude_unengaged_days": 180,
        "engaged_within_days": null,
        "tags": [],
        "exclude_tags": []
      },
      "segment_id": null,
      "topic_key": "newsletter",
      "status": "sent",
      "scheduled_at": null,
      "started_at": "2026-09-15T12:05:00.000Z",
      "completed_at": "2026-09-15T12:11:42.000Z",
      "pause_reason": null,
      "resume_after": null,
      "failure_reason": null,
      "total_recipients": 4210,
      "sent_count": 4198,
      "failed_count": 3,
      "created_at": "2026-09-15T12:00:00.000Z",
      "variants": [],
      "ab_test": null,
      "progress": null,
      "warnings": []
    }
  ],
  "has_more": false,
  "next_cursor": null
}

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

FieldTypeRequiredDefaultDescription
audience_idstringYesThe audience's id from GET /v1/audiences.
identity_idstringYesA marketing sending domain's id from GET /v1/domains.
namestringYes1 to 200 characters.
subjectstringYes1 to 998 characters.
htmlstringYesThe body. At least 1 character.
from_namestringNononeDisplay name on the From header. Surrounding whitespace is trimmed, then it must be 1 to 100 characters with no <, >, " or line break.
segment_idstringNononeA saved segment's id from GET /v1/segments, on the same audience. Surrounding whitespace is ignored.
segmentobjectNononeInline 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_keystringNononeThe key of an existing topic to send under. Surrounding whitespace is ignored.
variantsarray of objectNonone2 to 10 variants. Makes the campaign an A/B test.
variants[].keystringYesYour name for the variant, unique within the campaign: lowercase letters, digits, - and _, starting with a letter or digit, up to 32 characters.
variants[].subjectstringNocampaign's subject1 to 998 characters, not only whitespace.
variants[].from_namestringNocampaign's from_nameSame rules as from_name.
variants[].send_atstring (ISO 8601)NononeSend-time test only. UTC with a Z suffix, for example 2026-09-16T09:00:00.000Z. Set on every variant or on none.
ab_testobjectNosee belowTest settings. Only accepted with variants.
ab_test.metric"open" | "click"No"open"What decides the winner.
ab_test.sample_sharenumberNo0.2Share of the audience in the sample, 0.05 to 1. Ignored and stored as 1 for a send-time test.
ab_test.decide_after_minutesintegerNo24015 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

curl -X POST https://api.sendraven.ai/v1/broadcasts \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
    "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
    "name": "September update",
    "subject": "What'\''s new this month",
    "html": "<p>Here is what changed in September.</p>",
    "from_name": "Ana at Example",
    "topic_key": "newsletter",
    "segment": { "exclude_unengaged_days": 180 }
  }'

An A/B test on the subject line, with the control first:

curl -X POST https://api.sendraven.ai/v1/broadcasts \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
    "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
    "name": "September update",
    "subject": "What'\''s new this month",
    "html": "<p>Here is what changed in September.</p>",
    "topic_key": "newsletter",
    "variants": [
      { "key": "control" },
      { "key": "question", "subject": "Did you see what changed this month?" }
    ],
    "ab_test": { "metric": "open", "sample_share": 0.25, "decide_after_minutes": 180 }
  }'

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.

{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
  "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
  "name": "September update",
  "subject": "What's new this month",
  "html": "<p>Here is what changed in September.</p>",
  "from_name": "Ana at Example",
  "segment": {
    "opened_within_days": null,
    "clicked_within_days": null,
    "exclude_unengaged_days": 180,
    "engaged_within_days": null,
    "tags": [],
    "exclude_tags": []
  },
  "segment_id": null,
  "topic_key": "newsletter",
  "status": "draft",
  "scheduled_at": null,
  "started_at": null,
  "completed_at": null,
  "pause_reason": null,
  "resume_after": null,
  "failure_reason": null,
  "total_recipients": 0,
  "sent_count": 0,
  "failed_count": 0,
  "created_at": "2026-09-15T12:00:00.000Z",
  "variants": [],
  "ab_test": null,
  "progress": null,
  "warnings": []
}

For the A/B example, variants and ab_test are filled in instead of [] and null:

{
  "variants": [
    { "key": "control", "subject": null, "from_name": null, "send_at": null },
    { "key": "question", "subject": "Did you see what changed this month?", "from_name": null, "send_at": null }
  ],
  "ab_test": {
    "metric": "open",
    "sample_share": 0.25,
    "decide_after_minutes": 180,
    "sample_completed_at": null,
    "decide_at": null,
    "winner": null,
    "decided_by": null,
    "decided_at": null,
    "results": []
  }
}

Errors

StatusTypeWhen
422invalid_requestA 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.
422invalid_requestaudience_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.
422unknown_topictopic_key matches no topic in your workspace. The message starts topic_key:.
422invalid_requestidentity_id is a transactional sending domain.
422invalid_requestsegment_id filters a different audience than audience_id.
422invalid_requestThe 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

ParameterDescription
idThe campaign's id.

The progress object

FieldTypeDescription
pendingintegerAddresses not yet dealt with, including any a run has claimed and not finished.
sentintegerAddresses whose message was accepted.
failedintegerAddresses whose message the provider refused. Throttled addresses, and addresses held back by a suspension or a missing postal address, stay in pending.
skippedintegerAddresses suppressed or opted out of the topic between the freeze and their turn, or on a domain that cannot receive mail.
by_variantarray of object | nullA/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

curl https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

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.

{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
  "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
  "name": "September update",
  "subject": "What's new this month",
  "html": "<p>Here is what changed in September.</p>",
  "from_name": null,
  "segment": null,
  "segment_id": null,
  "topic_key": "newsletter",
  "status": "testing",
  "scheduled_at": null,
  "started_at": "2026-09-15T12:05:00.000Z",
  "completed_at": null,
  "pause_reason": null,
  "resume_after": null,
  "failure_reason": null,
  "total_recipients": 10000,
  "sent_count": 2000,
  "failed_count": 0,
  "created_at": "2026-09-15T12:00:00.000Z",
  "variants": [
    { "key": "control", "subject": null, "from_name": null, "send_at": null },
    { "key": "question", "subject": "Did you see what changed this month?", "from_name": null, "send_at": null }
  ],
  "ab_test": {
    "metric": "open",
    "sample_share": 0.2,
    "decide_after_minutes": 240,
    "sample_completed_at": "2026-09-15T12:09:30.000Z",
    "decide_at": "2026-09-15T16:09:30.000Z",
    "winner": null,
    "decided_by": null,
    "decided_at": null,
    "results": [
      { "key": "control", "sent": 1000, "opened": 212, "clicked": 31, "open_rate": 0.212, "click_rate": 0.031 },
      { "key": "question", "sent": 1000, "opened": 264, "clicked": 29, "open_rate": 0.264, "click_rate": 0.029 }
    ]
  },
  "progress": {
    "pending": 8000,
    "sent": 2000,
    "failed": 0,
    "skipped": 0,
    "by_variant": [
      { "key": "control", "pending": 0, "sent": 1000, "failed": 0, "skipped": 0 },
      { "key": "question", "pending": 0, "sent": 1000, "failed": 0, "skipped": 0 },
      { "key": null, "pending": 8000, "sent": 0, "failed": 0, "skipped": 0 }
    ]
  },
  "warnings": []
}

Errors

StatusTypeWhen
404not_foundNo 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

ParameterDescription
idThe campaign's id.

Body

FieldTypeRequiredDefaultDescription
namestringNounchanged1 to 200 characters.
subjectstringNounchanged1 to 998 characters.
htmlstringNounchangedAt least 1 character.
from_namestringNounchangedSame rules as on create.
variantsarray of objectNounchanged2 to 10 variants, same shape and rules as on create. Replaces the whole list.
ab_testobjectNounchangedmetric, sample_share, decide_after_minutes, as on create. Merged over the stored settings.
scheduled_atstring (ISO 8601) | nullNounchangedScheduled 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

curl -X PATCH https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "subject": "Three things that changed this month" }'

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.

{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
  "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
  "name": "September update",
  "subject": "Three things that changed this month",
  "html": "<p>Here is what changed in September.</p>",
  "from_name": "Ana at Example",
  "segment": {
    "opened_within_days": null,
    "clicked_within_days": null,
    "exclude_unengaged_days": 180,
    "engaged_within_days": null,
    "tags": [],
    "exclude_tags": []
  },
  "segment_id": null,
  "topic_key": "newsletter",
  "status": "draft",
  "scheduled_at": null,
  "started_at": null,
  "completed_at": null,
  "pause_reason": null,
  "resume_after": null,
  "failure_reason": null,
  "total_recipients": 0,
  "sent_count": 0,
  "failed_count": 0,
  "created_at": "2026-09-15T12:00:00.000Z",
  "variants": [],
  "ab_test": null,
  "progress": null,
  "warnings": []
}

Errors

StatusTypeWhen
404not_foundNo campaign with this id in your workspace, including one deleted while the change was being applied.
403forbiddenA 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.
409invalid_stateThe campaign is not a draft, and is not a scheduled campaign given only scheduled_at. The message names its status.
409invalid_statescheduled_at: null on a campaign that has already run, or that started or was deleted while the change was being made.
409invalid_stateA new scheduled_at on a campaign that a run claimed while the change was being made. The message names its status.
422invalid_requestA field fails the schema. The message is Validation failed and details lists each problem.
422invalid_requestscheduled_at on a draft, or a new scheduled_at on a send-time A/B test.
422invalid_requestThe 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.
429daily_limitA 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

ParameterDescription
idThe campaign's id.

Example

curl -X DELETE https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

Response

200 OK

FieldTypeDescription
idstringThe deleted campaign's id.
deletedbooleanAlways true.
recipients_discardedintegerRows of the frozen audience removed, in any state. 0 for a campaign that never ran.
{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "deleted": true,
  "recipients_discarded": 0
}

Errors

StatusTypeWhen
404not_foundNo campaign with this id in your workspace.
409invalid_stateThe 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

ParameterDescription
idThe campaign's id.

Query parameters

ParameterTypeDefaultDescription
sampleinteger20How 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

curl "https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b/preview?sample=3" \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

Response

200 OK

FieldTypeDescription
idstringThe campaign's id.
recipient_countintegerAddresses that would be mailed: resolved_count less suppressed_count.
resolved_countintegerSubscribed contacts matching the audience and segment, with topic opt-outs already removed.
suppressed_countintegerOf those, addresses suppressed for marketing mail (unsubscribes, hard bounces, complaints).
samplearray of stringThe first few addresses from recipient_count.
can_sendbooleanfalse when the reputation gate or the A/B sample floor would stop the send.
blocked_reasonstring | nullWhen the reputation gate blocks, which rate and what to do; null otherwise.
ab_testobject | nullA/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.

{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "recipient_count": 4210,
  "resolved_count": 4318,
  "suppressed_count": 108,
  "sample": ["ana@example.com", "ben@example.com", "carla@example.com"],
  "can_send": true,
  "blocked_reason": null,
  "ab_test": null
}

Errors

StatusTypeWhen
404not_foundNo 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

ParameterDescription
idThe campaign's id.

Query parameters

ParameterTypeDefaultDescription
limitinteger200Rows 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.
cursorstringnoneThe next_cursor from the previous page.
afterstringnoneThe same as cursor, used only when cursor is not given.

Example

curl "https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b/recipients?limit=2" \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

Response

200 OK

FieldTypeDescription
message_idstringThe message's id, for GET /v1/emails/{id}.
tostringThe recipient's address.
statusstringThe message's status: "queued", "sent", "delivered", "bounced", "complained", "failed" and the other email statuses. See emails.
sent_atstring (ISO 8601) | nullWhen the provider accepted it. null if it never was.
variantstring | nullThe A/B variant the recipient got. null on a campaign that is not a test.
{
  "object": "list",
  "data": [
    {
      "message_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
      "to": "ana@example.com",
      "status": "delivered",
      "sent_at": "2026-09-15T12:05:03.000Z",
      "variant": null
    },
    {
      "message_id": "0b2c3d4e-5f6a-4b7c-9d8e-0f1a2b3c4d5e",
      "to": "ben@example.com",
      "status": "bounced",
      "sent_at": "2026-09-15T12:05:03.000Z",
      "variant": null
    }
  ],
  "has_more": true,
  "next_cursor": "0b2c3d4e-5f6a-4b7c-9d8e-0f1a2b3c4d5e"
}

Errors

StatusTypeWhen
404not_foundNo campaign with this id in your workspace.

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

ParameterDescription
idThe campaign's id.

Example

curl https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b/links/clicked \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY"

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.

FieldTypeDescription
data[].urlstringThe destination URL.
data[].clicksintegerEvery click, including repeats by the same person.
data[].unique_recipientsintegerDistinct recipients who clicked it.
{
  "object": "list",
  "data": [
    { "url": "https://example.com/changelog", "clicks": 412, "unique_recipients": 377 },
    { "url": "https://example.com/pricing", "clicks": 96, "unique_recipients": 88 }
  ],
  "has_more": false,
  "next_cursor": null
}

Errors

StatusTypeWhen
404not_foundNo 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

ParameterDescription
idThe campaign's id.

Body

FieldTypeRequiredDefaultDescription
variantstringNodecided by the metricThe key of the variant to send the remainder to.

Example

curl -X POST https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b/winner \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "variant": "question" }'

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.

{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
  "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
  "name": "September update",
  "subject": "What's new this month",
  "html": "<p>Here is what changed in September.</p>",
  "from_name": null,
  "segment": null,
  "segment_id": null,
  "topic_key": "newsletter",
  "status": "sending",
  "scheduled_at": null,
  "started_at": "2026-09-15T12:05:00.000Z",
  "completed_at": null,
  "pause_reason": null,
  "resume_after": null,
  "failure_reason": null,
  "total_recipients": 10000,
  "sent_count": 2000,
  "failed_count": 0,
  "created_at": "2026-09-15T12:00:00.000Z",
  "variants": [
    { "key": "control", "subject": null, "from_name": null, "send_at": null },
    { "key": "question", "subject": "Did you see what changed this month?", "from_name": null, "send_at": null }
  ],
  "ab_test": {
    "metric": "open",
    "sample_share": 0.2,
    "decide_after_minutes": 240,
    "sample_completed_at": "2026-09-15T12:09:30.000Z",
    "decide_at": "2026-09-15T16:09:30.000Z",
    "winner": "question",
    "decided_by": "manual",
    "decided_at": "2026-09-15T13:00:00.000Z",
    "results": [
      { "key": "control", "sent": 1000, "opened": 212, "clicked": 31, "open_rate": 0.212, "click_rate": 0.031 },
      { "key": "question", "sent": 1000, "opened": 264, "clicked": 29, "open_rate": 0.264, "click_rate": 0.029 }
    ]
  },
  "progress": {
    "pending": 8000,
    "sent": 2000,
    "failed": 0,
    "skipped": 0,
    "by_variant": [
      { "key": "control", "pending": 0, "sent": 1000, "failed": 0, "skipped": 0 },
      { "key": "question", "pending": 0, "sent": 1000, "failed": 0, "skipped": 0 },
      { "key": null, "pending": 8000, "sent": 0, "failed": 0, "skipped": 0 }
    ]
  },
  "warnings": []
}

Errors

StatusTypeWhen
404not_foundNo campaign with this id in your workspace.
403forbiddenThe key has requires_approval or allowed_recipients. See Keys with guardrails.
409invalid_stateThe campaign is not an A/B test.
409invalid_stateThe campaign is not in testing, or left it without a winner while the call was being made. The message names its status.
422invalid_requestvariant is not a non-empty string (Bad variant), or names a key the campaign does not have.
429daily_limitThe 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

ParameterDescription
idThe campaign's id.

Example

curl -X POST https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b/resume \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"

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.

{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
  "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
  "name": "September update",
  "subject": "What's new this month",
  "html": "<p>Here is what changed in September.</p>",
  "from_name": null,
  "segment": null,
  "segment_id": null,
  "topic_key": "newsletter",
  "status": "sending",
  "scheduled_at": null,
  "started_at": "2026-09-15T12:05:00.000Z",
  "completed_at": null,
  "pause_reason": null,
  "resume_after": null,
  "failure_reason": null,
  "total_recipients": 20000,
  "sent_count": 6884,
  "failed_count": 0,
  "created_at": "2026-09-15T12:00:00.000Z",
  "variants": [],
  "ab_test": null,
  "progress": {
    "pending": 13116,
    "sent": 6884,
    "failed": 0,
    "skipped": 0,
    "by_variant": null
  },
  "warnings": []
}

Errors

StatusTypeWhen
404not_foundNo campaign with this id in your workspace.
403forbiddenThe key has requires_approval or allowed_recipients. See Keys with guardrails.
409invalid_stateThe campaign is not paused. The message names its status.
409invalid_stateThe campaign is paused on warm-up and its allowance has not renewed. The message gives the time it does.
422no_postal_addressThe workspace has no postal address. Add one in Settings.
422workspace_suspendedSending is suspended for the workspace.
429daily_limitThe 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_reason gives 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

ParameterDescription
idThe campaign's id.

Body

FieldTypeRequiredDefaultDescription
scheduled_atstring (ISO 8601)Nosend nowWhen 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

curl -X POST https://api.sendraven.ai/v1/broadcasts/6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b/send \
  -H "Authorization: Bearer $SENDRAVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "scheduled_at": "2026-09-16T09:00:00.000Z" }'

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:

{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
  "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
  "name": "September update",
  "subject": "What's new this month",
  "html": "<p>Here is what changed in September.</p>",
  "from_name": null,
  "segment": null,
  "segment_id": null,
  "topic_key": "newsletter",
  "status": "scheduled",
  "scheduled_at": "2026-09-16T09:00:00.000Z",
  "started_at": null,
  "completed_at": null,
  "pause_reason": null,
  "resume_after": null,
  "failure_reason": null,
  "total_recipients": 0,
  "sent_count": 0,
  "failed_count": 0,
  "created_at": "2026-09-15T12:00:00.000Z",
  "variants": [],
  "ab_test": null,
  "progress": null,
  "warnings": []
}

Started now:

{
  "id": "6f1c2a7e-3b4d-4e5f-9a8b-1c2d3e4f5a6b",
  "audience_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
  "identity_id": "8d7c6b5a-4e3f-4d2c-9b1a-0f9e8d7c6b5a",
  "name": "September update",
  "subject": "What's new this month",
  "html": "<p>Here is what changed in September.</p>",
  "from_name": null,
  "segment": null,
  "segment_id": null,
  "topic_key": "newsletter",
  "status": "sending",
  "scheduled_at": null,
  "started_at": "2026-09-15T12:05:00.000Z",
  "completed_at": null,
  "pause_reason": null,
  "resume_after": null,
  "failure_reason": null,
  "total_recipients": 0,
  "sent_count": 0,
  "failed_count": 0,
  "created_at": "2026-09-15T12:00:00.000Z",
  "variants": [],
  "ab_test": null,
  "progress": {
    "pending": 0,
    "sent": 0,
    "failed": 0,
    "skipped": 0,
    "by_variant": null
  },
  "warnings": []
}

Errors

StatusTypeWhen
404not_foundNo campaign with this id in your workspace.
403forbiddenThe 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.
409invalid_stateThe 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.
422no_postal_addressThe workspace has no postal address. Add one in Settings.
422workspace_suspendedSending is suspended for the workspace.
422invalid_requestscheduled_at is not a UTC ISO 8601 timestamp (Bad scheduled_at).
422invalid_requestscheduled_at was given for a send-time A/B test.
429daily_limitThe 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.