Sign in Create account
Marketing

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

  1. Create the broadcast as a draft (POST /broadcasts). Only the name is required.
  2. Check how many contacts it reaches (GET /broadcasts/audience), look at the personalized preview and send a test.
  3. 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.
  4. 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

StatusWhat it meansWhat you can do
draftDraft. It can be incomplete; what sending requires is checked when you send.Edit, delete, send, schedule
scheduledScheduled 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
queuedIn the queue, waiting to start sending.Cancel
sendingSending, in batches of up to 500 contacts. processed_count shows the progress.Cancel
pausedStopped 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
sentAll recipients were processed. Triggers the broadcast.sent webhook.Duplicate
canceledCanceled. Anyone who had not received it yet will not receive it.Duplicate
failedCould 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:

Subject
{{{FIRST_NAME|Customer}}}, Black Friday starts early for you
VariableValue
{{{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-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers 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.unsubscribed and triggers contact.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:

  1. the broadcast's language (pt-BR or en);
  2. the contact's language property, such as pt-BR or en-US (the language before the hyphen counts; other languages are ignored);
  3. the contact's locale property, in the same way;
  4. the language the team owner chose in the dashboard;
  5. 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.replied webhook, with reply.from, reply.subject, reply.preview and reply.auto.
  • Automatic replies (out of office, auto-responders, delivery reports) come with reply.auto: true and 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

Full-access key

From newest to oldest. The list returns the summary, without html, text, variables and editor.

Query parameters

  • limitinteger
    1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • searchstring
    Searches the name and the subject.
  • statusstring
    One or more statuses separated by commas, such as scheduled,sending.
GET /broadcasts

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);
200Response
{
  "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

Full-access key

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

  • namestringrequired
    Internal name, up to 255 characters. Required on creation.
  • segment_idstring | null
    The segment that receives the broadcast. Required to send.
  • topic_idstring | null
    Topic: only people subscribed to it receive the broadcast, and a click on “Unsubscribe” removes the contact from this topic only.
  • fromstring | null
    Sender, such as Acme Store <[email protected]>, on a verified domain of the team. Required to send.
  • reply_tostring | string[] | null
    Reply-to addresses.
  • detect_repliesboolean
    Default: false. With true, each email gets a reply address of its own and Postal records and forwards the replies. See Reply detection.
  • subjectstring | null
    Subject, up to 998 characters, with variables. Required to send, unless the template has a subject.
  • preheaderstring | null
    Preview text, up to 500 characters.
  • languagestring | null
    Language of what Postal writes in the emails of this broadcast: pt-BR or en. 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 | null
    HTML body, up to 2 MB. To send, you need html, text or template_id.
  • textstring | null
    Plain-text version, up to 2 MB.
  • template_idstring | null
    ID or alias of a template. The content comes from the published version (instead of html and text).
  • variablesobject | null
    Values for the template variables, such as {"COUPON": "BF40"}.
  • editorobject | null
    State of the dashboard's visual editor. Store it and send it back as it came.

Errors

  • 422validation_error
    Invalid field, such as an address not in the Name <[email protected]> format or an ID that is not a UUID.
  • 404not_found
    The segment, the topic or the template does not exist.
POST /broadcasts

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);
201Response
{
  "object": "broadcast",
  "id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b"
}

#Get a broadcast

GET/broadcasts/{id}

Full-access key

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

  • idstringrequired
    The broadcast ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /broadcasts/{id}

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    The broadcast ID.

Errors

  • 422validation_error
    Invalid field or, on a scheduled broadcast, something that sending requires is missing.
  • 409invalid_broadcast_status
    The broadcast is already queued, sending, sent, paused, canceled or failed.
  • 404not_found
    The broadcast, the segment, the topic or the template does not exist.
PATCH /broadcasts/{id}

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);
200Response
{
  "object": "broadcast",
  "id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b"
}

#Delete a broadcast

DELETE/broadcasts/{id}

Full-access key

Only drafts and scheduled broadcasts. To stop a send in progress, cancel it.

Path parameters

  • idstringrequired
    The broadcast ID.

Errors

  • 409invalid_broadcast_status
    The broadcast is not in a status that allows this action.
  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /broadcasts/{id}

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);
200Response
{
  "object": "broadcast",
  "id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
  "deleted": true
}

#Calculate the audience

GET/broadcasts/audience

Full-access key

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_idstringrequired
    The segment ID.
  • topic_idstring
    The topic ID.
  • scheduled_atstring
    Date and time of a scheduled send (ISO 8601), to work out cycle_recipients in its cycle.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 404not_found
    The segment or the topic does not exist.
GET /broadcasts/audience

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);
200Response
{
  "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

Full-access key

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

  • idstringrequired
    The broadcast ID.

Query parameters

  • contact_idstring
    ID or email of a contact to personalize with.

Errors

  • 404not_found
    The broadcast or the contact does not exist.
GET /broadcasts/{id}/preview

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);
200Response
{
  "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

Full-access key

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

  • idstringrequired
    The broadcast ID.

Request body

  • tostring[]required
    1 to 5 addresses.
  • contact_idstring
    ID or email of the contact used for personalization.

Errors

  • 422validation_error
    More than 5 addresses, an invalid address, or a missing subject, content or template variable value.
  • 403validation_error
    The sender's domain is not verified.
  • 403sending_paused
    The team's sending is paused.
  • 404not_found
    The broadcast or the contact does not exist.
  • 429daily_quota_exceeded
    The sending quota ran out (or monthly_quota_exceeded).
POST /broadcasts/{id}/test

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);
200Response
{
  "object": "email",
  "id": "019a1f02-4e5f-7a61-8c7d-8e9f0a1b2c3d"
}

#Send or schedule

POST/broadcasts/{id}/send

Full-access key

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

  • idstringrequired
    The broadcast ID.

Request body (optional)

  • scheduled_atstring
    Date and time of the send, in ISO 8601 with a time zone, such as 2026-10-28T09:00:00-03:00. In the future and up to 30 days ahead.
  • schedule_timezonestring
    IANA time zone the time was picked in, such as America/Sao_Paulo. It only serves to show the time as it was chosen (it comes back in schedule_timezone); it does not change when the send goes out.

Errors

  • 422validation_error
    The segment, sender, subject, content or a template variable is missing; the template is not published; no contact in the segment can receive it; or scheduled_at is in the past or more than 30 days ahead.
  • 403validation_error
    The sender's domain is not verified.
  • 403plan_limit_reached
    The plan does not allow more broadcasts this month.
  • 422cycle_recipients_limit
    The broadcast's contacts that have not received a campaign in the cycle would go over the plan's contact limit. Nothing is sent; cycle_recipients has new, used, limit, remaining and resets_at.
  • 403sending_paused
    The team's sending is paused.
  • 403paused_by_plan
    The broadcast's segment is paused because it is above the plan limit. Upgrade your plan or delete other segments.
  • 409invalid_broadcast_status
    The broadcast is already queued, sending, sent, canceled or failed.
  • 429daily_quota_exceeded
    On an immediate send, the sending quota ran out (or monthly_quota_exceeded).
  • 404not_found
    The resource does not exist or belongs to another team.
POST /broadcasts/{id}/send

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);
200Response
{
  "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

Full-access key

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

  • idstringrequired
    The broadcast ID.

Errors

  • 409invalid_broadcast_status
    The broadcast is a draft or has already finished.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /broadcasts/{id}/cancel

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);
200Response
{
  "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

Full-access key

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

  • idstringrequired
    The broadcast ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
POST /broadcasts/{id}/duplicate

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);
201Response
{
  "object": "broadcast",
  "id": "019a1f05-3d4e-7f50-9b6c-7d8e9f0a1b2c"
}

#List recipients

GET/broadcasts/{id}/recipients

Full-access key

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

  • idstringrequired
    The broadcast ID.

Query parameters

  • limitinteger
    1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • statusstring
    An email status, or unsubscribed for people who unsubscribed.
  • searchstring
    Searches the address.

Errors

  • 422validation_error
    Unknown status.
  • 404not_found
    The resource does not exist or belongs to another team.
GET /broadcasts/{id}/recipients

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);
200Response
{
  "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:

Request
GET /emails/metrics?dimensions=broadcast&metrics=delivered,open_rate,click_rate,unsubscribe_rate&start_date=2026-10-01
200Response
{
  "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.