URL: /code/apps/mailboxes/broadcast · Id: `page:/code/apps/mailboxes/broadcast`

# Message.broadcast

> Email a whole Audience, such as your waitlist, in one call.

`POST https://api.sb/{your-domain}/messages/broadcast`

Email a whole [Audience](https://startupbuilder.dev/business/nouns/audience), such as your waitlist, in one call.

- **Action class:** `send`
- **Approval:** Sends on its own
- **Emits:** `Message.broadcasted`
- **Sites:** Gmail, Outlook
- **Price:** Free

```text title="Run it · Ask"
Email the launch-day note to my waitlist at 8 a.m. Pacific on November 4.
```

```ts title="Run it · SDK"
import { $ } from 'startup-builder'

const result = await $.try.Message.broadcast({ message: 'msg_Wl5tQ8nB2r', audience: 'audience_Wt3kP9sL4e', at: '2026-11-04T16:00:00Z' })
```

```json title="Run it · Response"
{
  "api": "https://api.sb/leaseline.app",
  "message": {
    "$id": "https://api.sb/leaseline.app/messages/msg_Wl5tQ8nB2r",
    "$type": "Message",
    "medium": "email",
    "direction": "outbound",
    "from": "dana@leaseline.app",
    "subject": "Leaseline is live on Product Hunt today",
    "purpose": "launch"
  },
  "messages": [
    {
      "$type": "Message",
      "to": "leo@birchline-homes.example",
      "subject": "Leaseline is live on Product Hunt today",
      "body": "Leo, you joined the waitlist in September. Leaseline is live today: rent and every lease renewal tracked in one place, each notice sent 60 days out."
    }
  ],
  "counts": {
    "audience": 640,
    "sending": 611,
    "skippedNoConsent": 21,
    "skippedSuppressed": 8
  },
  "action": {
    "operation": "Message.broadcast",
    "mode": "try",
    "status": "done"
  }
}
```

Sends one email to a whole Audience, such as your waitlist, now or at a set time. [The hub’s definition of `Message.broadcast`](https://startupbuilder.dev/business/nouns/message/broadcast).</HubDefinition>

## Inputs
| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `message` | `Message` | Required | The Message to send. |
| `audience` | `Audience` | Required | Who receives it. |
| `at` | `datetime` | `now` | When it goes out; omitted, now. Unset: as soon as its delay window closes. |

## Outputs
| Field | Type | Unit | What `null` means |
| --- | --- | --- | --- |
| `messages` | `Message[]` | None. | Never null: one per Contact it reaches. In try mode, the first rendered Message only. |
| `message` | `Message` | None. | Never null. |
| `counts.audience` | `integer` | people | Never null: everyone the Audience selects when the call runs. |
| `counts.sending` | `integer` | people | Never null: the Audience less both skips. |
| `counts.skippedNoConsent` | `integer` | people | Never null: Contacts with no Consent for the Message’s purpose. |
| `counts.skippedSuppressed` | `integer` | people | Never null: Contacts on a Suppression, from any startup of the Company. |

## Effects
**Action class**

`send`: an approval rule on `send` can make it wait for your OK

**Modes**

- `try`: Counts who it would reach after the Consent and Suppression skips and renders the first Message; nothing sends.
- `send`: Returns a pending Action while its delay window runs; then each Message goes out from the broadcast stream.
- `do`: Holds the call for the Mode’s hold time, then returns `202` with the pending Action.

**Idempotency**

Only with a key: the same `Idempotency-Key` and input return the first Action, and the same key with other input returns `409`. Each Contact gets the Message once per broadcast: a retry never sends a second copy to anyone it reached.

**Saved or shared**

Each sent Message is saved on its Contact, and every reply threads to it.

**Emits**

`Message.broadcasted` with `{ message, audience, counts }`

**Performed by**

[Marketing lead](https://startupbuilder.dev/agents/marketing/marketing-lead)

**Price**

Free on your own account: the platform charges nothing for the call.

**Standing approval**

It goes out on its own, inside the Limits of your standing approval: a [`Mandate`](https://startupbuilder.dev/business/nouns/mandate) granted with [`Mandate.authorize`](/code/integrations/calls/authorize). One approval rule makes it wait for your OK.

## On each Venue
| Site | Fields | Formats |
| --- | --- | --- |
| [Gmail](#gmail) | `from`, `to`, `subject`, `body`, `purpose` | None. |
| [Outlook](#outlook) | `to`, `subject`, `body`, `purpose` | None. |

### Gmail
Through [Gmail](/code/integrations/gmail).

| Our field | Gmail’s field |
| --- | --- |
| `Message.from` | `raw: From header` (Your address, or a send-as alias you set up in Gmail) |
| `Message.to` | `raw: To header` |
| `Message.subject` | `raw: Subject header` (A reply keeps the thread’s subject, so Gmail threads it) |
| `Message.body` | `raw: text/plain and text/html parts` |
| `Message.purpose` | `raw: List-Unsubscribe and List-Unsubscribe-Post headers` (A commercial purpose adds the one-click unsubscribe headers Gmail shows as a link) |

- **Sends from one inbox.** 2,000 messages in a rolling 24 hours on a Google Workspace account, to at most 10,000 recipients; the rest wait for the window with `quota-reached`.
- **Outside recipients.** 3,000 addresses outside your own domain a day, and 500 on one message.
- **Replies.** Gmail pushes each new message within seconds; the platform renews the mailbox watch daily, inside the 7 days Gmail allows.

### Outlook
Through [Outlook](/code/integrations/outlook).

| Our field | Outlook’s field |
| --- | --- |
| `Message.to` | `message.toRecipients` |
| `Message.subject` | `message.subject` |
| `Message.body` | `message.body` (contentType html or text) |
| `Message.purpose` | `message.internetMessageHeaders` (A commercial purpose adds the one-click unsubscribe headers) |

- **Recipients from one mailbox.** 10,000 in a rolling 24 hours on Exchange Online; past it the mailbox sends nothing until the oldest sends age out, so the call returns `quota-reached` with the time.
- **Send rate.** 30 messages a minute; the platform paces a broadcast under it, so Microsoft never throttles a send.

## What try does
| Account | What try does |
| --- | --- |
| [Gmail](/code/integrations/gmail#try) | Renders the exact email, headers included, and returns it with the time it would send; nothing reaches Gmail and no draft is saved. |
| [Outlook](/code/integrations/outlook#try) | Renders the exact email or invite and returns it with the time it would go; nothing reaches Outlook and no draft or event is saved. |

## Refuses when
| Check | Error | Fix | Then call |
| --- | --- | --- | --- |
| A broadcast past three times the trailing 7-day Message rate in an hour | [`spam-brake`](/code/refusals#check-spam-brake) | Send to a smaller List, or wait an hour for the rate to fall. | [`Message.broadcast`](/code/apps/mailboxes/broadcast) |
| A send from a Domain whose DNS records do not resolve | [`domain-authenticated`](/code/refusals#check-domain-authenticated) | Add the 3 DNS records shown; it passes once they resolve, often within an hour. | [`Domain.authenticate`](https://startupbuilder.dev/business/nouns/domain/authenticate) |

Every call through an account can also meet [the shared account refusals](/code/refusals#accounts): [`grant-missing`](/code/refusals#grant-missing), [`needs-reauth`](/code/refusals#needs-reauth), [`account-not-connected`](/code/refusals#account-not-connected), [`over-mandate`](/code/refusals#over-mandate), [`quota-reached`](/code/refusals#quota-reached), [`venue-rejected`](/code/refusals#venue-rejected).

```json
{
  "type": "https://startupbuilder.dev/errors/spam-brake",
  "title": "Sending too fast",
  "status": 422,
  "detail": "A broadcast past three times the trailing 7-day Message rate in an hour.",
  "fix": "Send to a smaller List, or wait an hour for the rate to fall.",
  "next": {
    "verb": "Message.broadcast",
    "input": {}
  },
  "instance": "https://api.sb/leaseline.app/actions/action_1"
}
```

## When it waits
| Fact | Value |
| --- | --- |
| `send` returns | A pending Action, `reason: 'awaitingTime'`, while its delay window runs; `Message.broadcasted` fires when the last Message is handed off. |
| Delivered by | `Message.broadcasted` |

## Example

**Ask**

```text
Email the launch-day note to my waitlist at 8 a.m. Pacific on November
4.
```

**API**

```bash
curl -X POST "https://api.sb/leaseline.app/messages/broadcast?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"message":"msg_Wl5tQ8nB2r","audience":"audience_Wt3kP9sL4e","at":"2026-11-04T16:00:00Z"}'
```

The response is the one in the Run it window above.

**CLI**

```bash
npx startup-builder do Message.broadcast --try --data '{"message":"msg_Wl5tQ8nB2r","audience":"audience_Wt3kP9sL4e","at":"2026-11-04T16:00:00Z"}'
```

**MCP**

```json
{
  "name": "do",
  "arguments": {
    "mode": "try",
    "code": "return await $.Message.broadcast({ message: 'msg_Wl5tQ8nB2r', audience: 'audience_Wt3kP9sL4e', at: '2026-11-04T16:00:00Z' })"
  }
}
```

**SDK**

```ts
import { $ } from 'startup-builder'

const result = await $.try.Message.broadcast({
  message: 'msg_Wl5tQ8nB2r',
  audience: 'audience_Wt3kP9sL4e',
  at: '2026-11-04T16:00:00Z',
})
```

To retry, send the same `Idempotency-Key`: the retry returns the first answer for $0. A new key reads again.

A `sk_test_` key answers these the same way every time, at $0 ([every test value](/code/data#test-values)).

| Test value | Always returns | What it is for |
| --- | --- | --- |
| `audience_test_waitlist` | `{"counts":{"audience":4,"sending":3,"skippedNoConsent":0,"skippedSuppressed":1}}` | Four test Contacts, one of them on a Suppression, so your agent sees the skip without sending to anyone. |
| `audience_test_spam_rate` | Refuses with [`spam-brake`](/code/refusals#check-spam-brake) | Sends as if the Domain’s complaint rate were over the brake, so the broadcast refuses and nothing goes out. |
| `maxCost: 0.0001` | Refuses with [`max-cost`](/code/refusals#max-cost) | Any priced call with a maxCost of 0.0001 is refused before it spends: the price is above the cap. |
| `rate-limited.test` | Refuses with [`rate-limited`](/code/refusals#rate-limited) | Any call naming this hostname answers 429 with Retry-After, so your retry logic runs. |
| `sources-busy.test` | Refuses with [`sources-busy`](/code/refusals#sources-busy) | Any call naming this hostname answers 503 as if every route were busy, and nothing is spent. |

## Used by
- **Integrations:** [Gmail](/code/integrations/gmail) · [Outlook](/code/integrations/outlook)

## Related
[`Message.schedule`](/code/apps/mailboxes/schedule)