Broadcasts
`broadcasts->preview`, `send`, `list`, `listAll`, `iterate`, `get`, `listRecipients`, `listAllRecipients`, `iterateRecipients`, `getRecipient`, `stats`, `analytics` and `cancel`.
Every method
use OpenEmail\Constants\BroadcastRecipientFilters;use OpenEmail\Constants\BroadcastStatuses; $draft = [ 'audienceIds' => ['aud_4c1b8e2a7d9f05c36b4e8a71'], 'from' => 'Acme <[email protected]>', 'subject' => '{{firstName|Hello}}, the September release is out', 'html' => '<p>Hi {{firstName|there}},</p><p>Here is what changed this month.</p>', 'text' => 'Hi {{firstName|there}}, here is what changed this month. Unsubscribe: {{unsubscribeUrl}}', 'tags' => ['campaign' => 'release-2026-09'],]; $reach = $client->broadcasts->preview($draft);echo $reach['recipients'], ' ', $reach['unsubscribed'], ' ', $reach['suppressed'], PHP_EOL; $broadcast = $client->broadcasts->send($draft); $latest = $client->broadcasts->get($broadcast['id']); while (in_array($latest['status'], [BroadcastStatuses::SCHEDULED, BroadcastStatuses::QUEUED, BroadcastStatuses::SENDING], true)) { sleep(5); $latest = $client->broadcasts->get($broadcast['id']);} foreach ($client->broadcasts->iterateRecipients($broadcast['id']) as $copy) { echo $copy['email'], ' ', $copy['status'], ' ', $copy['opens'], ' ', $copy['clicks'], PHP_EOL;} $bounced = $client->broadcasts->listRecipients($broadcast['id'], filter: BroadcastRecipientFilters::BOUNCED); foreach ($bounced as $row) { echo $row['emailId'], ' ', $row['email'], PHP_EOL;} $copy = $client->broadcasts->getRecipient($broadcast['id'], 'msg_01dad25067bc4dac966d515d');echo $copy['subject'], ' ', $copy['bouncedAt'] ?? 'not bounced', PHP_EOL; $stats = $client->broadcasts->stats($broadcast['id'], grain: 'day');echo $stats['totals']['opened'], ' ', $stats['totals']['clicked'], ' ', $stats['totals']['unsubscribed'], PHP_EOL; $lately = $client->broadcasts->stats($broadcast['id'], days: 1);echo $lately['window']['opened'] ?? 0, PHP_EOL; $later = $client->broadcasts->send([...$draft, 'scheduledAt' => 'P1D']);$client->broadcasts->cancel($later['id']); $history = $client->broadcasts->list(audienceId: $draft['audienceIds'][0]);echo $latest['status'], ' ', $latest['counts']['sent'], ' ', count($history), PHP_EOL; $month = $client->broadcasts->analytics(days: 30); foreach ($month['broadcasts'] as $row) { echo $row['subject'], ' ', $row['sent'], ' ', $row['opened'], PHP_EOL;}A broadcast sends one message to everybody in one or more audiences, as a separate copy for each person. Every copy has exactly one recipient and no cc or bcc, so nobody sees who else it went to, and every copy is an ordinary email with its own msg_ id, events, tracking and webhooks. listRecipients lists them with what happened to each. The copies are not filed in the Sent folder, because the broadcast is the record.
send returns straight away with the broadcast queued, or scheduled when the body carries scheduledAt, and the sending runs in the background. send needs emails:send and audiences:read, and preview needs audiences:read. list, listAll, iterate, get, listRecipients, listAllRecipients, iterateRecipients, getRecipient, stats and analytics need emails:read, and cancel needs emails:send.
Every send carries an Idempotency-Key, yours through idempotencyKey: or one the client makes, so a retry after a network failure answers with the broadcast the first attempt created, with replayed set to true, instead of sending twice. preview, get, cancel and every read are safe to repeat and are retried.
$broadcast = $client->broadcasts->send([ 'audienceIds' => ['aud_4c1b8e2a7d9f05c36b4e8a71'], 'from' => 'Acme <[email protected]>', 'subject' => 'Doors open on Friday', 'text' => 'Hi {{firstName|there}}, doors open at nine. Unsubscribe: {{unsubscribeUrl}}', 'scheduledAt' => new \DateTimeImmutable('+1 hour'),], idempotencyKey: 'doors-open-2026-10'); echo $broadcast['id'], ' ', $broadcast['status'], ' ', $broadcast['replayed'] ? 'replayed' : 'new', PHP_EOL;The fields of a broadcast are the keys of one array under the API’s camelCase names (audienceIds, scheduledAt). idempotencyKey: and apiKey: are named arguments of the call and are never sent as fields. Spreading a draft into a new array beside a field sends the same draft with that one change, so send([...$draft, 'scheduledAt' => 'P1D']) sends it a day later. scheduledAt takes a DateTimeInterface, an ISO 8601 string or a duration such as PT2H, and a DateTimeInterface goes out as a UTC instant. preview sends only audienceIds from what you give it, so it takes the same array as send. A response is an array keyed in camelCase, so $broadcast['status'] reads the status.
Merge fields
subject, html and text are filled in for each person from their contact. {{firstName}} is the first word of the contact name, {{lastName}} the rest of it, {{name}} the whole name, {{email}} the address the copy goes to and {{unsubscribeUrl}} the link that unsubscribes them.
Every field takes a fallback after a bar, used when the contact has no value for it, so {{firstName|there}} becomes "there" for a contact saved without a name. Values are escaped in html, and any other {{…}} is left exactly as written.
Pass template instead of html and text to send a stored template, as an array with id and optionally version, props and slots. The same five values reach it as props, but only the props the template declares, so a template that declares firstName gets it and one that does not is never refused for it. Anything in its props goes to every copy alike.
Unsubscribe
Every copy carries the one-click unsubscribe headers that let a mail client show its own unsubscribe button, which the large mailbox providers require of bulk mail. An html or text body that does not place {{unsubscribeUrl}} itself gets a one-line footer with the link. A template is sent exactly as it is, so put {{unsubscribeUrl}} in the template.
Unsubscribing marks the person unsubscribed in every audience that broadcast went to, and audiences->listContacts shows it in the unsubscribedAt of their row, as the Audiences page describes. They stay in the audience and in the address book, their other audiences are untouched, and mail sent to them one message at a time still goes. Taking them out of the audience and adding them again makes them subscribed afresh.
Who is skipped
A broadcast reaches every contact in at least one of audienceIds, once however many of them hold it. It skips a contact that has unsubscribed from every one of those audiences it is in, and an address on the suppression list after a bounce or a complaint, or because somebody added it there. A contact added to one of the audiences after send but before the sending reaches it is included.
preview returns the same numbers without sending: recipients, unsubscribed and suppressed. A send that would reach nobody throws a 422 no_recipients as a ValidationException.
The whole send is checked against the monthly sends of the plan before anything is written, so a broadcast the allowance cannot cover throws a 429 send_quota_exceeded as a RateLimitException and leaves nothing behind. Each copy counts as one send.
Status and progress
get reads counts live from the copies, so poll it while a broadcast sends, with sleep() between calls as the sample above does. status moves from scheduled or queued to sending and settles on sent once every copy handed over has gone out or failed. It stays sending while copies are still waiting, even after completedAt says the last person was reached. failed means the whole broadcast stopped, and lastError says why: the from address can no longer be sent from, the template stopped resolving, the plan ran out part way, the sending itself kept failing, or not one copy could be written.
cancel stops a broadcast that is scheduled, queued or sending. Nobody else is added and every copy still waiting is cancelled, while copies that have gone cannot be recalled. Once every copy has gone, cancel throws a 409 broadcast_not_cancellable as a ConflictException, and cancelling a cancelled broadcast returns it as it stands.
Who it reached
listRecipients returns one OpenEmail\Result\Page of the people a broadcast went to, one row per copy, sorted by address, with items, hasMore and nextCursor. listAllRecipients walks every page into one array, and iterateRecipients returns a Generator that yields one copy at a time and fetches the next page only when the loop asks for it. limit: goes from 1 to 200 and defaults to 50, and a cursor: goes back with the same filter: and q:.
| `filter:` | Keeps |
|---|---|
| pending | Copies still queued, scheduled or sending. |
| sent | Copies that went out. |
| delivered | Copies the receiving server accepted. |
| opened | Copies opened at least once. |
| not_opened | Copies sent and never opened. |
| clicked | Copies with at least one tracked click. |
| bounced | Copies that bounced. |
| complained | Copies the person reported as spam. |
| failed | Copies that failed or were cancelled. |
| unsubscribed | People who unsubscribed after the broadcast went out. |
OpenEmail\Constants\BroadcastRecipientFilters names each filter, and q: searches the address and the name, ignoring case. Opens and clicks leave out image proxies and link scanners, and stay 0 when the broadcast went out with tracking off.
getRecipient($id, $emailId) returns one copy: the same row, plus subject, html and text exactly as that person received them, with the merge fields filled in and their own unsubscribe link. Pass the emailId of a row as the second argument. The HTML is from before open and click tracking was added. An emailId that is not a copy of this broadcast throws a 404 recipient_not_found, and an unknown broadcast a 404 broadcast_not_found, both as a NotFoundException.
stats returns the totals and a series. totals counts copies sent, delivered, bounced, complained and failed, with pending for the ones still waiting, and people who opened, clicked and unsubscribed, with opens and clicks as event counts. series is sparse and oldest first, one bucket per grain: (minute, hour or day, defaulting to hour) in which something happened, cut in the zone offsetMinutes: east of UTC. Pass intdiv((int) date('Z'), 60) for the local zone. It counts each person once, at the first time it happened to them, so it adds up to the totals.
Pass days: or minutes: to stats to also read what happened lately. window then counts what was delivered, bounced, reported as spam, opened, clicked and unsubscribed inside it, and series keeps only its buckets, while totals still covers the whole broadcast. Without either, window is null.
A key limited to particular addresses or domains reaches only the broadcasts sent from an address or domain it holds. list, listAll and iterate leave the others out, and get, the recipient methods, stats and cancel throw a 404 broadcast_not_found for them.
Response: a broadcast
send, get and cancel each return one of these as an array keyed in camelCase, and send adds replayed. list returns an OpenEmail\Result\Page of them, newest first, listAll returns every one in one array and iterate returns a Generator over them. preview returns an array with audienceIds, recipients, unsubscribed and suppressed. listRecipients returns a Page of recipient rows, getRecipient returns one row with its content, and stats returns an array with broadcastId, grain, totals, window and series. analytics returns an array with totals, series and one row per broadcast in broadcasts. Times are ISO 8601 strings, which new \DateTimeImmutable() reads.
idstring- The durable handle, `brd_` followed by 24 hex characters.
statusstring- `scheduled`, `queued`, `sending`, `sent`, `cancelled` or `failed`. `OpenEmail\Constants\BroadcastStatuses` names each one.
modestring- `live` or `test`, from the key that created it. The copies of a test broadcast are marked sent and delivered to nobody.
sourcestring- Where it was started: `api` for a key, `oauth` for a connected app, `composer` for the app, `mcp` for an assistant.
audienceIdsarray- The audiences it was sent to, each once.
fromstring- The address every copy is sent from.
subjectstring- The subject as written, merge fields and all. Empty when a template supplies the subject.
countsarray- `recipients` is the estimate taken at `send`. `created` counts the copies written, `skipped` the people passed over because their address was suppressed by then, and `failedToQueue` the people whose copy could not be written. `queued`, `sending`, `sent`, `failed` and `cancelled` count the copies by the state each one is in now.
lastErrorstring or null- Why the broadcast failed, or the most recent copy that could not be written and why. It is null while nothing has gone wrong.
scheduledAtstring or null- ISO-8601 UTC, when the sending is due to start. It is null for a broadcast sent straight away.
startedAtstring or null- ISO-8601 UTC, when the sending reached the first people.
completedAtstring or null- ISO-8601 UTC, when the last person was reached. Copies can still be waiting to go after it.
cancelledAtstring or null- ISO-8601 UTC, when `cancel` stopped it.
createdAtstring- ISO-8601 UTC, when `send` was called. Fixes the list order.
updatedAtstring- ISO-8601 UTC, bumped as the sending moves on.
Response: a recipient row
Each row of listRecipients, listAllRecipients and iterateRecipients, as an array keyed in camelCase. The array getRecipient returns adds subject, html and text.
emailIdstring- The `msg_` id of this person’s copy. `getRecipient` reads it with its content, and `emails->get` reads it as a sent email, as the List and get page describes.
contactIdstring or null- The contact it went to, or null when the contact has been deleted since.
emailstring- The address the copy went to.
namestring or null- The name on the contact.
statusstring- The state of the copy: `queued`, `scheduled`, `sending`, `sent`, `failed` or `cancelled`.
sentAtstring or null- ISO-8601 UTC, when the copy went out.
deliveredAtstring or null- ISO-8601 UTC, when the receiving server accepted it, the first `email.delivered`.
bouncedAtstring or null- ISO-8601 UTC, when it bounced, the first `email.bounced`.
complainedAtstring or null- ISO-8601 UTC, when the person reported it as spam, the first `email.complained`.
failurestring or null- Why the copy failed, when it did.
opensint- Opens recorded, without the ones image proxies and scanners make. 0 when tracking was off.
firstOpenAtstring or null- ISO-8601 UTC, the first open.
clicksint- Clicks recorded on tracked links, without scanners.
firstClickAtstring or null- ISO-8601 UTC, the first click.
unsubscribedAtstring or null- ISO-8601 UTC, when this person unsubscribed from one of the broadcast’s audiences after it went out, through its link or otherwise.