# Sending

Single sends, batches, attachments, headers and idempotency.

Send messages one at a time or in a batch. Both take the same message and return the same result for each message. The [reference](https://sendora.se/docs/api/post-v1-email) lists every field, and [Limits](https://sendora.se/docs/limits#messages) gives every size and count.

## The message

- `from` must be an address on a [verified sending domain](https://sendora.se/docs/domains) of your account.
- `to`, `cc` and `bcc` take plain addresses or objects with `email` and a display `name`. The recipients of all three count together.
- `subject` is required, and so is at least one of `text` and `html`. Send both when you can, since each receiver picks what it shows.
- `streamId` sets the [stream](https://sendora.se/docs/streams) the message goes on. Without it, the message goes on the server's default transactional stream.

## Attachments, headers, tags and metadata

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": "billing@example.se",
    "name": "Example AB"
  },
  "to": [
    {
      "email": "anna@example.com",
      "name": "Anna Andersson"
    }
  ],
  "cc": [
    "ekonomi@example.se"
  ],
  "subject": "Invoice 2026-0912",
  "text": "Hi Anna, invoice 2026-0912 is attached. It is due on 2026-10-15.",
  "html": "<p>Hi Anna,</p><p>Invoice <strong>2026-0912</strong> is attached. It is due on 2026-10-15.</p>",
  "attachments": [
    {
      "name": "invoice-2026-0912.pdf",
      "content": "JVBERi0xLjcKJcOkw7zDtsOfCg==",
      "contentType": "application/pdf"
    }
  ],
  "headers": {
    "Reply-To": "ekonomi@example.se",
    "X-Invoice-Id": "2026-0912"
  },
  "tag": "invoice",
  "metadata": {
    "invoiceId": "2026-0912",
    "customerId": "4711"
  }
}'
```

TypeScript:

```ts
import { readFile } from 'node:fs/promises';
import { Sendora } from '@sendora/sdk';

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

const invoice = await readFile(
  new URL('./invoice-2026-0912.pdf', import.meta.url),
);

const { messageId } = await sendora.email.send({
  from: {
    email: 'billing@example.se',
    name: 'Example AB',
  },
  to: [
    { email: 'anna@example.com', name: 'Anna Andersson' },
  ],
  cc: ['ekonomi@example.se'],
  subject: 'Invoice 2026-0912',
  text: 'Hi Anna, invoice 2026-0912 is attached. It is due on 2026-10-15.',
  html: '<p>Hi Anna,</p><p>Invoice <strong>2026-0912</strong> is attached. It is due on 2026-10-15.</p>',
  attachments: [
    {
      name: 'invoice-2026-0912.pdf',
      content: invoice,
      contentType: 'application/pdf',
    },
  ],
  headers: {
    'Reply-To': 'ekonomi@example.se',
    'X-Invoice-Id': '2026-0912',
  },
  tag: 'invoice',
  metadata: {
    invoiceId: '2026-0912',
    customerId: '4711',
  },
});

console.log(messageId);
```

Python:

```python
import os
from pathlib import Path

from sendora import Sendora

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

invoice = (
    Path(__file__)
    .with_name("invoice-2026-0912.pdf")
    .read_bytes()
)

accepted = sendora.email.send(
    from_={
        "email": "billing@example.se",
        "name": "Example AB",
    },
    to=[
        {
            "email": "anna@example.com",
            "name": "Anna Andersson",
        }
    ],
    cc=["ekonomi@example.se"],
    subject="Invoice 2026-0912",
    text=(
        "Hi Anna, invoice 2026-0912 is attached. "
        "It is due on 2026-10-15."
    ),
    html=(
        "<p>Hi Anna,</p>"
        "<p>Invoice <strong>2026-0912</strong> "
        "is attached. It is due on 2026-10-15.</p>"
    ),
    attachments=[
        {
            "name": "invoice-2026-0912.pdf",
            "content": invoice,
            "content_type": "application/pdf",
        }
    ],
    headers={
        "Reply-To": "ekonomi@example.se",
        "X-Invoice-Id": "2026-0912",
    },
    tag="invoice",
    metadata={
        "invoiceId": "2026-0912",
        "customerId": "4711",
    },
)

print(accepted.message_id)
```

Response:

```json
{
  "messageId": "4e1c3f2a-9b6d-4e5c-9a0f-3d4e5f6a7b82",
  "status": "accepted",
  "submittedAt": "2026-09-15T12:00:00.000Z",
  "test": false
}
```

- `attachments`: send the content base64-encoded, without line breaks. `contentType` defaults to `application/octet-stream`. Set `contentId` to use the attachment as an inline image, with `<img src="cid:…">` in the HTML. Sendora deletes the content once its mail server has the message, and keeps the name, type and size.
- `headers`: your own headers, such as `Reply-To`, `In-Reply-To` or `X-` headers. Sendora writes the address, date, ID and trace headers itself, such as `From`, `To`, `Subject`, `Date`, `Message-ID`, `Return-Path` and `DKIM-Signature`. Setting one of them fails with `invalid_request`, and so does any header that starts with `Content-`, `Resent-` or `ARC-`. The `headers` field in the [reference](https://sendora.se/docs/api/post-v1-email) lists every one.
- `tag`: one label per message, such as `invoice` or `welcome`. It comes back with the message, in the log's search and in every webhook about the message.
- `metadata`: your own key-value pairs. Keys are letters, digits, `_`, `.` and `-`. They come back with the message and in every webhook, so an order id here saves you a lookup when an event arrives.

With the TypeScript SDK:

The SDK takes attachment content as bytes, a `Uint8Array` or a Node `Buffer`, and encodes it. It passes a base64 string through as it is.

With the Python SDK:

The SDK takes attachment content as `bytes` and encodes it. It passes a base64 string through as it is. The content id is `content_id`.

## Idempotency

A network can fail after Sendora has accepted a message but before your code gets the answer. To retry safely, send an idempotency key: any printable text that identifies the request on your side. A retry with the same key returns the first answer instead of sending again. Without a key of your own, a retry of a request that went through sends the message twice. [Limits](https://sendora.se/docs/limits#requests) gives the key's length and how long Sendora remembers it.

cURL:

```bash
curl -X POST https://api.sendora.se/v1/email \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Idempotency-Key: order-4711-confirmation" \
  -H "Content-Type: application/json" \
  -d '{
  "from": {
    "email": "order@example.se",
    "name": "Example AB"
  },
  "to": [
    "anna@example.com"
  ],
  "subject": "Order 4711 confirmed",
  "text": "Hi Anna, we have received order 4711 and will ship it tomorrow."
}'
```

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: 'order@example.se',
      name: 'Example AB',
    },
    to: ['anna@example.com'],
    subject: 'Order 4711 confirmed',
    text: 'Hi Anna, we have received order 4711 and will ship it tomorrow.',
  },
  { idempotencyKey: 'order-4711-confirmation' },
);

console.log(messageId);
```

Python:

```python
import os

from sendora import Sendora

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

accepted = sendora.email.send(
    from_={
        "email": "order@example.se",
        "name": "Example AB",
    },
    to=["anna@example.com"],
    subject="Order 4711 confirmed",
    text=(
        "Hi Anna, we have received order 4711 "
        "and will ship it tomorrow."
    ),
    idempotency_key="order-4711-confirmation",
)

print(accepted.message_id)
```

Response:

```json
{
  "messageId": "5f2d4a3b-0c7e-4f6d-8b1a-4e5f6a7b8c93",
  "status": "accepted",
  "submittedAt": "2026-09-15T12:00:00.000Z",
  "test": false
}
```

Over HTTP:

Send the key in the `Idempotency-Key` header. A replayed answer has the header `Idempotent-Replayed: true`.

With the TypeScript SDK:

The SDK sends a key with every request: yours if you set `idempotencyKey`, or else a random one. So its own retries after a lost connection never send twice. Set your own key when a retry may come from another process, such as a job runner.

With the Python SDK:

The SDK sends a key with every request: yours if you set `idempotency_key`, or else a random one. So its own retries after a lost connection never send twice. Set your own key when a retry may come from another process, such as a job runner.

Keys are per server. The same key with a different message fails with `idempotency_key_mismatch`, since a changed request is a new request.

## Batches

A batch sends a list of messages in one request, up to the number on [Limits](https://sendora.se/docs/limits#requests). It needs an idempotency key. Over HTTP, a batch without one fails with `idempotency_key_required`. The SDKs send a random key when you give none.

cURL:

```bash
curl -X POST https://api.sendora.se/v1/email/batch \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Idempotency-Key: invoices-2026-09-15-run-1" \
  -H "Content-Type: application/json" \
  -d '[
  {
    "from": {
      "email": "billing@example.se",
      "name": "Example AB"
    },
    "to": [
      "anna@example.com"
    ],
    "subject": "Invoice 2026-0912",
    "text": "Hi Anna, invoice 2026-0912 is due on 2026-10-15.",
    "tag": "invoice",
    "metadata": {
      "invoiceId": "2026-0912"
    }
  },
  {
    "from": {
      "email": "billing@example.se",
      "name": "Example AB"
    },
    "to": [
      "bo@example.com"
    ],
    "subject": "Invoice 2026-0913",
    "text": "Hi Bo, invoice 2026-0913 is due on 2026-10-15.",
    "tag": "invoice",
    "metadata": {
      "invoiceId": "2026-0913"
    }
  }
]'
```

TypeScript:

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

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

const { results } = await sendora.email.sendBatch(
  [
    {
      from: {
        email: 'billing@example.se',
        name: 'Example AB',
      },
      to: ['anna@example.com'],
      subject: 'Invoice 2026-0912',
      text: 'Hi Anna, invoice 2026-0912 is due on 2026-10-15.',
      tag: 'invoice',
      metadata: { invoiceId: '2026-0912' },
    },
    {
      from: {
        email: 'billing@example.se',
        name: 'Example AB',
      },
      to: ['bo@example.com'],
      subject: 'Invoice 2026-0913',
      text: 'Hi Bo, invoice 2026-0913 is due on 2026-10-15.',
      tag: 'invoice',
      metadata: { invoiceId: '2026-0913' },
    },
  ],
  { idempotencyKey: 'invoices-2026-09-15-run-1' },
);

for (const result of results) {
  if (result.status === 'accepted') {
    console.log(
      result.index,
      result.messageId,
      result.replayed ? 'sent earlier' : 'sent',
    );
  } else {
    console.log(
      result.index,
      result.error,
      result.message,
    );
  }
}
```

Python:

```python
import os

from sendora import Sendora

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

batch = sendora.email.send_batch(
    [
        {
            "from_": {
                "email": "billing@example.se",
                "name": "Example AB",
            },
            "to": ["anna@example.com"],
            "subject": "Invoice 2026-0912",
            "text": (
                "Hi Anna, invoice 2026-0912 "
                "is due on 2026-10-15."
            ),
            "tag": "invoice",
            "metadata": {"invoiceId": "2026-0912"},
        },
        {
            "from_": {
                "email": "billing@example.se",
                "name": "Example AB",
            },
            "to": ["bo@example.com"],
            "subject": "Invoice 2026-0913",
            "text": (
                "Hi Bo, invoice 2026-0913 "
                "is due on 2026-10-15."
            ),
            "tag": "invoice",
            "metadata": {"invoiceId": "2026-0913"},
        },
    ],
    idempotency_key="invoices-2026-09-15-run-1",
)

for result in batch.results:
    if result.status == "accepted":
        print(
            result.index,
            result.message_id,
            "sent earlier" if result.replayed else "sent",
        )
    else:
        print(result.index, result.code, result.message)
```

Response:

```json
{
  "results": [
    {
      "index": 0,
      "messageId": "2c9a1d0e-7f4b-4c3a-9e8d-1b2c3d4e5f60",
      "status": "accepted",
      "submittedAt": "2026-09-15T12:00:00.000Z",
      "test": false,
      "replayed": false
    },
    {
      "index": 1,
      "messageId": "3d0b2e1f-8a5c-4d4b-8f9e-2c3d4e5f6a71",
      "status": "accepted",
      "submittedAt": "2026-09-15T12:00:00.000Z",
      "test": false,
      "replayed": false
    }
  ]
}
```

Sendora accepts or refuses each message on its own, so one invalid message never fails the batch. The batch returns `200` with one result per message, in the order you sent them. Each has the `status` `accepted`, with the message's id, or `error`, with the code in `error` and the `message` that a single send would have returned.

- A retry with the same key sends nothing twice. Messages accepted before come back with `replayed: true`, and only the messages that failed are tried again. You may correct a failed message in the retry, since only accepted messages are remembered. Changing an accepted one fails that message with `idempotency_key_mismatch`.
- A refusal that concerns the whole account fails the batch before any message is checked. That covers a paused account, one without active payment, and sending stopped by Sendora.
- The per-minute limit and the monthly cap count message by message, in order. The messages up to the limit are accepted, and the rest get `rate_limited` or `monthly_cap_reached` in their results.

## What happens next

Sendora stores the message encrypted and hands it to its mail server within seconds. Its `recipients[].status` then changes as receivers answer, and its timeline fills with events, as [Messages and events](https://sendora.se/docs/messages) shows. To be told instead of polling, register a [webhook](https://sendora.se/docs/webhooks).
