# Streams

Keep kinds of mail apart, each with its own suppression list.

Every message goes on a stream of the server that sends it. A server is one part of your account, with its own keys, streams and message log. A stream keeps one kind of mail apart from the rest. Each sending stream has its own suppression list, so a recipient who bounced or unsubscribed on one stream still gets mail on the others.

Every server starts with a default transactional stream. A message that names no stream goes there, so you can send without creating one.

## Three kinds

- `transactional`: mail one person expects because of something they did or have with you, such as a receipt, an invoice, a sign-in link or a case notice.
- `broadcast`: the same message to many people who asked for it, such as a newsletter or service information to every customer. Every message on it follows the [rules of a broadcast stream](https://sendora.se/docs/broadcasts#rules-of-a-broadcast-stream), the unsubscribe link among them.
- `inbound`: mail the server receives, as [Inbound](https://sendora.se/docs/inbound) explains. It sends nothing, and it has no suppression list.

Any account can create transactional and broadcast streams. An inbound stream needs an approved account.

Add a sending stream for each kind of mail that a recipient might stop on its own. Put a newsletter on a broadcast stream of its own, so that an unsubscribe from it never stops your receipts. Don't add a stream per customer or per message. A municipality might keep `Notices` for case notices and `Receipts` for payment receipts, each with its own list of addresses that bounced.

## Create a stream

cURL:

```bash
curl -X POST https://api.sendora.se/v1/streams \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "kind": "transactional",
  "name": "Notices"
}'
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

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

const stream = await sendora.streams.create({
  kind: 'transactional',
  name: 'Notices',
});

console.log(stream.streamId);
```

Python:

```python
import os

from sendora import Sendora

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

stream = sendora.streams.create(
    kind="transactional", name="Notices"
)

print(stream.stream_id)
```

Response:

```json
{
  "streamId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "kind": "transactional",
  "name": "Notices",
  "isDefault": false,
  "createdAt": "2026-09-16T12:00:00.000Z",
  "archivedAt": null,
  "pausedAt": null,
  "inboundAddress": null,
  "contentRetentionDays": null
}
```

The name is yours, and it must be unique within the server, archived streams included. A taken name fails with `stream_exists`, which gives the id of the stream that has it. The `streamId` never changes. A new sending stream's [suppression list](https://sendora.se/docs/suppressions) is empty, and you can limit a [webhook](https://sendora.se/docs/webhooks) to the stream.

## Send on a stream

Set `streamId` on the message. Without it, the message goes on the default stream. Over [SMTP](https://sendora.se/docs/smtp), set the header `X-Sendora-Stream` to the id instead.

cURL:

```bash
curl -X POST https://api.sendora.se/v1/email \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "from": {
    "email": "no-reply@example.se",
    "name": "Example AB"
  },
  "streamId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "to": [
    "anna@example.com"
  ],
  "subject": "Your case has a new message",
  "text": "Hi Anna, there is a new message in your case. Sign in to read it."
}'
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

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

const { messageId } = await sendora.email.send({
  from: {
    email: 'no-reply@example.se',
    name: 'Example AB',
  },
  streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
  to: ['anna@example.com'],
  subject: 'Your case has a new message',
  text: 'Hi Anna, there is a new message in your case. Sign in to read it.',
});

console.log(messageId);
```

Python:

```python
import os

from sendora import Sendora

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

accepted = sendora.email.send(
    from_={
        "email": "no-reply@example.se",
        "name": "Example AB",
    },
    stream_id="7c9e6679-7425-40de-944b-e07fc1f90ae7",
    to=["anna@example.com"],
    subject="Your case has a new message",
    text=(
        "Hi Anna, there is a new message in your case. "
        "Sign in to read it."
    ),
)

print(accepted.message_id)
```

Response:

```json
{
  "messageId": "9a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
  "status": "accepted",
  "submittedAt": "2026-09-16T12:00:00.000Z",
  "test": false
}
```

A send fails when the stream cannot take it:

| Code                              | When                                                                                                         |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `stream_not_found`                | The stream is not one of this server's.                                                                      |
| `stream_archived`                 | The stream is archived.                                                                                      |
| `stream_not_sendable`             | The stream is an inbound stream. A batch item or an SMTP submission that names it fails the same way.        |
| `stream_paused`                   | Sendora paused the broadcast stream after too many complaints. It shows `pausedAt` until support resumes it. |
| `unsubscribe_placeholder_missing` | The stream is a broadcast stream, and a part of the message lacks `{{ unsubscribe_url }}`.                   |
| `list_unsubscribe_reserved`       | The stream is a broadcast stream, and the message sets its own `List-Unsubscribe` header.                    |

The last three follow from the [rules of a broadcast stream](https://sendora.se/docs/broadcasts#rules-of-a-broadcast-stream).

## List, rename, archive

cURL:

```bash
curl https://api.sendora.se/v1/streams \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

```ts
import { Sendora } from '@sendora/sdk';

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

const { streams } = await sendora.streams.list();

for (const stream of streams) {
  console.log(
    stream.name,
    stream.kind,
    stream.isDefault ? 'default' : '',
    stream.archivedAt ?? '',
  );
}
```

Python:

```python
import os

from sendora import Sendora

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

listed = sendora.streams.list()

for stream in listed.streams:
    print(
        stream.name,
        stream.kind,
        "default" if stream.is_default else "",
        stream.archived_at or "",
    )
```

Response:

```json
{
  "streams": [
    {
      "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "kind": "transactional",
      "name": "transactional",
      "isDefault": true,
      "createdAt": "2026-09-15T12:00:00.000Z",
      "archivedAt": null,
      "pausedAt": null,
      "inboundAddress": null,
      "contentRetentionDays": null
    },
    {
      "streamId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "kind": "transactional",
      "name": "Notices",
      "isDefault": false,
      "createdAt": "2026-09-16T12:00:00.000Z",
      "archivedAt": null,
      "pausedAt": null,
      "inboundAddress": null,
      "contentRetentionDays": null
    }
  ]
}
```

Every message in the [log](https://sendora.se/docs/messages) carries its `streamId`, and the search filters by it.

- [Update a stream](https://sendora.se/docs/api/patch-v1-streams-id) to rename it. Its id stays the same. The same call sets an inbound stream's `contentRetentionDays`.
- [Archive a stream](https://sendora.se/docs/api/post-v1-streams-id-archive) to stop new messages on it. What it already holds is still delivered, and its log stays readable. An archive cannot be undone.
- Archiving the default stream fails with `default_stream`.

In the dashboard, open a server under [Servers](https://app.sendora.se/servers). Its Streams tab lists the streams with their last 30 days. Each stream's page shows its messages, its suppression list and its settings, where you rename or archive it.

With the TypeScript SDK:

`sendora.streams.update(streamId, { name })` renames a stream, and `sendora.streams.update(streamId, { contentRetentionDays })` sets an inbound stream's content window. `sendora.streams.archive(streamId)` archives it. Each returns the stream.

With the Python SDK:

`sendora.streams.update(stream_id, name=)` renames a stream, and `sendora.streams.update(stream_id, content_retention_days=)` sets an inbound stream's content window. `sendora.streams.archive(stream_id)` archives it. Each returns the stream.
