# Messages and events

Statuses, timelines and searching the log.

Every message you send is in the log, with its recipients, their statuses and a timeline of what Sendora's mail server and the receivers said about it. Sendora keeps a message's text and HTML only to deliver it, and never returns them, so keep what you send. The server's **Messages** tab under [Servers](https://app.sendora.se/servers) in the dashboard shows the same log.

## Statuses

A message has a status of its own, which says whether Sendora's mail server has it: `accepted` once stored, `injected` once the mail server has it, and `failed` if that never happened. The outcome for each recipient is in the recipient's status.

Each recipient has a status too, which starts as `queued`. This table maps what happened to the recipient's status, the timeline's event and the [webhook](https://sendora.se/docs/webhooks) event:

| What happened                                      | Status      | Timeline event     | Webhook event                   |
| -------------------------------------------------- | ----------- | ------------------ | ------------------------------- |
| Sendora's mail server took the message             | `queued`    | `Reception`        | none                            |
| The receiver accepted it                           | `delivered` | `Delivery`         | `delivered`                     |
| The receiver asked Sendora to try later            | `deferred`  | `TransientFailure` | `deferred`                      |
| The receiver refused it                            | `bounced`   | `Bounce`           | `bounced`                       |
| A bounce report arrived later by mail              | `bounced`   | `OOB`              | `bounced`                       |
| The receiver was still deferring after three days  | `expired`   | `Expiration`       | `bounced`, with `expired: true` |
| The recipient reported it as spam                  | unchanged   | `Feedback`         | `spam_complaint`                |
| The recipient unsubscribed from a broadcast stream | unchanged   | none               | `unsubscribed`                  |

A status only moves forward, so a late deferral never undoes a delivery. `delivered`, `bounced` and `expired` are final. The one exception is a bounce report that arrives later, which turns `delivered` into `bounced`.

A timeline event has the SMTP `code` and the receiver's text in `details`, where there is one. A `Bounce` also has a `classification`, such as `InvalidRecipient`, where Sendora can tell. Other event types are rare, and the recipient's status reflects them.

Read a message with its timeline by its id:

cURL:

```bash
curl https://api.sendora.se/v1/messages/{messageId} \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

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

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

const message = await sendora.messages.get(
  '5f432ffd-c005-499b-aa3a-5b8a088ea20e',
);

for (const recipient of message.recipients) {
  console.log(recipient.address, recipient.status);
}
for (const event of message.events) {
  console.log(
    event.at,
    event.type,
    event.code,
    event.details,
  );
}
```

Python:

```python
import os

from sendora import Sendora

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

message = sendora.messages.get(
    "5f432ffd-c005-499b-aa3a-5b8a088ea20e"
)

for recipient in message.recipients:
    print(recipient.address, recipient.status)
for event in message.events:
    print(event.at, event.type, event.code, event.details)
```

Response:

```json
{
  "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
  "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "broadcastId": null,
  "status": "injected",
  "from": "no-reply@example.se",
  "subject": "Your account is ready",
  "tag": "welcome",
  "metadata": {},
  "submittedAt": "2026-09-15T12:00:00.000Z",
  "test": false,
  "recipients": [
    {
      "address": "anna@example.com",
      "kind": "to",
      "status": "delivered"
    }
  ],
  "attachments": [],
  "events": [
    {
      "recipient": "anna@example.com",
      "type": "Reception",
      "at": "2026-09-15T12:00:00.000Z",
      "code": 250,
      "details": null,
      "classification": null
    },
    {
      "recipient": "anna@example.com",
      "type": "Delivery",
      "at": "2026-09-15T12:00:02.000Z",
      "code": 250,
      "details": "2.0.0 OK",
      "classification": null
    }
  ]
}
```

## Search the log

Every filter is optional:

| Filter        | Matches                                             |
| ------------- | --------------------------------------------------- |
| `recipient`   | Messages to this address, in `to`, `cc` or `bcc`    |
| `streamId`    | Messages on this [stream](https://sendora.se/docs/streams)            |
| `broadcastId` | Messages of this [broadcast](https://sendora.se/docs/broadcasts)      |
| `tag`         | Messages with this tag                              |
| `status`      | Messages with at least one recipient in this status |
| `from`        | Messages submitted at or after this time            |
| `to`          | Messages submitted before this time                 |

`from` and `to` are times, not addresses. A recipient address travels in the request body, never in a URL. The results are newest first, in the page sizes on [Limits](https://sendora.se/docs/limits#page-sizes). Pass `next` as `after` for the next page.

cURL:

```bash
curl -X POST https://api.sendora.se/v1/messages/search \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "tag": "welcome",
  "from": "2026-09-01T00:00:00Z",
  "limit": 20
}'
```

TypeScript:

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

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

const page = await sendora.messages.search({
  tag: 'welcome',
  from: '2026-09-01T00:00:00Z',
  limit: 20,
});

for (const message of page.messages) {
  console.log(
    message.submittedAt,
    message.subject,
    message.recipients[0]?.status,
  );
}
console.log(
  page.next === null
    ? 'last page'
    : `next page after ${page.next}`,
);
```

Python:

```python
import os

from sendora import Sendora

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

page = sendora.messages.search(
    tag="welcome", from_="2026-09-01T00:00:00Z", limit=20
)

for message in page.messages:
    print(
        message.submitted_at,
        message.subject,
        message.recipients[0].status,
    )
if page.next is None:
    print("last page")
else:
    print(f"next page after {page.next}")
```

Response:

```json
{
  "messages": [
    {
      "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
      "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "broadcastId": null,
      "status": "injected",
      "from": "no-reply@example.se",
      "subject": "Your account is ready",
      "tag": "welcome",
      "metadata": {},
      "submittedAt": "2026-09-15T12:00:00.000Z",
      "test": false,
      "recipients": [
        {
          "address": "anna@example.com",
          "kind": "to",
          "status": "delivered"
        }
      ]
    }
  ],
  "next": null
}
```

With the TypeScript SDK:

`messages.searchAll` takes the same filters and walks every page for you, for `for await`. Its `from` and `to` also take a `Date`.

With the Python SDK:

`messages.search_all` takes the same filters and walks every page for you, as an iterator. Its `from_` and `to` also take an aware `datetime`.

## How long a message is kept

Sendora deletes the content of a message's attachments as soon as its mail server has the message, or once the message has failed. Their names, types and sizes stay in the log. The text and HTML go next, and the message, its recipients and its events last. [Limits](https://sendora.se/docs/limits#retention) gives each period, and a [test server](https://sendora.se/docs/test-servers) keeps less.

When a recipient asks you to erase their data, use an [erasure request](https://sendora.se/docs/suppressions#erasure-requests).
