MillionSend Docs

Broadcasts

Compose, schedule, and send one email to many contacts.

A broadcast is one email sent to many contacts: all of them, a segment, or a topic. Compose in the dashboard's block editor or as HTML in code mode, with per-contact merge fields, or create broadcasts via the API.

Lifecycle

draft → scheduled → sending → sent
              ↘ canceled
  • Broadcasts are created as drafts. Only drafts can be edited or deleted.
  • POST /broadcasts/{id}/send schedules the send — immediately, or at a scheduled_at timestamp.
  • A queued broadcast — scheduled, or already going out — can be canceled with POST /broadcasts/{id}/cancel. Emails already sent are not recalled; the response's canceled_remaining says how many were stopped, and sent_count on a read says how many had gone out.
  • On the wire, scheduled and sending both read as queued (matching the Resend SDK's status union); canceled is a MillionSend extension. A broadcast reads queued with sent_at null until its last email has gone out, however long that takes.

Block editor and HTML

The composer edits the body in one of two modes. The block editor builds the email from blocks. Code mode edits the HTML source beside a live preview. Both modes use the same per-contact merge fields.

A broadcast created with html (through the API, MCP or a migration) or started from an HTML template is html-authored. The dashboard opens it on its preview and edits it in code mode. The HTML stays byte for byte as written. The block editor never touches it on its own, because parsing a table-and-inline-CSS layout into blocks flattens it.

It becomes blocks only when you choose Convert to blocks, and the stored HTML changes only at the next save. Templates follow the same rule.

The other way works too. Edit as HTML moves a broadcast built with blocks to code mode. The switch always happens in the broadcast itself, since broadcasts can't be duplicated. The HTML that the blocks render replaces the blocks. That HTML leaves out blocks with a Show if condition.

Until the next save, Back to blocks puts the blocks back exactly as they were. After a save, converting back to blocks flattens the layout. On an empty body, the switch happens at once, so a new broadcast can start as HTML. A body that you save empty opens in the block editor again.

In code mode, a plain text part generated from the HTML keeps following the HTML as you edit. Plain text supplied through the API is sent as stored.

What the fan-out does

Every recipient email goes through the same pipeline as a transactional send, plus broadcast-specific handling:

  • Audience resolution — globally unsubscribed contacts are always excluded; segment filters and topic subscriptions are evaluated at send time.
  • Suppression checks — addresses on the suppression list are skipped.
  • Merge fields — per-contact values (name, custom properties) are substituted into the template.
  • Unsubscribe links — RFC 8058 one-click List-Unsubscribe headers and a hosted unsubscribe link are added to every message. To place the link in the body yourself, write {{{UNSUBSCRIBE_URL}}} — it is replaced per-recipient with their hosted unsubscribe URL. {{{RESEND_UNSUBSCRIBE_URL}}} is a supported alias, so templates written for Resend keep working unchanged (and roll back unchanged).
  • List headers: every message carries List-Id, Precedence: bulk and Auto-Submitted: auto-generated, so mailbox providers file it as list mail. List-Id names the list rather than the send: <{topic_id}.example.com> for a broadcast to a topic and <broadcasts.example.com> for the rest, where example.com is your sending domain. Every broadcast to the same list builds the same list's reputation.

Broadcast emails enter the send queue below transactional ones: a transactional email accepted while a broadcast is going out is sent ahead of the remaining recipients. Sends run on several lanes at once, paced by the instance's SES send rate.

On a self-hosted instance, unsubscribe links are built from APP_BASE_URL, so broadcast sending is rejected until it is configured — see Self-hosting. On Cloud this is automatic.

Pacing

The platform sends a bounded number of emails a day. A broadcast that fits inside what is available goes out at once, at the sending rate. A larger one goes out in waves: the first wave now, the rest as capacity frees over the following days. Transactional email is never held behind a broadcast's waves.

The send tells you up front:

{
  "id": "8c1f0b8e-…",
  "finishes_at": "2026-09-18T13:26:05Z",
  "estimated": true,
  "warning": {
    "code": "paced",
    "days": 3,
    "message": "170,000 recipients exceed the broadcast capacity available now; sending is paced and finishes about 2026-09-18T13:30:00Z. Transactional email is unaffected."
  }
}
  • finishes_at is the estimated instant the last email goes out; null when no estimate is available. It is an estimate: other sends move it.
  • warning is present only when the send takes more than one wave: paced when the audience exceeds the capacity available now, queued_behind when other sends are ahead (the message says when this one starts). A domain warming up answers warming_up instead (see Warm-up).
  • From a domain under the new domain limit, the estimate follows the limit's schedule, and warning is new_domain_limit when the limit spreads the send over days. The limit alone never makes a send too large: when the estimate can't tell when it ends, finishes_at is null.
  • On the Free plan, mail to providers that report complaints goes out to 10 recipients at once, then 10 an hour, across all the Free teams you created, each email in turn: transactional email sent after a broadcast waits behind it. Mail only to Gmail, Apple Mail, t-online or the SES mailbox simulator never waits. finishes_at doesn't count this wait.
  • While a broadcast is going out, GET /broadcasts/{id} and the list carry a live finishes_at and sent_count.
  • An audience that would need more than 24 days of capacity — the platform's, or your plan's — is refused with 422 broadcast_too_large instead of accepted and left waiting. Split it into smaller segments or contact support.

Warm-up

When the sending domain's warm-up is on, a broadcast goes out over days: each day sends up to that day's volume, most active contacts first, and contacts inactive for 90 days or more are skipped unless the warm-up sends them last. The Send broadcast dialog shows the plan day by day before you send. For a domain with no warm-up, it offers one when a broadcast is far bigger than anything the domain has sent in a day.

The send response carries warning.code warming_up:

{
  "id": "8c1f0b8e-…",
  "finishes_at": "2026-10-24T19:45:00Z",
  "estimated": true,
  "warning": {
    "code": "warming_up",
    "days": 6,
    "message": "This domain is warming up, so this broadcast goes out over about 6 days, most active contacts first."
  }
}
  • finishes_at comes from the warm-up's plan, which counts on every day being healthy. A day that holds or cuts the volume moves it later.
  • While the warm-up is paused, days is 0 and finishes_at is null. The broadcast waits until the warm-up resumes.
  • As with pacing, a broadcast the warm-up would need more than 24 days to send is refused with 422 broadcast_too_large. Raise the daily target or send to a smaller segment.

The broadcast page shows the warm-up's pace day by day, how many inactive contacts were skipped and, when a day missed a signal, why the volume held or was cut. Mail waiting for the warm-up shows as Waiting.

By inbox provider

The broadcast page splits the broadcast's mail by inbox provider: Gmail, Microsoft, Yahoo, Apple and Other, each with how many emails were sent, delivered, opened, clicked, bounced, complained and delayed. It groups by the recipient's address, so a company domain whose mail Google or Microsoft hosts counts as Other. Opens count people only, never a machine's prefetch, and bounces count hard bounces only. Complaints are a share of the mail to providers that report them, the same base the guardrails use; Gmail and Apple never report one, so their cell shows a dash.

Guardrails

  • Only a verified domain of your team may appear in from. Your team's shared subdomain reads verified too, but sending a broadcast from it answers 403 inbox_send_policy.
  • If your trailing bounce or complaint rate has crossed the SES pause threshold, new broadcast sends are blocked with a 403 sending_paused error, stopping the damage before SES pauses sending entirely. A complaint is dated by the UTC day its email was sent, as SES dates its own rate, so the pause weighs complaints about the last two days' mail and the warning about the last seven days'. The complaint rate counts only mail to providers that report complaints (Gmail, Apple Mail and t-online never do), and complaints count toward a warning or this pause only once there are 100 such sends. On the Free plan they count from the first email, judged together across all the Free teams you created, deleted ones included, and a pause holds every one of them.
  • An operator can lift a bounce pause early after a review (on MillionSend Cloud, ask support; on the Free plan the pause lifts on its own within two days). Bounces after the lift count as usual, and a complaint pause is never lifted early.
  • If the platform's aggregate bounce rate in your sender's SES region nears the line where SES acts on the account, broadcast sends in that region are refused with 403 broadcasts_paused until the rate recovers. When it is the complaint rate, only teams whose own complaint rate over the last 30 days reaches SES's 0.1% review line are refused; the pause refuses no other team's broadcasts. Transactional email keeps flowing, and the pause clears on its own. On MillionSend Cloud, each Free team whose mail drew a complaint in the region is also held for review.

See the API reference for all broadcast endpoints.

Recent changes

  • An operator can now lift a bounce pause early after a review (on MillionSend Cloud, ask support; on the Free plan the pause lifts on its own within two days). Bounces after the lift count as usual, and a complaint pause is never lifted early.
  • Complaints are dated by the day their email was sent, as SES dates its own rate, not the day they arrive: in the guardrail, the account score and the complaint rate on the Metrics page. A late complaint about older mail no longer pauses a team that is sending cleanly today.
  • A pause for the platform's complaint rate in an SES region refuses broadcasts with 403 broadcasts_paused only to the teams whose own complaint rate over the last 30 days is at 0.1% or more; before, it refused every team in the region.
  • On the Free plan, mail to providers that report complaints goes out to 10 recipients at once, then 10 an hour, across all the Free teams you created. See Pacing.
  • On the Free plan, complaints count toward a warning or 403 sending_paused from the first email, judged together across all the Free teams you created. Bounces and complaints from the SES mailbox simulator never count.
  • Edit as HTML in the composer moves a broadcast built with blocks to code mode. Back to blocks undoes the switch until the next save. See Block editor and HTML.
  • A broadcast from a domain warming up goes out over days. The send response carries warning.code warming_up, and one the warm-up would need more than 24 days to send is refused with 422 broadcast_too_large.
  • List-Id names the topic, or the sending domain, instead of each broadcast, so every broadcast to the same list carries the same one.
  • The broadcast page splits delivery by inbox provider.
  • The complaint rate behind 403 sending_paused counts only mail to providers that report complaints, and only once there are 100 such sends.
  • A broadcast with emails still waiting — for capacity, or for the plan's cap to reset — reads queued with sent_at null until its last email has gone out. It used to read sent as soon as every email was written.
  • An audience whose plan cap would need more than 24 days is refused with 422 broadcast_too_large instead of accepted and left waiting.
  • POST /broadcasts/{id}/cancel works on a broadcast that is already going out; the response carries canceled_remaining.

On this page