SonarSend API Sending Broadcasts

Broadcasts

The external broadcast API is designed for content-first draft creation.

Typical flow:

  1. discover reference data
  2. create a draft
  3. optionally review and complete the draft in SonarSend
  4. schedule the draft when it is ready

Supported broadcast shapes#

The current public API supports:

  • single broadcast drafts (inline body_html / body_mjml / body_text)
  • single broadcast drafts authored via a template binding (template_id + template_binding) — see template-mode.md for the full flow including binding discovery, validation, and preview rendering
  • simple A/B drafts with content-only variants

It does not support:

  • holdout/winner split-test workflows
  • per-variant MTA overrides
  • per-variant sending-identity overrides

When a draft is created with template_id + template_binding, the server runs Phase 0 substitution at save time and persists the resolved body_html / body_mjml / body_text on broadcasts.content. If the post-binding MJML compiles to empty html, the request is rejected with 400 MJML_COMPILE_FAILED. Templates pass MJML compile validation at create/update time (MJML_INVALID), so this code typically signals a slot binding pulled in a block whose MJML breaks the parent’s structure.

Create a draft#

POST /api/broadcasts

Required scope:

  • broadcasts:write

Supported reference fields#

  • email_type_key
  • mta_config_key
  • include_segment_keys
  • exclude_segment_keys
  • sending_identity_emails
  • content.template_slug (template-mode — resolves to the template by slug)

Additional fields#

  • tags — string array for categorizing broadcasts
  • metadata — free-form key-value object for workflow context (e.g., post IDs, campaign sources). See metadata rules below.

Retrying safely#

Send an Idempotency-Key header and a replay returns the original 201 body with idempotency-replay: true instead of creating a second broadcast. The key must match ^[A-Za-z0-9_-]{8,128}$ and is remembered for 24 hours. A key that does not match the pattern is ignored silently — you get no protection and no error — so generate one that fits.

Single-draft example#

{
  "name": "April newsletter",
  "email_type_key": "prospect_newsletter",
  "mta_config_key": "primary-mail",
  "include_segment_keys": ["vip-list"],
  "exclude_segment_keys": ["recent-unsubscribers"],
  "sending_identity_emails": ["ops@example.com"],
  "tracking_enabled": true,
  "content": {
    "subject": "April updates",
    "body_html": "<html><body><h1>Hello</h1></body></html>",
    "body_text": "Hello"
  }
}

Simple A/B example#

{
  "name": "April newsletter subject test",
  "email_type_key": "prospect_newsletter",
  "mta_config_key": "primary-mail",
  "include_segment_keys": ["vip-list"],
  "sending_identity_emails": ["ops@example.com"],
  "content": {
    "subject": "Fallback subject",
    "body_html": "<html><body>Fallback</body></html>",
    "body_text": "Fallback"
  },
  "variants": [
    {
      "id": "a",
      "name": "Variant A",
      "weight": 50,
      "content_overrides": {
        "subject": "April updates",
        "body_html": "<html><body>A</body></html>",
        "body_text": "A"
      }
    },
    {
      "id": "b",
      "name": "Variant B",
      "weight": 50,
      "content_overrides": {
        "subject": "What’s new this month",
        "body_html": "<html><body>B</body></html>",
        "body_text": "B"
      }
    }
  ]
}

Read broadcasts#

List broadcasts#

GET /api/broadcasts

Required scope:

  • broadcasts:read

Query filters#

ParameterTypeDescription
statusstringComma-separated statuses: draft, scheduled, sending, paused, completed, failed, cancelled. Any other value rejects the whole request with INVALID_STATUS.
tagsstringComma-separated tag names. Returns broadcasts whose tags overlap with any given value.
metadatastringJSON object for containment filtering. Returns broadcasts whose metadata contains all specified key-value pairs.
sincestringISO 8601 timestamp. Returns broadcasts created on or after this date.
beforestringISO 8601 timestamp. Returns broadcasts created before this date.
fullstringtrue to return full content bodies. Default is lean (see below).
archivedstringtrue includes archived broadcasts alongside live ones; only returns archived ones alone. Omit to exclude them — false is not an accepted value.

The list is not paginated. It returns every match in one response, with next_cursor: null and has_more: false, so narrow it with the filters above rather than paging.

Example — find completed broadcasts containing a specific post ID from the last 6 months:

GET /api/broadcasts?status=completed&metadata={"post_ids":[4729]}&since=2025-10-29

Lean response (default)#

The list endpoint returns a lean shape by default. Content drops the heavy body fields (body_html, body_mjml, body_text) and template_snapshot, replacing them with boolean flags. Keeps template_slug and template_binding so callers know which template was used and what was bound.

{
  "id": "33333333-3333-4333-8333-333333333333",
  "name": "April newsletter",
  "status": "draft",
  "tags": ["newsletter", "subscriber"],
  "metadata": { "newsletter_type": "subscriber", "post_ids": [4729, 5102] },
  "email_type_key": "prospect_newsletter",
  "mta_config_key": "primary-mail",
  "include_segment_keys": ["vip-list"],
  "exclude_segment_keys": [],
  "sending_identity_emails": ["ops@example.com"],
  "content": {
    "subject": "April updates",
    "template_slug": "dog-ear-weekly",
    "template_binding": { "parameters": {}, "sections": {}, "slots": {} },
    "has_body_html": true,
    "has_body_mjml": true,
    "has_body_text": true,
    "has_template_snapshot": true
  }
}

Pass ?full=true for the complete payload including rendered bodies and template snapshot.

Full response fields#

For API-key callers, all dependency references are returned in their public form:

  • email_type_key (instead of email_type_id)
  • mta_config_key (instead of mta_config_id)
  • include_segment_keys (instead of include_segment_ids)
  • exclude_segment_keys (instead of exclude_segment_ids)
  • sending_identity_emails (instead of sending_identity_ids)
  • content.template_slug (instead of content.template_id)

Get one broadcast#

GET /api/broadcasts/:broadcast_id

Required scope:

  • broadcasts:read

Returns the full broadcast payload (not lean). Broadcast URLs use the broadcast’s canonical ID.

Update a draft#

PUT /api/broadcasts/:broadcast_id

Required scope:

  • broadcasts:write

Drafts only. Once a broadcast has been scheduled the row is frozen and this returns 409 NOT_DRAFT. Unschedule it first if you need to edit it.

You can continue using the same public reference fields on update:

  • email_type_key
  • mta_config_key
  • include_segment_keys
  • exclude_segment_keys
  • sending_identity_emails

Fields you omit are left alone; fields you send REPLACE what is stored. tags and metadata are overwritten wholesale, not merged — unlike the contact upsert, which unions tags.

Delete a draft#

DELETE /api/broadcasts/:broadcast_id

Required scope:

  • broadcasts:write

Only draft broadcasts can be deleted, and the delete is permanent — there is no undelete. Anything that has been scheduled or sent returns 409 NOT_DRAFT so its record survives for reporting; archive those instead.

Schedule a broadcast#

POST /api/broadcasts/:broadcast_id/schedule

Required scope:

  • broadcasts:schedule

The draft must already be complete enough to schedule successfully.

Typical schedule-time requirements:

  • at least one included audience segment
  • valid email type
  • valid MTA config
  • valid content
  • resolvable sending identities or delivery pool

GET /api/broadcasts/{id} reports the same guards up front in view_model.schedule_readiness, so you can find out what is missing without provoking an error.

Set scheduled_at before you schedule. This call does not supply a default, and the dispatcher only picks up broadcasts whose scheduled_at has passed — a broadcast scheduled with a null scheduled_at sits in scheduled indefinitely.

Test send#

POST /api/broadcasts/:broadcast_id/test-send

Required scope:

  • broadcasts:schedule

This sends real mail through the real send path, which is why it needs the same grant as scheduling rather than broadcasts:write. It publishes no events and creates no recipient rows, so it never shows up in the broadcast’s report.

A proof for a human to read. Takes 1–10 recipients. Every variant is sent to every address from every sending identity — five addresses across three variants and two senders is thirty emails. Subjects are prefixed [TEST: <variant> · <sender>] so the copies are distinguishable in one inbox.

Metadata#

Free-form key-value pairs for external workflow context. Stored as JSONB.

  • Keys must be lowercase snake_case (/^[a-z][a-z0-9_]{0,63}$/)
  • Values: strings (max 256 chars), numbers, booleans, or arrays of those (max 50 items)
  • Maximum 20 keys, 8KB serialized
  • No nested objects
{
  "metadata": {
    "newsletter_type": "subscriber",
    "post_ids": [4729, 5102, 5387],
    "send_date": "2026-04-29"
  }
}

Use the metadata query filter on the list endpoint to search by containment:

GET /api/broadcasts?metadata={"post_ids":[4729]}

Unsupported advanced split-test fields#

These are rejected in the external API:

  • split_test_config
  • variant sending_identity_id
  • variant mta_config_id

If sent, expect a 400 response such as:

{
  "error": {
    "code": "ADVANCED_SPLIT_TEST_UNSUPPORTED",
    "message": "Advanced split-test workflows are not supported in the external broadcast API"
  }
}

Rescheduling and archiving#

POST /api/broadcasts/{id}/reschedule     # edit the calendar of a RUNNING spread send
POST /api/broadcasts/{id}/unschedule     # return a scheduled broadcast to draft
POST /api/broadcasts/{id}/pause          # stop a running send
POST /api/broadcasts/{id}/resume         # restart a paused send
POST /api/broadcasts/{id}/cancel         # stop a send for good
POST /api/broadcasts/{id}/archive        # hide a finished broadcast
POST /api/broadcasts/{id}/unarchive      # restore an archived broadcast
POST /api/broadcasts/bulk-archive        # archive many at once

reschedule, unschedule, pause, resume and cancel need broadcasts:schedule — the same grant as scheduling, because they change when (or whether) mail goes out. archive, unarchive and bulk-archive need broadcasts:write; archiving is housekeeping, not a send decision.

Each of these is legal only from certain statuses, and calling one from the wrong status returns 409 INVALID_TRANSITION:

CallLegal from
scheduledraft
unschedulescheduled
pausesending
resumepaused
cancelscheduled, sending, paused
reschedulesending, and only with a spread plan
archivecompleted, failed, cancelled

reschedule is not “change the send time”. It takes only skipped_dates, and it applies only to a broadcast that is actively sending on a multi-day spread plan — any other status is 409 NOT_SENDING, and a send with no spread plan is 409 NO_SPREAD_PLAN. skipped_dates is the complete replacement list, not a delta, and only future days can be newly skipped (400 INVALID_SKIP otherwise). To move a send that has not started, unschedule it, set scheduled_at, and schedule it again.

unschedule does not stop a live send. It is legal from scheduled only; a broadcast that has started sending returns 409 INVALID_TRANSITION. cancel is the call for stopping a send in flight — it drops every spread batch that has not fired, though mail the provider has already accepted cannot be recalled.

bulk-archive takes { "ids": [...] }, up to 100. It reports partial success as a 200 with { "archived": [...], "skipped": [...] } and gives no reason for a skip, so read the arrays rather than the status code.