Skip to content

Broadcasts

Send one message to every contact in a segment — a newsletter, a launch, an announcement — with personalization, scheduling, A/B tests and follow-ups to the people who didn't open.

How a broadcast is sent#

A broadcast is always created as a draft. Nothing leaves until you call send, which checks the broadcast and moves it through these states:

StatusMeaningWhat you can do
draftCreated, not sent. The only editable state.Update, test, send
queuedAccepted by send and waiting: either scheduled for later, or about to be picked up.Cancel
sendingBeing handed to the send pipeline, recipient by recipient.Cancel
sentEvery recipient has been handed off.Read stats, resend to non-responders
cancelledStopped by cancel. Mail already handed off is not recalled.Read stats

Who receives it

The audience is resolved when sending starts, not when you create the broadcast:

  1. Every contact in the segment (a dynamic segment's rules are evaluated at that moment).
  2. minus contacts marked unsubscribed,
  3. minus addresses on your suppression list,
  4. minus, for a topic-scoped broadcast, contacts who opted out of that topic.

Each recipient becomes an ordinary email in your account: it counts against your plan's email quota, gets the open and click tracking your from-domain is configured for, appears in the email log, and fires the per-email webhooks (email.sent, email.delivered, email.opened…). Broadcast mail is queued at bulk priority, so your transactional mail sent during a large broadcast is not stuck behind it.

Every broadcast message carries RFC 8058 one-click List-Unsubscribe headers, whether or not the body has an unsubscribe link, and never gets a mailbox signature.

One extra email.sent event per broadcast

When a broadcast finishes, an email.sent webhook fires once more with a broadcast-level payload — { "broadcast_id": "bc_…", "total_recipients": 1250 } and no email_id. A handler that expects data.email_id on every email.sent must skip it.

Scheduling

Set scheduled_at, then call send. If the time is in the future the broadcast stays queued and a scheduler that runs every 30 seconds starts it once the time has passed; if it is in the past or unset, sending starts straight away. A draft with a scheduled_at that you never send is never sent.

Personalization#

Both bodies are personalized per recipient when the broadcast is sent. These are the only placeholders a broadcast understands:

PlaceholderReplaced with
{{first_name}}The contact's first name, or an empty string when it isn't set.
{{last_name}}The contact's last name, or an empty string.
{{email}}The contact's email address.
{{unsubscribe_url}}A signed link for this contact. Opening it shows a confirmation page; only confirming suppresses the address and marks the contact unsubscribed, so a link scanner following it cannot opt anyone out.
{{preferences_url}}A signed link to the contact's preference centre, where they can turn individual subscription topics off instead of opting out of everything.
  • Write them exactly as shown: double braces, no spaces inside. Case does not matter ({{First_Name}} works); {{ first_name }} does not.
  • Only the HTML and text bodies are personalized. A placeholder in the subject is sent literally.
  • Anything else in braces — including template syntax such as {{#if first_name}} or a contact property — is delivered exactly as written. Broadcasts do not use the template renderer.

The one-click headers are automatic, but Gmail and Yahoo also expect a visible unsubscribe link in bulk mail. Put {{unsubscribe_url}} in your footer:

html
<p>Hi {{first_name}}, here's what shipped this month.</p>

<footer>
  <a href="{{unsubscribe_url}}">Unsubscribe</a> ·
  <a href="{{preferences_url}}">Manage preferences</a>
</footer>

A/B testing#

Pass ab_test on create (or update) to test up to five subject lines or bodies. When the broadcast is sent:

  1. A random test_sample_size% of the audience (never fewer contacts than there are variants) is picked, and each of them gets one variant, chosen at random in proportion to its weight.
  2. The broadcast stays sending for test_duration_minutes.
  3. The variant with the highest open rate (opens ÷ recipients, scanner and image-proxy opens excluded) wins; on a tie the first variant wins. The rest of the audience gets the winner. You can end the window early with end-ab-test.
ab_test.variantsobject[]required
2–5 variants. Their weights must add up to exactly 100, or the request fails with 422 Variant weights must sum to 100.
ab_test.variants[].namestringrequired
1–10 characters, e.g. A.
ab_test.variants[].subjectstringrequired
This variant's subject line, 1–998 characters.
ab_test.variants[].html / textstring
This variant's bodies. Omit them to use the broadcast's own html/text — the usual subject-line-only test.
ab_test.variants[].weightintegerrequired
1–100. Relative share of the test group.
ab_test.test_sample_sizeintegerdefault 20
Percentage of the audience in the test group, 5–50.
ab_test.test_duration_minutesintegerdefault 120
How long to wait before picking the winner, 30–1440.
typescript
const broadcast = await poststack.broadcasts.create({
  name: 'March newsletter — subject test',
  from: 'Acme Newsletter <newsletter@yourdomain.com>',
  subject: "What's new in March", // not used once variants exist
  html: '<p>Hi {{first_name}}, here is what shipped.</p>',
  segment_id: 'seg_4hd8s1k0q7m2x9w5c3v6b1nz',
  ab_test: {
    variants: [
      { name: 'A', subject: "What's new in March", weight: 50 },
      { name: 'B', subject: 'Five things we shipped in March', weight: 50 },
    ],
    test_sample_size: 20,       // 20% get a variant first
    test_duration_minutes: 120, // winner picked after 2 hours
  },
});

await poststack.broadcasts.send(broadcast.publicId);

Endpoints#

All paths are relative to https://api.poststack.dev. Every endpoint needs an API key with full access (the broadcasts:read or broadcasts:manage scope for OAuth tokens); a sending-only key gets 403.

Create a broadcast#

Create a draft broadcast. It is never sent on create — call send.

POSThttps://api.poststack.dev/broadcasts
Request
// The SDK unwraps the { broadcast } envelope.
const broadcast = await poststack.broadcasts.create({
  name: 'March newsletter',
  from: 'Acme Newsletter <newsletter@yourdomain.com>',
  subject: "What's new in March",
  html: "<p>Hi {{first_name}}, here's what shipped.</p>",
  segment_id: 'seg_4hd8s1k0q7m2x9w5c3v6b1nz',
  topic_id: 'top_9pn3t6w0y2k5m8r1x4c7v0qa',
  scheduled_at: '2026-03-25T09:00:00Z',
});
HTTP/1.1 201 Created

{
  "broadcast": {
    "id": 214,
    "publicId": "bc_7kq2m9x4w1p8z3c6v5b0n2rt",
    "teamId": 42,
    "segmentId": 17,
    "segmentPublicId": "seg_4hd8s1k0q7m2x9w5c3v6b1nz",
    "topicId": 3,
    "topicPublicId": "top_9pn3t6w0y2k5m8r1x4c7v0qa",
    "fromAddress": "newsletter@yourdomain.com",
    "fromName": "Acme Newsletter",
    "name": "March newsletter",
    "subject": "What's new in March",
    "htmlBody": "<p>Hi {{first_name}}, here's what shipped.</p>",
    "textBody": null,
    "replyTo": ["hello@yourdomain.com"],
    "status": "draft",
    "scheduledAt": "2026-03-25T09:00:00.000Z",
    "sentAt": null,
    "totalRecipients": 0,
    "deliveredCount": 0,
    "openedCount": 0,
    "clickedCount": 0,
    "bouncedCount": 0,
    "abTestEnabled": false,
    "winnerVariantId": null,
    "testSampleSize": null,
    "testDurationMinutes": null,
    "createdAt": "2026-03-23T10:00:00.000Z",
    "updatedAt": "2026-03-23T10:00:00.000Z"
  }
}

Body parameters

segment_idstringrequired
The segment to send to: its seg_… id. A legacy numeric id as a string is still accepted. Must belong to your team, or you get 404.
topic_idstring
A subscription topic (top_…). Contacts who opted out of this topic are skipped at send time; contacts with no preference for it are included.
fromstringrequired
newsletter@yourdomain.com or Acme Newsletter <newsletter@yourdomain.com>. The domain is not checked here — it must be a verified domain of your team by the time you call send or test.
subjectstringrequired
1–998 characters after trimming.
htmlstring
HTML body. May contain the personalization placeholders listed below.
textstring
Plain-text body. Same placeholders as html.
reply_tostring
A single email address. A display-name form is rejected with 400.
namestring
Internal label, up to 100 characters.
scheduled_atstring (ISO 8601)
UTC datetime ending in Z, e.g. 2026-03-25T09:00:00Z; an offset like +01:00 is rejected. Stored only — nothing is sent until you call send.
ab_testobject
Turns the broadcast into an A/B test. See A/B testing.

Response fields

broadcast.idinteger
Internal numeric id. Address the broadcast by publicId instead.
broadcast.publicIdstring
The bc_… id every other endpoint takes.
broadcast.segmentPublicIdstring | null
The target segment. null if the segment was deleted.
broadcast.topicPublicIdstring | null
The subscription topic, when the broadcast is topic-scoped.
broadcast.segmentId / topicIdinteger | null
Internal numeric ids of the same two references.
broadcast.fromAddressstring
The bare address parsed out of from.
broadcast.fromNamestring | null
The display name parsed out of from, if you gave one.
broadcast.namestring | null
Internal label. Never shown to recipients.
broadcast.subjectstring
Subject line, sent as written (placeholders are not filled in the subject).
broadcast.htmlBody / textBodystring | null
The bodies as stored, placeholders unresolved.
broadcast.replyTostring[] | null
The reply_to address, as a one-element array.
broadcast.status"draft" | "queued" | "sending" | "sent" | "cancelled"
See the lifecycle table above.
broadcast.scheduledAtstring | null
When a queued broadcast goes out. Only acted on after you call send.
broadcast.sentAtstring | null
Set when the whole audience has been handed to the send pipeline.
broadcast.totalRecipientsinteger
Audience size after filtering; replaced by the count actually reached when sending finishes.
broadcast.deliveredCount / openedCount / clickedCount / bouncedCountinteger
Engagement counters. Opens and clicks exclude scanner and image-proxy hits (see Tracking).
broadcast.abTestEnabledboolean
true when the broadcast was created or updated with ab_test.
broadcast.winnerVariantIdinteger | null
The winning variant once the test window closes.
broadcast.testSampleSize / testDurationMinutesinteger | null
The A/B settings in effect.
broadcast.createdAt / updatedAtstring
ISO 8601 timestamps.

Status codes

StatusMeaning
201Draft created.
400Body failed validation — the error names the field.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404Segment not found, or Subscription topic not found, in your team.
422Variant weights must sum to 100.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.

List broadcasts#

Your team's broadcasts, newest first. Bodies are left out of list rows — fetch one broadcast to get them.

GEThttps://api.poststack.dev/broadcasts
Request
const { data, meta } = await poststack.broadcasts.list({ status: 'sent', per_page: 50 });
{
  "data": [
    {
      "id": 214,
      "publicId": "bc_7kq2m9x4w1p8z3c6v5b0n2rt",
      "name": "March newsletter",
      "subject": "What's new in March",
      "status": "sent",
      "segmentPublicId": "seg_4hd8s1k0q7m2x9w5c3v6b1nz",
      "topicPublicId": null,
      "totalRecipients": 1250,
      "deliveredCount": 1230,
      "openedCount": 456,
      "clickedCount": 89,
      "bouncedCount": 12,
      "sentAt": "2026-03-25T09:04:12.000Z",
      "createdAt": "2026-03-23T10:00:00.000Z",
      "...": "every other column from the Create response"
    }
  ],
  "meta": { "page": 1, "perPage": 20, "total": 24, "totalPages": 2 }
}

Query parameters

pageintegerdefault 1
Page number, from 1.
per_pageintegerdefault 20
1–100.
searchstring
Case-insensitive substring match on the subject (not the name).
status"draft" | "queued" | "sending" | "sent" | "cancelled"
Only broadcasts in this state. Any other value is a 400 that lists the valid ones.

Response fields

data[]object[]
Broadcast rows as in Create, without htmlBody and textBody.
meta.pageinteger
The page returned.
meta.perPageinteger
Rows per page.
meta.totalinteger
Rows matching the filters.
meta.totalPagesinteger
ceil(total / perPage).

Status codes

StatusMeaning
200The page of broadcasts.
400page or per_page is not a positive integer, per_page > 100, or status is not one of the five states (status: status must be one of: draft, queued, sending, sent, cancelled).
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.

Get a broadcast#

One broadcast with its bodies, the names of its segment and topic, and how many of its messages are still being retried.

GEThttps://api.poststack.dev/broadcasts/:id
Request
const broadcast = await poststack.broadcasts.get('bc_7kq2m9x4w1p8z3c6v5b0n2rt');
{
  "broadcast": {
    "id": 214,
    "publicId": "bc_7kq2m9x4w1p8z3c6v5b0n2rt",
    "status": "sent",
    "subject": "What's new in March",
    "htmlBody": "<p>Hi {{first_name}}, here's what shipped.</p>",
    "segmentPublicId": "seg_4hd8s1k0q7m2x9w5c3v6b1nz",
    "segment": { "name": "Newsletter", "publicId": "seg_4hd8s1k0q7m2x9w5c3v6b1nz" },
    "topicPublicId": "top_9pn3t6w0y2k5m8r1x4c7v0qa",
    "topic": { "name": "Product updates", "publicId": "top_9pn3t6w0y2k5m8r1x4c7v0qa" },
    "totalRecipients": 1250,
    "deliveredCount": 1230,
    "bouncedCount": 12,
    "deferredCount": 8,
    "openedCount": 456,
    "clickedCount": 89,
    "sentAt": "2026-03-25T09:04:12.000Z",
    "...": "every other column from the Create response"
  }
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Response fields

broadcastobject
Every field from Create, plus the three below.
broadcast.segment{ name, publicId } | null
The target segment.
broadcast.topic{ name, publicId } | null
The subscription topic.
broadcast.deferredCountinteger
Messages still being retried by the receiving server. Explains why delivered + bounced can be below totalRecipients while mail is in flight.

Status codes

StatusMeaning
200The broadcast.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.

Update a broadcast#

Change a draft. Send only the fields you want to change. Once a broadcast has been sent (queued or later) it can no longer be edited.

PATCHhttps://api.poststack.dev/broadcasts/:id
Request
const broadcast = await poststack.broadcasts.update('bc_7kq2m9x4w1p8z3c6v5b0n2rt', {
  subject: "Updated: what's new in March",
  scheduled_at: '2026-03-26T09:00:00Z',
});
{
  "broadcast": {
    "id": 214,
    "publicId": "bc_7kq2m9x4w1p8z3c6v5b0n2rt",
    "teamId": 42,
    "segmentId": 17,
    "segmentPublicId": "seg_4hd8s1k0q7m2x9w5c3v6b1nz",
    "topicId": 3,
    "topicPublicId": "top_9pn3t6w0y2k5m8r1x4c7v0qa",
    "fromAddress": "newsletter@yourdomain.com",
    "fromName": "Acme Newsletter",
    "name": "March newsletter",
    "subject": "What's new in March",
    "htmlBody": "<p>Hi {{first_name}}, here's what shipped.</p>",
    "textBody": null,
    "replyTo": ["hello@yourdomain.com"],
    "status": "draft",
    "scheduledAt": "2026-03-25T09:00:00.000Z",
    "sentAt": null,
    "totalRecipients": 0,
    "deliveredCount": 0,
    "openedCount": 0,
    "clickedCount": 0,
    "bouncedCount": 0,
    "abTestEnabled": false,
    "winnerVariantId": null,
    "testSampleSize": null,
    "testDurationMinutes": null,
    "createdAt": "2026-03-23T10:00:00.000Z",
    "updatedAt": "2026-03-23T10:00:00.000Z"
  }
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Body parameters

segment_idstring
The segment to send to: its seg_… id. A legacy numeric id as a string is still accepted. Must belong to your team, or you get 404.
topic_idstring | null
A subscription topic (top_…). Contacts who opted out of this topic are skipped at send time; contacts with no preference for it are included. null detaches the topic.
fromstring
newsletter@yourdomain.com or Acme Newsletter <newsletter@yourdomain.com>. The domain is not checked here — it must be a verified domain of your team by the time you call send or test.
subjectstring
1–998 characters after trimming.
htmlstring
HTML body. May contain the personalization placeholders listed below.
textstring
Plain-text body. Same placeholders as html.
reply_tostring
A single email address. A display-name form is rejected with 400.
namestring
Internal label, up to 100 characters.
scheduled_atstring (ISO 8601)
UTC datetime ending in Z, e.g. 2026-03-25T09:00:00Z; an offset like +01:00 is rejected. Stored only — nothing is sent until you call send.
ab_testobject
Turns the broadcast into an A/B test. See A/B testing. Sending it again replaces every variant.

Response fields

broadcastobject
The updated broadcast, shaped as in Create.

Status codes

StatusMeaning
200Updated (or unchanged, if the body was empty).
400Body failed validation.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404Broadcast, segment or subscription topic not found.
422Only draft broadcasts can be updated — or Variant weights must sum to 100.
429More than 30 requests a minute to this endpoint from one key. Honour Retry-After.

Send a test#

Send one copy to an address of your choice, in any status. The subject is prefixed with [TEST]. The copy is not tied to a contact, so {{first_name}}, {{last_name}} and {{email}} arrive literally; the unsubscribe and preferences links are real links for the test address. An A/B broadcast's test uses the broadcast's own subject and body, not a variant. The test is a real send: it counts against your quota.

POSThttps://api.poststack.dev/broadcasts/:id/test
Request
await poststack.broadcasts.sendTest('bc_7kq2m9x4w1p8z3c6v5b0n2rt', {
  email: 'you@yourdomain.com',
});
{
  "success": true
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Body parameters

emailstringrequired
Where to send the test copy.

Response fields

successboolean
Always true on 200.

Status codes

StatusMeaning
200Test handed to the send pipeline.
400email is not a valid address.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.
422Broadcast has no content to send, the from domain is not a domain of your team or not verified, or the send itself was refused (for example the address is suppressed).
429More than 10 requests a minute to this endpoint from one key. Honour Retry-After.

Send a broadcast#

Start (or schedule) a draft. The checks below run first; if they pass the broadcast becomes queued and goes out now, or at scheduled_at if that is in the future. Calling send twice is safe: the second call gets 422.

POSThttps://api.poststack.dev/broadcasts/:id/send
Request
await poststack.broadcasts.send('bc_7kq2m9x4w1p8z3c6v5b0n2rt');
{
  "success": true
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Response fields

successboolean
Always true on 200.

Status codes

StatusMeaning
200Queued. Poll Get a broadcast for progress.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.
422Only draft broadcasts can be sent, Broadcast must have a segment (its segment was deleted), Segment has no contacts / Segment has no matching contacts, or the from domain is not a domain of your team / not verified.
429More than 10 requests a minute to this endpoint from one key. Honour Retry-After.

Cancel a broadcast#

Stop a queued (including scheduled) or sending broadcast. A run in progress stops before its next recipient; messages already handed off are not recalled, and totalRecipients is set to how many were reached. A cancelled broadcast cannot be restarted — create a new one.

POSThttps://api.poststack.dev/broadcasts/:id/cancel
Request
await poststack.broadcasts.cancel('bc_7kq2m9x4w1p8z3c6v5b0n2rt');
{
  "success": true
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Response fields

successboolean
Always true on 200.

Status codes

StatusMeaning
200Cancelled.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.
422Only queued or sending broadcasts can be cancelled.
429More than 10 requests a minute to this endpoint from one key. Honour Retry-After.

List A/B variants#

The stored variants of a broadcast. Returns an empty array for a broadcast without an A/B test.

GEThttps://api.poststack.dev/broadcasts/:id/variants
Request
// Returns the array directly.
const variants = await poststack.broadcasts.getVariants('bc_7kq2m9x4w1p8z3c6v5b0n2rt');
{
  "variants": [
    {
      "id": 31,
      "broadcastId": 214,
      "name": "A",
      "subject": "What's new in March",
      "htmlBody": null,
      "textBody": null,
      "weight": 50,
      "recipientCount": 125,
      "deliveredCount": 124,
      "openedCount": 46,
      "clickedCount": 9,
      "bouncedCount": 1,
      "createdAt": "2026-03-23T10:00:00.000Z",
      "updatedAt": "2026-03-25T11:04:00.000Z"
    }
  ]
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Response fields

variants[].idinteger
Variant id; matches winnerVariantId.
variants[].broadcastIdinteger
Internal id of the broadcast.
variants[].name / subject / weightstring / string / integer
As you set them.
variants[].htmlBody / textBodystring | null
null means the variant uses the broadcast body.
variants[].recipientCount / deliveredCount / openedCount / clickedCount / bouncedCountinteger
Per-variant counters.
variants[].createdAt / updatedAtstring
ISO 8601.

Status codes

StatusMeaning
200The variants.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.

Get A/B variant stats#

Per-variant performance with open and click rates, and the winner once it has been picked.

GEThttps://api.poststack.dev/broadcasts/:id/variant-stats
Request
const { variants, winnerVariantId } = await poststack.broadcasts.getVariantStats(
  'bc_7kq2m9x4w1p8z3c6v5b0n2rt',
);
{
  "variants": [
    {
      "id": 31, "name": "A", "subject": "What's new in March", "weight": 50,
      "recipientCount": 125, "deliveredCount": 124, "openedCount": 46,
      "clickedCount": 9, "bouncedCount": 1,
      "openRate": 0.368, "clickRate": 0.072
    },
    {
      "id": 32, "name": "B", "subject": "Five things we shipped in March", "weight": 50,
      "recipientCount": 125, "deliveredCount": 125, "openedCount": 56,
      "clickedCount": 11, "bouncedCount": 0,
      "openRate": 0.448, "clickRate": 0.088
    }
  ],
  "winnerVariantId": 32
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Response fields

variants[]object[]
id, name, subject, weight and the five counters, as in List A/B variants.
variants[].openRatenumber
openedCount ÷ recipientCount, as a fraction (0.368 = 36.8%). 0 when nobody received it.
variants[].clickRatenumber
clickedCount ÷ recipientCount.
winnerVariantIdinteger | null
null until the test window closes.

Status codes

StatusMeaning
200The stats.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.
422Broadcast does not have A/B testing enabled.

End an A/B test early#

Pick the winner now instead of waiting out test_duration_minutes, and send it to the rest of the audience. The winner is chosen by the same open-rate rule and the decision is final.

POSThttps://api.poststack.dev/broadcasts/:id/end-ab-test
Request
curl -X POST https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/end-ab-test \
  -H "Authorization: Bearer sk_live_..."
{
  "success": true
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Response fields

successboolean
Always true on 200.

Status codes

StatusMeaning
200Winner picked; the rest of the audience is being sent.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.
422Broadcast does not have A/B testing enabled, A/B test can only be ended while the test window is active (status is not sending), or Winner has already been picked.
429More than 10 requests a minute to this endpoint from one key. Honour Retry-After.

Get broadcast performance#

A compact summary of one broadcast's results with rates computed for you, plus its A/B stats when it has a test. Rates divide by total_recipients.

GEThttps://api.poststack.dev/broadcasts/:id/performance
Request
curl https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/performance \
  -H "Authorization: Bearer sk_live_..."
{
  "broadcast": {
    "id": "bc_7kq2m9x4w1p8z3c6v5b0n2rt",
    "subject": "What's new in March",
    "status": "sent",
    "sent_at": "2026-03-25T09:04:12.000Z",
    "total_recipients": 1250,
    "delivered": 1230,
    "in_progress": 8,
    "opened": 456,
    "clicked": 89,
    "bounced": 12,
    "open_rate": 0.3648,
    "click_rate": 0.0712
  },
  "variants": null
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Response fields

broadcast.idstring
The bc_… id.
broadcast.subject / statusstring
As on the broadcast.
broadcast.sent_atstring | null
ISO 8601.
broadcast.total_recipientsinteger
Audience reached.
broadcast.delivered / opened / clicked / bouncedinteger
The counters.
broadcast.in_progressinteger
Messages still being retried — the gap between delivered + bounced and total.
broadcast.open_rate / click_ratenumber
Fractions of total_recipients.
variantsobject | null
The Get A/B variant stats body, or null without an A/B test.

Status codes

StatusMeaning
200The summary.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.

Rank broadcasts by performance#

Your sent and sending broadcasts, ranked by one metric — the quickest way to answer 'which newsletter did best this quarter?'.

GEThttps://api.poststack.dev/broadcasts/performance
Request
curl "https://api.poststack.dev/broadcasts/performance?metric=open_rate&since=2026-01-01T00:00:00Z" \
  -H "Authorization: Bearer sk_live_..."
{
  "metric": "open_rate",
  "since": "2026-01-01T00:00:00.000Z",
  "broadcasts": [
    {
      "id": "bc_7kq2m9x4w1p8z3c6v5b0n2rt",
      "subject": "What's new in March",
      "status": "sent",
      "sent_at": "2026-03-25T09:04:12.000Z",
      "total_recipients": 1250,
      "delivered": 1230,
      "opened": 456,
      "clicked": 89,
      "bounced": 12,
      "open_rate": 0.3648,
      "click_rate": 0.0712
    }
  ]
}

Query parameters

metric"open_rate" | "click_rate" | "delivered"default click_rate
What to rank by, highest first.
sincestring (ISO 8601)
Only broadcasts created at or after this time. An unparseable value is ignored.
limitintegerdefault 20
At most 100.

Response fields

metricstring
The metric used.
sincestring | null
The window start used.
broadcasts[]object[]
Same fields as broadcast in Get broadcast performance, without in_progress.

Status codes

StatusMeaning
200The ranking.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.

List non-openers#

Recipients whose copy was delivered but who have no recorded open. Messages that bounced or are still in flight are not included — they could not have been opened. Scanner and image-proxy opens count as opens here.

GEThttps://api.poststack.dev/broadcasts/:id/non-openers
Request
curl "https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/non-openers?limit=1000" \
  -H "Authorization: Bearer sk_live_..."
{
  "broadcast": { "id": "bc_7kq2m9x4w1p8z3c6v5b0n2rt", "subject": "What's new in March" },
  "count": 1,
  "contacts": [
    { "id": 874, "email": "ada@example.com", "first_name": "Ada", "last_name": null }
  ]
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Query parameters

limitintegerdefault 500
At most 5000.

Response fields

broadcast{ id, subject }
The broadcast (id is the bc_… id).
countinteger
Contacts returned (capped by limit).
contacts[].idinteger
The contact's internal numeric id.
contacts[].email / first_name / last_namestring / string | null
Contact details.

Status codes

StatusMeaning
200The contacts.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.

List non-clickers#

Recipients whose copy was delivered but who clicked no tracked link. Same rules and response as List non-openers.

GEThttps://api.poststack.dev/broadcasts/:id/non-clickers
Request
curl https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/non-clickers \
  -H "Authorization: Bearer sk_live_..."
{
  "broadcast": { "id": "bc_7kq2m9x4w1p8z3c6v5b0n2rt", "subject": "What's new in March" },
  "count": 1,
  "contacts": [
    { "id": 912, "email": "grace@example.com", "first_name": "Grace", "last_name": "Hopper" }
  ]
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Query parameters

limitintegerdefault 500
At most 5000.

Response fields

broadcast / count / contacts[]object / integer / object[]
As in List non-openers.

Status codes

StatusMeaning
200The contacts.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.

Resend to non-responders#

Build a follow-up for a sent (or sending) broadcast. PostStack snapshots up to 5,000 non-openers (or non-clickers) into a new manual segment and creates a new DRAFT broadcast to it, copying the sender, reply-to and bodies. Nothing is sent: review the draft, then call send on it. The copy has no topic and no A/B test.

POSThttps://api.poststack.dev/broadcasts/:id/resend
Request
const followUp = await poststack.broadcasts.resend('bc_7kq2m9x4w1p8z3c6v5b0n2rt', {
  target: 'non_openers',
  subject: "In case you missed it: what's new in March",
});

await poststack.broadcasts.send(followUp.broadcast.id);
HTTP/1.1 201 Created

{
  "broadcast": {
    "id": "bc_1mz5x8c2v7b4n9q3w6e0r5ty",
    "subject": "In case you missed it: what's new in March",
    "status": "draft"
  },
  "segment": {
    "id": "seg_6yt3r0e7w4q1p8o5i2u9y6tr",
    "name": "Re-engage: What's new in March (non-openers) · bc_7kq2m9x4w1p8z3c6v5b0n2rt · 2026-03-27T08:00:00.000Z"
  },
  "recipientCount": 794
}

Path parameters

idstringrequired
The broadcast's public id (bc_…).

Body parameters

target"non_openers" | "non_clickers"default non_openers
Who the follow-up goes to.
subjectstring
Subject of the follow-up, 1–998 characters. Defaults to "Re: " + the original subject.

Response fields

broadcast.idstring
The new draft (bc_…) — pass it to send.
broadcast.subject / statusstring
Status is always draft.
segment.id / namestring
The snapshot segment (seg_…).
recipientCountinteger
Contacts in the snapshot.

Status codes

StatusMeaning
201Draft and segment created.
400Body failed validation.
401Missing or invalid API key.
403insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended.
404No broadcast with that id exists in your team.
422Can only resend a broadcast that has been sent, Source broadcast has no content to resend, or No non-openers to resend to / No non-clickers to resend to.
429More than 10 requests a minute to this endpoint from one key. Honour Retry-After.

There is no endpoint to delete a broadcast. Drafts you no longer need can stay; they are never sent unless you call send.

Troubleshooting#

Send returns 422 “Domain 'yourdomain.com' is not verified”

The from address's domain must be a domain on your team with status verified. Check it on Domains, or change from with an update while the broadcast is still a draft.

Recipients see {{first_name}} literally

Either the placeholder has spaces inside the braces, or it is in the subject line, which is never personalized. In a test send, contact placeholders always stay literal because the test copy is not tied to a contact.

totalRecipients is smaller than the segment

Unsubscribed contacts, suppressed addresses and (for a topic-scoped broadcast) topic opt-outs are removed when sending starts. That is intended.

A scheduled broadcast never went out

scheduled_at only takes effect after you call send. If the broadcast is still draft, send it; it will go out at the scheduled time, or immediately if that time has passed.

Next steps

Was this page helpful?

Related

Broadcasts overviewNewsletters and one-off campaigns to segments, with unsubscribe handling.

Background reading