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:
| Status | Meaning | What you can do |
|---|---|---|
draft | Created, not sent. The only editable state. | Update, test, send |
queued | Accepted by send and waiting: either scheduled for later, or about to be picked up. | Cancel |
sending | Being handed to the send pipeline, recipient by recipient. | Cancel |
sent | Every recipient has been handed off. | Read stats, resend to non-responders |
cancelled | Stopped 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:
- Every contact in the segment (a dynamic segment's rules are evaluated at that moment).
- minus contacts marked unsubscribed,
- minus addresses on your suppression list,
- 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.
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:
| Placeholder | Replaced 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:
<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:
- 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 itsweight. - The broadcast stays
sendingfortest_duration_minutes. - 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_sizeintegerdefault20- Percentage of the audience in the test group, 5–50.
ab_test.test_duration_minutesintegerdefault120- How long to wait before picking the winner, 30–1440.
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.
https://api.poststack.dev/broadcasts// 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',
});curl -X POST https://api.poststack.dev/broadcasts \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "March newsletter",
"from": "Acme Newsletter <newsletter@yourdomain.com>",
"subject": "What is new in March",
"html": "<p>Hi {{first_name}}</p>",
"segment_id": "seg_4hd8s1k0q7m2x9w5c3v6b1nz"
}'{
"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>",
"reply_to": "hello@yourdomain.com",
"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. fromstringrequirednewsletter@yourdomain.comorAcme 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:00is 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
| Status | Meaning |
|---|---|
| 201 | Draft created. |
| 400 | Body failed validation — the error names the field. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | Segment not found, or Subscription topic not found, in your team. |
| 422 | Variant weights must sum to 100. |
| 429 | More 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.
https://api.poststack.dev/broadcastsconst { data, meta } = await poststack.broadcasts.list({ status: 'sent', per_page: 50 });curl "https://api.poststack.dev/broadcasts?status=sent&per_page=50" \
-H "Authorization: Bearer sk_live_..."{
"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
pageintegerdefault1- Page number, from 1.
per_pageintegerdefault20- 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
| Status | Meaning |
|---|---|
| 200 | The page of broadcasts. |
| 400 | page 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). |
| 401 | Missing or invalid API key. |
| 403 | insufficient_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.
https://api.poststack.dev/broadcasts/:idconst broadcast = await poststack.broadcasts.get('bc_7kq2m9x4w1p8z3c6v5b0n2rt');curl https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt \
-H "Authorization: Bearer sk_live_..."{
"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
| Status | Meaning |
|---|---|
| 200 | The broadcast. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No 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.
https://api.poststack.dev/broadcasts/:idconst broadcast = await poststack.broadcasts.update('bc_7kq2m9x4w1p8z3c6v5b0n2rt', {
subject: "Updated: what's new in March",
scheduled_at: '2026-03-26T09:00:00Z',
});curl -X PATCH https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "subject": "Updated subject", "scheduled_at": "2026-03-26T09:00:00Z" }'{
"subject": "Updated: what's new in March",
"topic_id": null,
"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.nulldetaches the topic. fromstringnewsletter@yourdomain.comorAcme 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:00is 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
| Status | Meaning |
|---|---|
| 200 | Updated (or unchanged, if the body was empty). |
| 400 | Body failed validation. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | Broadcast, segment or subscription topic not found. |
| 422 | Only draft broadcasts can be updated — or Variant weights must sum to 100. |
| 429 | More 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.
https://api.poststack.dev/broadcasts/:id/testawait poststack.broadcasts.sendTest('bc_7kq2m9x4w1p8z3c6v5b0n2rt', {
email: 'you@yourdomain.com',
});curl -X POST https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/test \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "email": "you@yourdomain.com" }'{
"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
| Status | Meaning |
|---|---|
| 200 | Test handed to the send pipeline. |
| 400 | email is not a valid address. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No broadcast with that id exists in your team. |
| 422 | Broadcast 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). |
| 429 | More 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.
https://api.poststack.dev/broadcasts/:id/sendawait poststack.broadcasts.send('bc_7kq2m9x4w1p8z3c6v5b0n2rt');curl -X POST https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/send \
-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
| Status | Meaning |
|---|---|
| 200 | Queued. Poll Get a broadcast for progress. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No broadcast with that id exists in your team. |
| 422 | Only 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. |
| 429 | More 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.
https://api.poststack.dev/broadcasts/:id/cancelawait poststack.broadcasts.cancel('bc_7kq2m9x4w1p8z3c6v5b0n2rt');curl -X POST https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/cancel \
-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
| Status | Meaning |
|---|---|
| 200 | Cancelled. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No broadcast with that id exists in your team. |
| 422 | Only queued or sending broadcasts can be cancelled. |
| 429 | More 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.
https://api.poststack.dev/broadcasts/:id/variants// Returns the array directly.
const variants = await poststack.broadcasts.getVariants('bc_7kq2m9x4w1p8z3c6v5b0n2rt');curl https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/variants \
-H "Authorization: Bearer sk_live_..."{
"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
| Status | Meaning |
|---|---|
| 200 | The variants. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No 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.
https://api.poststack.dev/broadcasts/:id/variant-statsconst { variants, winnerVariantId } = await poststack.broadcasts.getVariantStats(
'bc_7kq2m9x4w1p8z3c6v5b0n2rt',
);curl https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/variant-stats \
-H "Authorization: Bearer sk_live_..."{
"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
| Status | Meaning |
|---|---|
| 200 | The stats. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No broadcast with that id exists in your team. |
| 422 | Broadcast 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.
https://api.poststack.dev/broadcasts/:id/end-ab-testcurl -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
| Status | Meaning |
|---|---|
| 200 | Winner picked; the rest of the audience is being sent. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No broadcast with that id exists in your team. |
| 422 | Broadcast 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. |
| 429 | More 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.
https://api.poststack.dev/broadcasts/:id/performancecurl 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
| Status | Meaning |
|---|---|
| 200 | The summary. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No 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?'.
https://api.poststack.dev/broadcasts/performancecurl "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"defaultclick_rate- What to rank by, highest first.
sincestring (ISO 8601)- Only broadcasts created at or after this time. An unparseable value is ignored.
limitintegerdefault20- 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
| Status | Meaning |
|---|---|
| 200 | The ranking. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_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.
https://api.poststack.dev/broadcasts/:id/non-openerscurl "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
limitintegerdefault500- 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
| Status | Meaning |
|---|---|
| 200 | The contacts. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No 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.
https://api.poststack.dev/broadcasts/:id/non-clickerscurl 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
limitintegerdefault500- At most 5000.
Response fields
broadcast / count / contacts[]object / integer / object[]- As in List non-openers.
Status codes
| Status | Meaning |
|---|---|
| 200 | The contacts. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No 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.
https://api.poststack.dev/broadcasts/:id/resendconst 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);curl -X POST https://api.poststack.dev/broadcasts/bc_7kq2m9x4w1p8z3c6v5b0n2rt/resend \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "target": "non_openers" }'{
"target": "non_openers",
"subject": "In case you missed it: what's new in March"
}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"defaultnon_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
| Status | Meaning |
|---|---|
| 201 | Draft and segment created. |
| 400 | Body failed validation. |
| 401 | Missing or invalid API key. |
| 403 | insufficient_scope — the key is a sending_access key, which cannot manage broadcasts. Also returned when the team is suspended. |
| 404 | No broadcast with that id exists in your team. |
| 422 | Can 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. |
| 429 | More than 10 requests a minute to this endpoint from one key. Honour Retry-After. |
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.