Sending

Broadcasts

Send one email to an entire audience — and watch it land, open by open.

A broadcast sends a single email to many recipients — a newsletter, a product announcement, a campaign. Point it at a contact list or an inline recipient set, write your content once with {{ variables }}, and Sendara fans it out in the background, personalizing each copy and tracking delivery as it goes.

Every recipient becomes a normal message that flows through the same pipeline as POST /v1/send — so suppression, unsubscribe handling, spend caps, idempotency, and provider events all apply automatically. The endpoints live under /v1/broadcasts.

Broadcasts are email-only today. For one-off transactional sends (a receipt, an OTP, a password reset), use POST /v1/send instead — broadcasts are for one message to many people.

The broadcast lifecycle

A broadcast moves through a small set of states. You create it as a draft, then send it now or schedule it; the worker flips it to sending while it fans out, then to a terminal sent (or failed) when every recipient has been processed.

Broadcast statuses
StatusMeaning
draftCreated but not yet sending. Can be sent, scheduled, edited via re-create, or deleted.
scheduledA future scheduled_at is set. Sends automatically at that time. Can still be cancelled.
sendingFan-out is in progress. Recipients are being resolved and sent concurrently.
sentEvery recipient has been handed off. Terminal — check stats for delivery outcomes.
cancelledA draft or scheduled broadcast was cancelled before it started sending.
failedThe fan-out could not start (e.g. the audience failed to resolve) or every recipient failed.

Create a broadcast

POST /v1/broadcasts creates a broadcast. Give it a from_email on a verified domain, some content, and an audience. Set send_now: true to start fan-out immediately, or a future scheduled_at to send later — omit both to keep it as a draft you send explicitly.

Request body

from_emailstringRequired
The sending address. Must be on a verified domain, otherwise the fan-out fails per recipient with from_not_verified.
namestringOptional
A human label for the broadcast, shown in the dashboard and list responses. Defaults to "Untitled broadcast".
subjectstringOptional
Inline subject line. Required unless you pass a template_id. Supports {{ variables }}.
body_htmlstringOptional
Inline HTML body. Provide this and/or body_text, or use a template_id. Supports {{ variables }}.
body_textstringOptional
Inline plain-text body. Used as the text/plain part and as a fallback.
template_idstringOptional
Render a stored template per recipient instead of inline content. Mutually exclusive with subject / body_html.
audience_list_idstringOptional
A contact list (static or dynamic) to send to. Provide this or recipients.
recipientsarrayOptional
Inline recipients — an array of { email, data } objects. data supplies per-recipient template variables. An alternative to audience_list_id.
message_typestringOptional
marketing (default) or transactional. Marketing sends add unsubscribe headers and honor consent.
scheduled_atstringOptional
RFC 3339 timestamp. When in the future, the broadcast is created as scheduled and sent automatically at that time.
send_nowbooleanOptional
When true, fan-out starts immediately after creation (ignored if scheduled_at is in the future).
You must provide content — either a template_id, or a subject together with a body_html or body_text — and an audience — either an audience_list_id or a non-empty recipients array. Missing either returns invalid_request (400).

The response is the created broadcast. When you pass send_now: true it is returned with status: "sending" and fan-out has already begun; poll get for live counts.

{
  "id": "bc_a1b2c3",
  "name": "October newsletter",
  "status": "sending",
  "from_email": "news@mail.acme.com",
  "subject": "What shipped in October, {{ first_name }}",
  "message_type": "marketing",
  "audience_list_id": "list_9f21",
  "total_recipients": 0,
  "sent_count": 0,
  "failed_count": 0,
  "test_mode": false,
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}
total_recipients is 0 until the worker has resolved the audience. Once fan-out starts it is set to the deduped, consent-filtered recipient count — then sent_count and failed_count climb as each message is handed off.

Audiences vs. inline recipients

There are two ways to target a broadcast. Use whichever fits — you can mix neither, but not both is required.

A contact list

Pass an audience_list_id to send to a saved contact list. Both static and dynamic (rule-based) lists are supported — dynamic lists are resolved at send time. Each contact's attributes become template variables automatically, so first_name, last_name, email, and any custom attributes are available in your {{ … }} placeholders.

Inline recipients

Pass a recipients array for an ad-hoc send — no list needed. Each entry is { email, data }, where datasupplies that recipient's variables.

curl https://api.sendara.dev/v1/broadcasts \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Beta invites",
    "from_email": "team@mail.acme.com",
    "subject": "You are in, {{ first_name }}",
    "body_html": "<p>Hi {{ first_name }}, your access code is {{ code }}.</p>",
    "recipients": [
      { "email": "ada@acme.com",  "data": { "first_name": "Ada",  "code": "A1B2" } },
      { "email": "grace@acme.com", "data": { "first_name": "Grace", "code": "C3D4" } }
    ],
    "send_now": true
  }'
Recipients are deduplicated by email (case-insensitive) before sending, so the same address never receives two copies of one broadcast — even if it appears in both the list and the inline array.

Personalization

Inline subject, body_html, and body_text support lightweight {{ variable }}substitution. For a contact list, variables come from each contact's fields and attributes; for inline recipients, they come from the per-recipient data object.

Inline content uses simple string substitution. For strict rendering — required variables, defaults, validation, loops, and conditionals — use a stored template via template_id, which renders each recipient through the full template engine.

Scheduling

Set a future scheduled_at (RFC 3339) and the broadcast is created with status: "scheduled". A background sweeper claims due broadcasts and fans them out at the scheduled time — no cron on your side. Until then you can cancel it.

curl https://api.sendara.dev/v1/broadcasts \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Black Friday teaser",
    "from_email": "news@mail.acme.com",
    "subject": "Something is coming",
    "body_html": "<p>Mark your calendar.</p>",
    "audience_list_id": "list_9f21",
    "scheduled_at": "2026-11-27T14:00:00Z"
  }'
A scheduled_at in the past (or omitted) does not schedule anything — the broadcast stays a draft until you send it. Pair a future scheduled_at with send_now and the schedule wins.

Send a draft

If you created a broadcast without send_now — for example to review it first — kick off fan-out with POST /v1/broadcasts/{id}/send. It accepts a broadcast in draft or scheduled state, returns 202 Accepted with status: "sending", and begins sending immediately (overriding any future schedule).

curl -X POST https://api.sendara.dev/v1/broadcasts/bc_a1b2c3/send \
  -H "Authorization: Bearer sk_live_xxx"
Sending is only valid from draft or scheduled. Calling /send on a broadcast that is already sending, sent, cancelled, or failed returns invalid_state (409).

Cancel a broadcast

POST /v1/broadcasts/{id}/cancel stops a draft or scheduled broadcast before it starts, moving it to cancelled. Once a broadcast is sending it has already begun handing recipients to the send pipeline and can no longer be cancelled.

curl -X POST https://api.sendara.dev/v1/broadcasts/bc_a1b2c3/cancel \
  -H "Authorization: Bearer sk_live_xxx"
Cancelling anything past scheduled returns invalid_state (409); an unknown id returns not_found (404).

List broadcasts

GET /v1/broadcasts returns your broadcasts, newest first, under a broadcasts array. Page with limit (default 50, max 200) and offset.

limitintegerOptional
Page size. Default 50, maximum 200.
offsetintegerOptional
Number of broadcasts to skip, newest first. Default 0.
curl "https://api.sendara.dev/v1/broadcasts?limit=20" \
  -H "Authorization: Bearer sk_live_xxx"
{
  "broadcasts": [
    {
      "id": "bc_a1b2c3",
      "name": "October newsletter",
      "status": "sent",
      "from_email": "news@mail.acme.com",
      "message_type": "marketing",
      "total_recipients": 1840,
      "sent_count": 1840,
      "failed_count": 0,
      "test_mode": false,
      "created_at": "2026-06-14T10:00:00Z",
      "completed_at": "2026-06-14T10:03:11Z"
    }
  ]
}

Get a broadcast with stats

GET /v1/broadcasts/{id} returns the broadcast plus a live stats object aggregated from the per-recipient messages and their provider events. This is what you poll while a broadcast is sending and how you measure results after.

curl https://api.sendara.dev/v1/broadcasts/bc_a1b2c3 \
  -H "Authorization: Bearer sk_live_xxx"
{
  "broadcast": {
    "id": "bc_a1b2c3",
    "name": "October newsletter",
    "status": "sent",
    "from_email": "news@mail.acme.com",
    "message_type": "marketing",
    "audience_list_id": "list_9f21",
    "total_recipients": 1840,
    "sent_count": 1840,
    "failed_count": 0,
    "test_mode": false,
    "started_at": "2026-06-14T10:00:02Z",
    "completed_at": "2026-06-14T10:03:11Z",
    "created_at": "2026-06-14T10:00:00Z",
    "updated_at": "2026-06-14T10:03:11Z"
  },
  "stats": {
    "total": 1840,
    "queued": 0,
    "sent": 1840,
    "delivered": 1798,
    "opened": 642,
    "bounced": 31,
    "complained": 2,
    "failed": 0
  }
}
sent_count / failed_count on the broadcast track how many recipients were handed to the send pipeline. The stats object reflects what happened after delivered, opened, bounced, and complained are derived from provider events and keep updating after status reaches sent.

Delete a broadcast

DELETE /v1/broadcasts/{id} removes a broadcast and returns 204 No Content. You can delete a broadcast in a settled state — draft, cancelled, sent, or failed. A broadcast that is scheduled or sending cannot be deleted; cancel it first.

curl -X DELETE https://api.sendara.dev/v1/broadcasts/bc_a1b2c3 \
  -H "Authorization: Bearer sk_live_xxx"
Deleting a broadcast removes the campaign record. The individual messages it sent — and their event history — are retained under messages, so deletion never erases your delivery audit trail.

One-shot bulk send

When you don't need a draft step, POST /v1/send/bulk creates a broadcast and starts fan-out in a single call — the create + send shortcut. It takes the same body (minus send_now and scheduled_at, which don't apply) and returns 202 Accepted with the broadcast to poll.

curl https://api.sendara.dev/v1/send/bulk \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from_email": "news@mail.acme.com",
    "subject": "News from Acme",
    "body_html": "<h1>Hi {{ first_name }}</h1>",
    "audience_list_id": "list_9f21"
  }'
Reach for /v1/send/bulk for fire-and-forget campaigns from a script or backend job; reach for the /v1/broadcasts create-then-send flow when you want to review, schedule, or cancel before anything goes out.

Suppression, consent & idempotency

Because every recipient runs through the standard send pipeline, the same safety rails as POST /v1/sendapply automatically — you don't opt in:

  • Suppression — addresses on your suppression list (hard bounces, complaints, manual entries) are skipped.
  • Consent — for marketing broadcasts, contacts who have unsubscribed are excluded, and an Unsubscribe header is added to every message.
  • Idempotency — each recipient gets a deterministic idempotency key derived from the broadcast id, so re-running a broadcast (or a worker retry) never double-sends to the same person.
  • Spend caps — if a send would exceed your spend cap, that recipient fails with spend_cap_exceeded and is counted in failed_count; the rest still go out.

Testing broadcasts

Use a sandbox key (sk_test_) to dry-run a broadcast end to end without sending real email or being billed. Resolution, dedupe, personalization, and stats all run; including the sandbox addresses delivered@, bounced@, and complained@ in your recipients drives those outcomes (and fires webhooks) so you can verify your tracking. Sandbox broadcasts are flagged with test_mode: true.

To validate a campaign against real inboxes before a full send, build a small broadcast with an inline recipients array of your own verified test addresses — a safe rehearsal for subject lines, rendering, and links.

Errors

Broadcast endpoints use the standard error envelope. The codes you'll see most:

Broadcast error codes
CodeStatusWhen it fires
invalid_request400Missing from_email, no content (template or subject + body), or no audience (list or recipients).
invalid_state409Sending or cancelling a broadcast that isn't in a valid state for that action (e.g. cancelling one that's already sending).
not_found404No broadcast with that id exists for your account.
unauthorized401Missing or invalid API key.
forbidden403Key lacks the required scope — read to list/get, send to create/send/cancel/delete.
Per-recipient failures during fan-out (a suppressed address, an unverified from_email, a spend-cap hit) don't fail the broadcast — they're counted in failed_count. The broadcast only ends in failedwhen the audience can't be resolved at all, or every single recipient fails.

Scopes

Listing and reading broadcasts requires a key with the read scope. Creating, sending, cancelling, deleting, and one-shot bulk sends require the send scope. See Authentication for how scopes work.