Hoppa till innehållet

Sending

Broadcasts

A broadcast sends the same content to many people who asked for it, such as a newsletter or a notice to every customer. You send the content once, with a list of messages. Sendora gives every recipient a copy with their own unsubscribe link, and tells you how far it has come.

A broadcast goes on a broadcast stream. Create one with kind: "broadcast" before your first broadcast, as Streams shows.

Send a broadcast

POST /v1/broadcasts
curl -X POST https://api.sendora.se/v1/broadcasts \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Idempotency-Key: newsletter-2026-10" \
  -H "Content-Type: application/json" \
  -d '{
  "streamId": "a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "from": {
    "email": "news@example.se",
    "name": "Example AB"
  },
  "subject": "News from Example in October",
  "text": "Hi!\n\nHere is what happened in October.\n\nNo more of these? {{ unsubscribe_url }}",
  "html": "<p>Hi!</p><p>Here is what happened in October.</p><p><a href=\"{{ unsubscribe_url }}\">Unsubscribe</a></p>",
  "tag": "newsletter-2026-10",
  "messages": [
    {
      "to": [
        "anna@example.com"
      ],
      "metadata": {
        "customer": "1042"
      }
    },
    {
      "to": [
        {
          "email": "bo@example.com",
          "name": "Bo Berg"
        }
      ]
    }
  ]
}'
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const { broadcastId, total } =
  await sendora.broadcasts.send(
    {
      streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
      from: {
        email: 'news@example.se',
        name: 'Example AB',
      },
      subject: 'News from Example in October',
      text: 'Hi!\n\nHere is what happened in October.\n\nNo more of these? {{ unsubscribe_url }}',
      html: '<p>Hi!</p><p>Here is what happened in October.</p><p><a href="{{ unsubscribe_url }}">Unsubscribe</a></p>',
      tag: 'newsletter-2026-10',
      messages: [
        {
          to: ['anna@example.com'],
          metadata: { customer: '1042' },
        },
        {
          to: [
            { email: 'bo@example.com', name: 'Bo Berg' },
          ],
        },
      ],
    },
    { idempotencyKey: 'newsletter-2026-10' },
  );

console.log(broadcastId, total);
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

accepted = sendora.broadcasts.send(
    stream_id="a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    from_={
        "email": "news@example.se",
        "name": "Example AB",
    },
    subject="News from Example in October",
    text=(
        "Hi!\n\n"
        "Here is what happened in October.\n\n"
        "No more of these? {{ unsubscribe_url }}"
    ),
    html=(
        "<p>Hi!</p>"
        "<p>Here is what happened in October.</p>"
        '<p><a href="{{ unsubscribe_url }}">'
        "Unsubscribe</a></p>"
    ),
    tag="newsletter-2026-10",
    messages=[
        {
            "to": ["anna@example.com"],
            "metadata": {"customer": "1042"},
        },
        {
            "to": [
                {
                    "email": "bo@example.com",
                    "name": "Bo Berg",
                }
            ]
        },
    ],
    idempotency_key="newsletter-2026-10",
)

print(accepted.broadcast_id, accepted.total)

Response

{
  "broadcastId": "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01",
  "status": "accepted",
  "total": 2,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:00:00.000Z",
  "test": false
}

Give the content once: from, subject, text, html, headers, attachments, tag and metadata. Each entry of messages is one message with its own to, cc and bcc. An entry may also set metadata and headers, which are laid over the broadcast’s.

Limits gives the most messages and the largest size a broadcast may have. Each message in it has the size and recipient limits of a single send. Send a longer list as several broadcasts under one tag.

The Idempotency-Key header is required, and a request without it fails with idempotency_key_required. A retry with the same key returns the same broadcast and never sends it twice. Choose the key yourself, such as the newsletter’s name, so that a retry from another process uses it too.

Pass the key as idempotencyKey, in the second argument to sendora.broadcasts.send. Without it, the SDK makes up a random key, which covers only its own retries. A refused broadcast throws SendoraError with its code.

Pass the key as idempotency_key to sendora.broadcasts.send. Without it, the SDK makes up a random key, which covers only its own retries. A refused broadcast raises SendoraError with its code.

Rules of a broadcast stream

These rules apply to every message on a broadcast stream, whether it goes in a broadcast or alone.

  • Send only to people who asked for the mail, as the acceptable use rules say.
  • Write {{ unsubscribe_url }} in the text and in the HTML, where the link should go. A message without it in every part fails with unsubscribe_placeholder_missing.
  • Sendora sends each recipient a copy addressed to them alone, with their own link. A message with several addresses gives each address its own copy, so the recipients never see each other. Sendora also sets the List-Unsubscribe headers that mail clients show as a button. A List-Unsubscribe header of your own fails with list_unsubscribe_reserved.
  • A recipient who unsubscribes goes on the stream’s suppression list, and your webhook gets the event unsubscribed.

If complaints pass the rate on Limits, Sendora pauses the stream. Every send on it then fails with stream_paused, and the stream shows pausedAt. Support goes through your mailing list with you, then resumes the stream.

What is dropped and what is refused

  • An address on the stream’s suppression list is dropped and counted in suppressed, and the rest are still sent. A message with no address left is not stored. If no address is left at all, the broadcast fails with recipient_suppressed.
  • Every address left counts against the monthly cap when the broadcast is accepted. If it would pass the cap, it fails with monthly_cap_reached and nothing is stored. The per-minute limits do not apply to a broadcast.
  • A broadcast on a transactional or an inbound stream fails with stream_not_broadcast, and on an archived one with stream_archived.

Personalise

POST /v1/broadcasts
curl -X POST https://api.sendora.se/v1/broadcasts \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Idempotency-Key: welcome-2026-10" \
  -H "Content-Type: application/json" \
  -d '{
  "streamId": "a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "from": {
    "email": "news@example.se",
    "name": "Example AB"
  },
  "subject": "{{ first_name }}, your October news",
  "text": "Hi {{ first_name }}!\n\nYou have {{ points }} points to spend before the end of the year.\n\nNo more of these? {{ unsubscribe_url }}",
  "messages": [
    {
      "to": [
        "anna@example.com"
      ],
      "substitutions": {
        "first_name": "Anna",
        "points": "120"
      }
    },
    {
      "to": [
        "bo@example.com"
      ],
      "substitutions": {
        "first_name": "Bo",
        "points": "35"
      }
    }
  ]
}'
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const { broadcastId } = await sendora.broadcasts.send(
  {
    streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
    from: {
      email: 'news@example.se',
      name: 'Example AB',
    },
    subject: '{{ first_name }}, your October news',
    text: 'Hi {{ first_name }}!\n\nYou have {{ points }} points to spend before the end of the year.\n\nNo more of these? {{ unsubscribe_url }}',
    messages: [
      {
        to: ['anna@example.com'],
        substitutions: {
          first_name: 'Anna',
          points: '120',
        },
      },
      {
        to: ['bo@example.com'],
        substitutions: { first_name: 'Bo', points: '35' },
      },
    ],
  },
  { idempotencyKey: 'welcome-2026-10' },
);

console.log(broadcastId);
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

accepted = sendora.broadcasts.send(
    stream_id="a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    from_={
        "email": "news@example.se",
        "name": "Example AB",
    },
    subject="{{ first_name }}, your October news",
    text=(
        "Hi {{ first_name }}!\n\n"
        "You have {{ points }} points to spend "
        "before the end of the year.\n\n"
        "No more of these? {{ unsubscribe_url }}"
    ),
    messages=[
        {
            "to": ["anna@example.com"],
            "substitutions": {
                "first_name": "Anna",
                "points": "120",
            },
        },
        {
            "to": ["bo@example.com"],
            "substitutions": {
                "first_name": "Bo",
                "points": "35",
            },
        },
    ],
    idempotency_key="welcome-2026-10",
)

print(accepted.broadcast_id)

Response

{
  "broadcastId": "9b7e5d3c-1a2f-4e6b-8c0d-2f4a6b8c0d1e",
  "status": "accepted",
  "total": 2,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:10:00.000Z",
  "test": false
}

Write {{ key }} in the subject, the text or the HTML, and give each message its values in substitutions. A key is letters, digits and underscores, and every value is a string. Limits gives how many values a message may have, and how long each may be.

Sendora fills in each message before it adds the unsubscribe link, and escapes the values in the HTML. The log shows the filled-in subject. If the content names a key that a message lacks, the whole broadcast fails with substitution_missing. The error names the message’s position and the missing keys.

Nothing else is rendered: there are no conditions, loops or filters.

Follow a broadcast

GET /v1/broadcasts/{broadcastId}
curl https://api.sendora.se/v1/broadcasts/{broadcastId} \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const broadcast = await sendora.broadcasts.get(
  '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',
);

console.log(
  broadcast.status,
  broadcast.released,
  broadcast.total,
);
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

broadcast = sendora.broadcasts.get(
    "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01"
)

print(
    broadcast.status, broadcast.released, broadcast.total
)

Response

{
  "broadcastId": "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01",
  "streamId": "a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "status": "processing",
  "from": "news@example.se",
  "subject": "News from Example in October",
  "tag": "newsletter-2026-10",
  "metadata": {},
  "total": 2,
  "released": 1,
  "failed": 0,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:00:00.000Z",
  "completedAt": null
}
  • total: the messages stored.
  • released: the messages handed to the mail server so far. Sendora releases them at the pace on Limits. Divide the messages by that pace for the minutes a broadcast takes.
  • failed: the messages that never reached the mail server, cancelled ones included. A bounce is not counted here. It shows on its message.
  • status: accepted, then processing, then completed once nothing is left to send. A cancelled broadcast is cancelled. completedAt is set when it ends.

Each message of a broadcast is a message in the log, with its own timeline and the broadcastId. The search filters by broadcastId, and every webhook event about such a message includes it. Follow deliveries, bounces, complaints and unsubscribes there, as for any other message.

List the broadcasts returns them newest first. In the dashboard, a broadcast stream’s page lists its broadcasts. Each broadcast’s page shows its counts, and a cancel button while it runs.

Cancel a broadcast

POST /v1/broadcasts/{broadcastId}/cancel
curl -X POST https://api.sendora.se/v1/broadcasts/{broadcastId}/cancel \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
import { Sendora } from '@sendora/sdk';

const sendora = new Sendora({
  token: process.env.SENDORA_API_TOKEN,
});

const cancelled = await sendora.broadcasts.cancel(
  '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',
);

console.log(cancelled.status, cancelled.failed);
import os

from sendora import Sendora

sendora = Sendora(os.environ["SENDORA_API_TOKEN"])

cancelled = sendora.broadcasts.cancel(
    "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01"
)

print(cancelled.status, cancelled.failed)

Response

{
  "broadcastId": "2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01",
  "streamId": "a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "status": "cancelled",
  "from": "news@example.se",
  "subject": "News from Example in October",
  "tag": "newsletter-2026-10",
  "metadata": {},
  "total": 2,
  "released": 1,
  "failed": 1,
  "suppressed": 0,
  "submittedAt": "2026-10-01T08:00:00.000Z",
  "completedAt": "2026-10-01T08:05:00.000Z"
}

A cancel stops every message that is not yet handed to the mail server. Those count as failed, and the broadcast becomes cancelled with completedAt set. A message already on its way is still delivered. Cancelling a completed or cancelled broadcast fails with broadcast_not_open.