Broadcasts API: send marketing email to a segment
Marketing emails to an audience segment (the /broadcasts endpoint), with personalization, testing, scheduling, unsubscribe and statistics.
Broadcasts (the /broadcasts endpoint) are marketing sends to an audience segment, optionally only to the people subscribed to a topic. Write the content in the broadcast itself (html and text) or use a published template, personalize it with each contact's data, send a test, and send right away or schedule it. Each recipient gets their own email, with an unsubscribe link and the List-Unsubscribe headers. All endpoints require a full-access key.
#How it works
- Create the broadcast as a draft (
POST /broadcasts). Only the name is required. - Check how many contacts it reaches (
GET /broadcasts/audience), look at the personalized preview and send a test. - Send or schedule it (
POST /broadcasts/{id}/send). At that point Postal requires the segment, a sender on a verified domain, the subject and the content. - Track the progress and statistics on the broadcast itself, in recipients and in the metrics.
Who receives it: the contacts in the segment who are not unsubscribed (unsubscribed), are not on the suppression list and, if there is a topic_id, are subscribed to the topic (they chose to receive it or, on an opt_in topic, have not chosen anything). The list is read in batches during the send, so anyone who unsubscribes along the way does not receive it.
With a dynamic segment, the filters are evaluated when the send runs: whoever matches them at that moment receives it.
Each recipient becomes a regular email: it counts toward the sending quota, appears in GET /emails and generates the email.* webhooks with the broadcast_id tag. If the quota runs out midway, the broadcast becomes paused and continues on its own when the quota renews. No contact receives the same broadcast twice.
Status
| Status | What it means | What you can do |
|---|---|---|
draft | Draft. It can be incomplete; what sending requires is checked when you send. | Edit, delete, send, schedule |
scheduled | Scheduled for scheduled_at. It can still be edited, but it has to stay ready to send. The time zone you picked the time in is kept in schedule_timezone, for display only: the send goes out at scheduled_at. | Edit, delete, reschedule, send now, cancel |
queued | In the queue, waiting to start sending. | Cancel |
sending | Sending, in batches of up to 500 contacts. processed_count shows the progress. | Cancel |
paused | Stopped midway through sending, with the reason in error. With error.resumes_at (the quota ran out), it resumes on its own when the quota renews; without it (the team's sending is paused), it resumes when you send again. The error.code is daily_quota_exceeded or monthly_quota_exceeded (the sending quota ran out; scheduled_at shows when it renews; the Sends add-on reserve is not used by broadcasts) or sending_paused (the team's sending was paused because of its reputation; the sending.paused webhook tells you, and the send is only accepted again after sending.resumed). | Send again, cancel |
sent | All recipients were processed. Triggers the broadcast.sent webhook. | Duplicate |
canceled | Canceled. Anyone who had not received it yet will not receive it. | Duplicate |
failed | Could not be sent (for example, the template is no longer published or the domain lost its verification). error.code: invalid_broadcast or send_failed. | Duplicate |
#Personalization
In the subject, the HTML and the text, use variables between triple braces. After |, put a default value for when the contact does not have the data:
{{{FIRST_NAME|Customer}}}, Black Friday starts early for you
| Variable | Value |
|---|---|
{{{FIRST_NAME}}}, {{{LAST_NAME}}}, {{{EMAIL}}} | The contact's first name, last name and email. Also available as {{{contact.first_name}}}, {{{contact.last_name}}} and {{{contact.email}}}. |
{{{contact.plan}}} | Any contact property, by its key. If the contact has no value, it uses the inline default or the property's fallback_value. |
{{{COUPON}}} | Template variables (with template_id), filled in by variables or by the default value declared in the template. |
{{{POSTAL_UNSUBSCRIBE_URL}}} | The link to the contact's preferences and unsubscribe page. See Unsubscribing. |
The order is: the contact's value, the default after |, the property's fallback_value and, finally, empty. In the HTML, values are escaped. The preheader goes in as hidden text at the start of the body, the snippet that inboxes show next to the subject. Without text, the plain-text version is generated from the HTML.
With template_id, the content comes from the template's published version, and the broadcast's subject, if there is one, is used instead of the template's subject. Template variables declared with no value and no default block the send (422); FIRST_NAME, LAST_NAME, EMAIL and POSTAL_UNSUBSCRIBE_URL are filled in from the contact.
#Unsubscribing
Every broadcast email carries a signed unsubscribe link, valid only for that contact, to the preferences page at https://forms.postal.so/preferencias/…. There, the person can opt out of everything, start receiving again or choose among the public topics.
- Place the link wherever you want with
{{{POSTAL_UNSUBSCRIBE_URL}}}. If the HTML does not have the variable, Postal adds a discreet footer at the end: “Do not want to receive these emails anymore? Unsubscribe.” (in Portuguese: “Não quer mais receber estes e-mails? Descadastre-se.”). The text version gets a line with the link (“To stop receiving these emails: …” or “Para não receber mais estes e-mails: …”). The language of the footer follows the rules in Footer and page language. - The
List-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Clickheaders go on every email, for the “Unsubscribe” button of inboxes. On click, if the broadcast has a topic, the contact leaves only the topic; if not, they are unsubscribed from everything. - Each unsubscribe counts once in the broadcast's
stats.unsubscribedand triggerscontact.updated.
Footer and page language
The automatic footer and the preferences page (with the notices it shows) speak the recipient's language, pt-BR or en. Postal chooses for each recipient, using the first one that exists:
- the broadcast's
language(pt-BRoren); - the contact's
languageproperty, such aspt-BRoren-US(the language before the hyphen counts; other languages are ignored); - the contact's
localeproperty, in the same way; - the language the team owner chose in the dashboard;
- Brazilian Portuguese, when nothing says which language it is.
The preferences page uses the same order. The broadcast that counts is the one in the email the person clicked (the link carries the email ID); links from automations and old links, without the broadcast's email, start with the contact. The browser's Accept-Language does not count on these pages. The invalid link page (an altered link, or one for a contact that does not exist) chooses like this: the language of the contact named in the link (the one from its properties or, without it, the team's), then the browser's Accept-Language (pt-BR or en) and, last, Brazilian Portuguese.
To fix the language of a broadcast, send language when you create or update it. Postal does not translate your content (subject, HTML, text): only what it adds itself.
Details and best practices are in the Email marketing guide.
#Reply detection
With detect_replies: true, each broadcast email goes out with a Reply-To of its own, in the form r-<token>@…, valid only for that email: on the sender's domain, if it receives email through Postal, or on Postal's receiving domain. When the person replies, Postal records the reply on the email and forwards it to your reply_to (or, without one, to the from sender), so you keep receiving replies as usual.
- The reply triggers the
email.repliedwebhook, withreply.from,reply.subject,reply.previewandreply.auto. - Automatic replies (out of office, auto-responders, delivery reports) come with
reply.auto: trueand do not count as replies. - Each email forwards at most 20 replies, so two auto-responders cannot keep exchanging messages forever.
Automations have the same option on the send_email step, and the wait_for step can wait for the reply.
#List broadcasts
GET/broadcasts
From newest to oldest. The list returns the summary, without html, text, variables and editor.
Query parameters
limitinteger1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the name and the subject.statusstringOne or more statuses separated by commas, such asscheduled,sending.
cURL
curl 'https://api.postal.so/broadcasts?status=sent' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/broadcasts?status=sent', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/broadcasts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"status": "sent",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('broadcasts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'status' => 'sent',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "broadcast",
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"name": "Black Friday: early access",
"status": "sent",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"segment": {
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers"
},
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"topic": {
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "News"
},
"template_id": null,
"from": "Acme Store <[email protected]>",
"reply_to": ["[email protected]"],
"detect_replies": false,
"subject": "{{{FIRST_NAME|Customer}}}, Black Friday starts early for you",
"preheader": "Up to 40% off starting Thursday, for customers only.",
"language": null,
"scheduled_at": null,
"schedule_timezone": null,
"queued_at": "2026-10-22T12:00:00.000000Z",
"sent_at": "2026-10-22T12:04:31.000000Z",
"canceled_at": null,
"recipients_count": 1247,
"processed_count": 1247,
"stats": {
"sent": 1247,
"delivered": 1236,
"opened": 612,
"clicked": 188,
"bounced": 6,
"complained": 1,
"unsubscribed": 9,
"failed": 0
},
"error": null,
"created_at": "2026-10-21T18:40:00.000000Z",
"updated_at": "2026-10-22T12:04:31.000000Z"
}
]
}
#Create a broadcast
POST/broadcasts
Creates a draft (draft). Everything except the name can be missing: what sending requires is checked at send time. The segment, the topic and the template must belong to your team.
Request body
namestringrequiredInternal name, up to 255 characters. Required on creation.segment_idstring | nullThe segment that receives the broadcast. Required to send.topic_idstring | nullTopic: only people subscribed to it receive the broadcast, and a click on “Unsubscribe” removes the contact from this topic only.fromstring | nullSender, such asAcme Store <[email protected]>, on a verified domain of the team. Required to send.reply_tostring | string[] | nullReply-to addresses.detect_repliesbooleanDefault:false. Withtrue, each email gets a reply address of its own and Postal records and forwards the replies. See Reply detection.subjectstring | nullSubject, up to 998 characters, with variables. Required to send, unless the template has a subject.preheaderstring | nullPreview text, up to 500 characters.languagestring | nullLanguage of what Postal writes in the emails of this broadcast:pt-BRoren. It applies to the automatic unsubscribe footer and to the preferences page its link opens;null(the default) lets Postal choose for each recipient. See Unsubscribing. It does not translate your content.htmlstring | nullHTML body, up to 2 MB. To send, you needhtml,textortemplate_id.textstring | nullPlain-text version, up to 2 MB.template_idstring | nullvariablesobject | nullValues for the template variables, such as{"COUPON": "BF40"}.editorobject | nullState of the dashboard's visual editor. Store it and send it back as it came.
Errors
- 422
validation_errorInvalid field, such as an address not in theName <[email protected]>format or an ID that is not a UUID. - 404
not_foundThe segment, the topic or the template does not exist.
cURL
curl -X POST https://api.postal.so/broadcasts \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Black Friday: early access",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"from": "Acme Store <[email protected]>",
"reply_to": "[email protected]",
"subject": "{{{FIRST_NAME|Customer}}}, Black Friday starts early for you",
"preheader": "Up to 40% off starting Thursday, for customers only.",
"language": "en",
"html": "<p>Hi, {{{FIRST_NAME|customer}}}!</p><p>We set aside discounts of up to 40% for everyone on the {{{contact.plan}}} plan.</p><p><a href=\"https://acmestore.com/black-friday\">See the deals</a></p><p><a href=\"{{{POSTAL_UNSUBSCRIBE_URL}}}\">Unsubscribe</a></p>"
}'
Node.js
const response = await fetch('https://api.postal.so/broadcasts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Black Friday: early access',
segment_id: '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
topic_id: '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
from: 'Acme Store <[email protected]>',
reply_to: '[email protected]',
subject: '{{{FIRST_NAME|Customer}}}, Black Friday starts early for you',
preheader: 'Up to 40% off starting Thursday, for customers only.',
language: 'en',
html: '<p>Hi, {{{FIRST_NAME|customer}}}!</p><p>We set aside discounts of up to 40% for everyone on the {{{contact.plan}}} plan.</p><p><a href="https://acmestore.com/black-friday">See the deals</a></p><p><a href="{{{POSTAL_UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/broadcasts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Black Friday: early access",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"from": "Acme Store <[email protected]>",
"reply_to": "[email protected]",
"subject": "{{{FIRST_NAME|Customer}}}, Black Friday starts early for you",
"preheader": "Up to 40% off starting Thursday, for customers only.",
"language": "en",
"html": "<p>Hi, {{{FIRST_NAME|customer}}}!</p><p>We set aside discounts of up to 40% for everyone on the {{{contact.plan}}} plan.</p><p><a href=\"https://acmestore.com/black-friday\">See the deals</a></p><p><a href=\"{{{POSTAL_UNSUBSCRIBE_URL}}}\">Unsubscribe</a></p>",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('broadcasts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Black Friday: early access',
'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
'topic_id' => '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
'from' => 'Acme Store <[email protected]>',
'reply_to' => '[email protected]',
'subject' => '{{{FIRST_NAME|Customer}}}, Black Friday starts early for you',
'preheader' => 'Up to 40% off starting Thursday, for customers only.',
'language' => 'en',
'html' => '<p>Hi, {{{FIRST_NAME|customer}}}!</p><p>We set aside discounts of up to 40% for everyone on the {{{contact.plan}}} plan.</p><p><a href="https://acmestore.com/black-friday">See the deals</a></p><p><a href="{{{POSTAL_UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast",
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b"
}
#Get a broadcast
GET/broadcasts/{id}
Returns the broadcast with its content and statistics. In stats, each recipient counts once per metric (the first open, the first click…): sent, delivered, opened, clicked, bounced, complained, unsubscribed and failed (which includes suppressed recipients). recipients_count is the size of the audience when the send started, and processed_count is how many emails have been created so far. On a paused or failed broadcast, error has code, message and, when it resumes on its own, resumes_at.
Path parameters
idstringrequiredThe broadcast ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast",
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"name": "Black Friday: early access",
"status": "sent",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"segment": {
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers"
},
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"topic": {
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "News"
},
"template_id": null,
"from": "Acme Store <[email protected]>",
"reply_to": ["[email protected]"],
"detect_replies": false,
"subject": "{{{FIRST_NAME|Customer}}}, Black Friday starts early for you",
"preheader": "Up to 40% off starting Thursday, for customers only.",
"language": null,
"html": "<p>Hi, {{{FIRST_NAME|customer}}}!</p><p>We set aside discounts of up to 40% for everyone on the {{{contact.plan}}} plan.</p><p><a href=\"https://acmestore.com/black-friday\">See the deals</a></p><p><a href=\"{{{POSTAL_UNSUBSCRIBE_URL}}}\">Unsubscribe</a></p>",
"text": null,
"variables": {},
"editor": null,
"scheduled_at": null,
"schedule_timezone": null,
"queued_at": "2026-10-22T12:00:00.000000Z",
"sent_at": "2026-10-22T12:04:31.000000Z",
"canceled_at": null,
"recipients_count": 1247,
"processed_count": 1247,
"stats": {
"sent": 1247,
"delivered": 1236,
"opened": 612,
"clicked": 188,
"bounced": 6,
"complained": 1,
"unsubscribed": 9,
"failed": 0
},
"error": null,
"created_at": "2026-10-21T18:40:00.000000Z",
"updated_at": "2026-10-22T12:04:31.000000Z"
}
#Update a broadcast
PATCH/broadcasts/{id}
Only drafts and scheduled broadcasts can change; the fields are the same as in creation, all optional, and null clears a field. On a scheduled broadcast, Postal checks again that it is still ready to send.
Path parameters
idstringrequiredThe broadcast ID.
Errors
- 422
validation_errorInvalid field or, on a scheduled broadcast, something that sending requires is missing. - 409
invalid_broadcast_statusThe broadcast is already queued, sending, sent, paused, canceled or failed. - 404
not_foundThe broadcast, the segment, the topic or the template does not exist.
cURL
curl -X PATCH https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": "{{{FIRST_NAME|Customer}}}, 3 days to go until Black Friday"
}'
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subject: '{{{FIRST_NAME|Customer}}}, 3 days to go until Black Friday',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"subject": "{{{FIRST_NAME|Customer}}}, 3 days to go until Black Friday",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'subject' => '{{{FIRST_NAME|Customer}}}, 3 days to go until Black Friday',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast",
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b"
}
#Delete a broadcast
DELETE/broadcasts/{id}
Only drafts and scheduled broadcasts. To stop a send in progress, cancel it.
Path parameters
idstringrequiredThe broadcast ID.
Errors
- 409
invalid_broadcast_statusThe broadcast is not in a status that allows this action. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast",
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"deleted": true
}
#Calculate the audience
GET/broadcasts/audience
How many contacts a broadcast to the segment (and the topic) would reach right now. contacts is the total in the segment; recipients, who actually receives it; quota_remaining, how many emails the sending quota still allows (null: no limit). If recipients exceeds the quota, the send pauses midway and continues when the quota renews.
cycle_recipients says what the send would do to the contacts that received campaigns in the cycle: new (how many of these contacts have not received a campaign in the cycle yet), used, limit, remaining, fits (false: the send would be refused with 422 cycle_recipients_limit), billed_above_limit and resets_at. With scheduled_at, it counts in the cycle of that date.
Query parameters
segment_idstringrequiredThe segment ID.topic_idstringThe topic ID.scheduled_atstringDate and time of a scheduled send (ISO 8601), to work outcycle_recipientsin its cycle.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe segment or the topic does not exist.
cURL
curl 'https://api.postal.so/broadcasts/audience?segment_id=019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a&topic_id=019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/broadcasts/audience?segment_id=019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a&topic_id=019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/broadcasts/audience",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('broadcasts/audience', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'segment_id' => '019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a',
'topic_id' => '019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast_audience",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"contacts": 1284,
"recipients": 1247,
"quota_remaining": 18793,
"cycle_recipients": {
"new": 412,
"used": 2870,
"limit": 5000,
"remaining": 2130,
"fits": true,
"billed_above_limit": false,
"resets_at": "2026-11-25T14:00:00Z"
}
}
#Preview
GET/broadcasts/{id}/preview
The broadcast as a contact will receive it. Without contact_id, the variables use their default values and the unsubscribe link becomes #. It works with incomplete drafts and shows the draft of a template that is not published yet. The automatic unsubscribe footer, if there is one, comes in the language that would apply to that contact in a send (rules).
Path parameters
idstringrequiredThe broadcast ID.
Query parameters
contact_idstringID or email of a contact to personalize with.
Errors
- 404
not_foundThe broadcast or the contact does not exist.
cURL
curl 'https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/preview?contact_id=jane%40example.com' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/preview?contact_id=jane%40example.com', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/preview",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"contact_id": "[email protected]",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/preview', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'contact_id' => '[email protected]',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast_preview",
"broadcast_id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"subject": "Jane, Black Friday starts early for you",
"preheader": "Up to 40% off starting Thursday, for customers only.",
"html": "<div style=\"display:none;…\">Up to 40% off starting Thursday, for customers only.…</div><p>Hi, Jane!</p><p>We set aside discounts of up to 40% for everyone on the pro plan.</p><p><a href=\"https://acmestore.com/black-friday\">See the deals</a></p><p><a href=\"https://forms.postal.so/preferencias/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f?signature=8b2e…\">Unsubscribe</a></p>",
"text": "Hi, Jane!\n\nWe set aside discounts of up to 40% for everyone on the pro plan.\n\nSee the deals (https://acmestore.com/black-friday)\n\nUnsubscribe (https://forms.postal.so/preferencias/019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f?signature=8b2e…)"
}
#Send a test
POST/broadcasts/{id}/test
Sends the broadcast as it is now to up to 5 addresses, with [Test] before the subject ([Teste] if the request sends Accept-Language: pt-BR) and the broadcast_test tag. Personalization uses the contact from contact_id or, without it, the contact with the first address in to (if one exists). The automatic unsubscribe footer follows the language of the broadcast or the contact, as in a real send (see Footer and page language). It needs a sender on a verified domain, a subject and content, but not a segment. It counts toward the sending quota, not toward the statistics.
Path parameters
idstringrequiredThe broadcast ID.
Request body
tostring[]required1 to 5 addresses.contact_idstringID or email of the contact used for personalization.
Errors
- 422
validation_errorMore than 5 addresses, an invalid address, or a missing subject, content or template variable value. - 403
validation_errorThe sender's domain is not verified. - 403
sending_pausedThe team's sending is paused. - 404
not_foundThe broadcast or the contact does not exist. - 429
daily_quota_exceededThe sending quota ran out (ormonthly_quota_exceeded).
cURL
curl -X POST https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/test \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["[email protected]"],
"contact_id": "[email protected]"
}'
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/test', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: ['[email protected]'],
contact_id: '[email protected]',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/test",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"to": ["[email protected]"],
"contact_id": "[email protected]",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/test', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'to' => ['[email protected]'],
'contact_id' => '[email protected]',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "email",
"id": "019a1f02-4e5f-7a61-8c7d-8e9f0a1b2c3d"
}
#Send or schedule
POST/broadcasts/{id}/send
Without a body, it sends now (queued, then sending). With scheduled_at, it schedules the send (scheduled). The same endpoint reschedules or brings forward a scheduled broadcast and resumes a paused one. It returns the full broadcast.
Before accepting, Postal checks the segment, the sender (verified domain), the subject, the content and the template variables, and that there is at least one recipient. On an immediate send, it also checks the sending quota; on a scheduled one, it only checks that the team's sending is not paused.
It also checks the contacts that received campaigns in the cycle (the cycle of scheduled_at, when scheduling): if the broadcast's contacts that have received nothing in the cycle would go over the contact limit, the whole broadcast is refused with 422 cycle_recipients_limit and nothing is sent. When it goes out, Postal checks again; if it no longer fits, it becomes paused with error.code cycle_recipients_limit and the numbers in error.cycle_recipients: with nothing sent, it waits to be sent again or canceled; midway, the rest goes out by itself when the cycle renews.
Path parameters
idstringrequiredThe broadcast ID.
Request body (optional)
scheduled_atstringDate and time of the send, in ISO 8601 with a time zone, such as2026-10-28T09:00:00-03:00. In the future and up to 30 days ahead.schedule_timezonestringIANA time zone the time was picked in, such asAmerica/Sao_Paulo. It only serves to show the time as it was chosen (it comes back inschedule_timezone); it does not change when the send goes out.
Errors
- 422
validation_errorThe segment, sender, subject, content or a template variable is missing; the template is not published; no contact in the segment can receive it; orscheduled_atis in the past or more than 30 days ahead. - 403
validation_errorThe sender's domain is not verified. - 403
plan_limit_reachedThe plan does not allow more broadcasts this month. - 422
cycle_recipients_limitThe broadcast's contacts that have not received a campaign in the cycle would go over the plan's contact limit. Nothing is sent;cycle_recipientshasnew,used,limit,remainingandresets_at. - 403
sending_pausedThe team's sending is paused. - 403
paused_by_planThe broadcast's segment is paused because it is above the plan limit. Upgrade your plan or delete other segments. - 409
invalid_broadcast_statusThe broadcast is already queued, sending, sent, canceled or failed. - 429
daily_quota_exceededOn an immediate send, the sending quota ran out (ormonthly_quota_exceeded). - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/send \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scheduled_at": "2026-10-28T09:00:00-03:00",
"schedule_timezone": "America/Sao_Paulo"
}'
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
scheduled_at: '2026-10-28T09:00:00-03:00',
schedule_timezone: 'America/Sao_Paulo',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/send",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"scheduled_at": "2026-10-28T09:00:00-03:00",
"schedule_timezone": "America/Sao_Paulo",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/send', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'scheduled_at' => '2026-10-28T09:00:00-03:00',
'schedule_timezone' => 'America/Sao_Paulo',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast",
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"name": "Black Friday: early access",
"status": "scheduled",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"segment": {
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers"
},
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"topic": {
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "News"
},
"template_id": null,
"from": "Acme Store <[email protected]>",
"reply_to": ["[email protected]"],
"detect_replies": false,
"subject": "{{{FIRST_NAME|Customer}}}, Black Friday starts early for you",
"preheader": "Up to 40% off starting Thursday, for customers only.",
"language": null,
"html": "<p>Hi, {{{FIRST_NAME|customer}}}!</p><p>We set aside discounts of up to 40% for everyone on the {{{contact.plan}}} plan.</p><p><a href=\"https://acmestore.com/black-friday\">See the deals</a></p><p><a href=\"{{{POSTAL_UNSUBSCRIBE_URL}}}\">Unsubscribe</a></p>",
"text": null,
"variables": {},
"editor": null,
"scheduled_at": "2026-10-28T12:00:00.000000Z",
"schedule_timezone": "America/Sao_Paulo",
"queued_at": null,
"sent_at": null,
"canceled_at": null,
"recipients_count": 0,
"processed_count": 0,
"stats": {
"sent": 0,
"delivered": 0,
"opened": 0,
"clicked": 0,
"bounced": 0,
"complained": 0,
"unsubscribed": 0,
"failed": 0
},
"error": null,
"created_at": "2026-10-21T18:40:00.000000Z",
"updated_at": "2026-10-24T16:10:00.000000Z"
}
#Cancel the send
POST/broadcasts/{id}/cancel
Stops a scheduled, queued, sending or paused broadcast. Emails that had not gone out yet get the status canceled; the ones that already went out cannot be recalled. It returns the full broadcast.
Path parameters
idstringrequiredThe broadcast ID.
Errors
- 409
invalid_broadcast_statusThe broadcast is a draft or has already finished. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/cancel \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/cancel', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/cancel",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/cancel', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast",
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"name": "Black Friday: early access",
"status": "canceled",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"segment": {
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Active customers"
},
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"topic": {
"id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"name": "News"
},
"template_id": null,
"from": "Acme Store <[email protected]>",
"reply_to": ["[email protected]"],
"detect_replies": false,
"subject": "{{{FIRST_NAME|Customer}}}, Black Friday starts early for you",
"preheader": "Up to 40% off starting Thursday, for customers only.",
"language": null,
"html": "<p>Hi, {{{FIRST_NAME|customer}}}!</p><p>We set aside discounts of up to 40% for everyone on the {{{contact.plan}}} plan.</p><p><a href=\"https://acmestore.com/black-friday\">See the deals</a></p><p><a href=\"{{{POSTAL_UNSUBSCRIBE_URL}}}\">Unsubscribe</a></p>",
"text": null,
"variables": {},
"editor": null,
"scheduled_at": null,
"schedule_timezone": null,
"queued_at": "2026-10-22T12:00:00.000000Z",
"sent_at": null,
"canceled_at": "2026-10-22T12:02:10.000000Z",
"recipients_count": 1247,
"processed_count": 500,
"stats": {
"sent": 488,
"delivered": 471,
"opened": 96,
"clicked": 21,
"bounced": 2,
"complained": 0,
"unsubscribed": 1,
"failed": 0
},
"error": null,
"created_at": "2026-10-21T18:40:00.000000Z",
"updated_at": "2026-10-22T12:02:10.000000Z"
}
#Duplicate
POST/broadcasts/{id}/duplicate
Creates a draft with the same content, language (language), segment, topic and sender, with “(copy)” in the name (“(cópia)” if the request sends Accept-Language: pt-BR). It works for any status, including to reuse a broadcast that was already sent.
Path parameters
idstringrequiredThe broadcast ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/duplicate \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/duplicate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/duplicate",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/duplicate', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "broadcast",
"id": "019a1f05-3d4e-7f50-9b6c-7d8e9f0a1b2c"
}
#List recipients
GET/broadcasts/{id}/recipients
Each recipient with the status of the email they received (the latest event: queued, sent, delivered, opened, clicked, bounced, complained, failed, suppressed, canceled…) and whether they unsubscribed through this broadcast. id and email_id are the email ID, to use in GET /emails/{id}.
Path parameters
idstringrequiredThe broadcast ID.
Query parameters
limitinteger1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.statusstringAn email status, orunsubscribedfor people who unsubscribed.searchstringSearches the address.
Errors
- 422
validation_errorUnknown status. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl 'https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/recipients?status=clicked' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/recipients?status=clicked', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/recipients",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"status": "clicked",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('broadcasts/019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b/recipients', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'status' => 'clicked',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": true,
"data": [
{
"object": "broadcast_recipient",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"status": "clicked",
"unsubscribed": false,
"error": null,
"sent_at": "2026-10-22T12:00:04.000000Z",
"created_at": "2026-10-22T12:00:02.000000Z"
}
]
}
#Metrics and webhooks
Besides stats, the email metrics accept the broadcast dimension and the broadcast_id filter, to compare broadcasts over a period, with open, click and unsubscribe rates:
GET /emails/metrics?dimensions=broadcast&metrics=delivered,open_rate,click_rate,unsubscribe_rate&start_date=2026-10-01
{
"object": "metrics",
"start_date": "2026-10-01T00:00:00.000Z",
"end_date": "2026-10-24T16:10:00.000Z",
"metrics": [
"delivered",
"open_rate",
"click_rate",
"unsubscribe_rate"
],
"dimensions": ["broadcast"],
"granularity": "daily",
"totals": {
"delivered": 1236,
"open_rate": 0.4951,
"click_rate": 0.1521,
"unsubscribe_rate": 0.0073
},
"data": [
{
"broadcast_id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"broadcast_name": "Black Friday: early access",
"delivered": 1236,
"open_rate": 0.4951,
"click_rate": 0.1521,
"unsubscribe_rate": 0.0073
}
]
}
When the last recipient is processed, Postal triggers the broadcast.sent webhook. The events of each email (email.delivered, email.opened…) carry "tags": {"broadcast_id": "…"}.
The number of broadcasts per month can be limited on your marketing plan (see Limits and broadcasts in GET /usage). Broadcasts that were sent, are sending or are scheduled in the month (in UTC) count.