# Inbound

Receive mail on an inbound stream or on your own domain, and read it.

An inbound [stream](https://sendora.se/docs/streams) receives email for your application, such as replies, support mail or forms. Sendora accepts each message, checks its SPF, DKIM, DMARC and ARC, stores it encrypted and posts it to your [webhook](https://sendora.se/docs/webhooks).

Sendora refuses mail for an address it does not know while the sending server is still connected. It never accepts a message and bounces it later.

## Before you start

- An approved account. Until it is approved, creating an inbound stream fails.
- A live server and its key. A [test server](https://sendora.se/docs/test-servers) has no inbound stream.

## Receive your first message

1. Create an inbound 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": "inbound",
  "name": "Support"
}'
```

TypeScript:

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

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

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

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

Python:

```python
import os

from sendora import Sendora

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

stream = sendora.streams.create(
    kind="inbound", name="Support"
)

print(stream.inbound_address)
```

Response:

```json
{
  "streamId": "b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
  "kind": "inbound",
  "name": "Support",
  "isDefault": false,
  "createdAt": "2026-09-19T07:55:00.000Z",
  "archivedAt": null,
  "pausedAt": null,
  "inboundAddress": "b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se",
  "contentRetentionDays": null
}
```

2. Note the stream's `inboundAddress` from the response. It is the stream's id without its dashes, at `inbound.sendora.se`.
3. [Register a webhook](https://sendora.se/docs/webhooks#register) that takes the `inbound` event. A webhook registered without `events` takes every event, `inbound` included.
4. Send a message to the address from any mail account.
5. Your webhook receives the `inbound` event. You can also find the message by [searching received mail](#search-received-mail).

Sendora accepts a message up to the size on [Limits](https://sendora.se/docs/limits#inbound-mail).

A server has one inbound stream at a time, so creating a second fails with `inbound_stream_exists`. To replace the stream, archive it first. Mail to its address is then refused.

## Route with a plus address

Anything after a plus sign in the local part is yours to choose. Mail to the stream's address with `+ticket-42` before the `@` arrives on the same stream, with `mailboxHash` set to `ticket-42`. So one stream can route mail by ticket, by customer or by form.

## Receive on your own domain

A stream can also receive on a domain of yours, such as `post.example.se`. Every address on the domain arrives on the stream, whatever its local part. A plus address works as it does on Sendora's address: `support+ticket-42@post.example.se` has `mailboxHash` set to `ticket-42`.

The domain's MX record will bring all of its mail to Sendora. So choose a domain that receives no other mail, such as a subdomain like `post.example.se`. Its own subdomains are not included.

1. Claim the domain for the stream, with the stream's id.

cURL:

```bash
curl -X POST https://api.sendora.se/v1/inbound/domains \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "streamId": "b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
  "domain": "post.example.se"
}'
```

TypeScript:

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

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

const { mx, txt } = await sendora.inboundDomains.create({
  streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',
  domain: 'post.example.se',
});

console.log(`${mx.type} ${mx.host} -> ${mx.value}`);
console.log(`${txt.type} ${txt.host} -> ${txt.value}`);
```

Python:

```python
import os

from sendora import Sendora

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

domain = sendora.inbound_domains.create(
    stream_id="b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
    domain="post.example.se",
)

for record in (domain.mx, domain.txt):
    print(record.type, record.host, "->", record.value)
```

Response:

```json
{
  "inboundDomainId": "6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60",
  "streamId": "b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
  "domain": "post.example.se",
  "verified": false,
  "createdAt": "2026-09-19T09:00:00.000Z",
  "lastCheckedAt": null,
  "unverifiedAt": null,
  "mx": {
    "type": "MX",
    "host": "post.example.se",
    "value": "10 inbound.sendora.se.",
    "verified": false,
    "verifiedAt": null
  },
  "txt": {
    "type": "TXT",
    "host": "_sendora-inbound.post.example.se",
    "value": "sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e",
    "verified": false,
    "verifiedAt": null
  }
}
```

2. At your DNS provider, add the response's two records, `mx` and `txt`, each at its `host` with its `value`. The MX record points the domain to `inbound.sendora.se`, and any priority passes the check. The TXT record proves that the domain is yours.
3. Wait for Sendora's check, which runs every few minutes until the domain is verified, or ask for one:

cURL:

```bash
curl -X POST https://api.sendora.se/v1/inbound/domains/{inboundDomainId}/verify \
  -H "Authorization: Bearer $SENDORA_API_TOKEN"
```

TypeScript:

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

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

const checked = await sendora.inboundDomains.verify(
  '6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60',
);
const { verified, check } = checked;

console.log(verified, check.mx, check.txt);
```

Python:

```python
import os

from sendora import Sendora

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

checked = sendora.inbound_domains.verify(
    "6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60"
)

print(
    checked.verified, checked.check.mx, checked.check.txt
)
```

Response:

```json
{
  "inboundDomainId": "6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60",
  "streamId": "b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
  "domain": "post.example.se",
  "verified": true,
  "createdAt": "2026-09-19T09:00:00.000Z",
  "lastCheckedAt": "2026-09-19T09:10:00.000Z",
  "unverifiedAt": null,
  "mx": {
    "type": "MX",
    "host": "post.example.se",
    "value": "10 inbound.sendora.se.",
    "verified": true,
    "verifiedAt": "2026-09-19T09:10:00.000Z"
  },
  "txt": {
    "type": "TXT",
    "host": "_sendora-inbound.post.example.se",
    "value": "sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e",
    "verified": true,
    "verifiedAt": "2026-09-19T09:10:00.000Z"
  },
  "check": {
    "mx": "ok",
    "txt": "ok"
  }
}
```

`check` says what each lookup found: `ok`, `missing`, `mismatch`, or `dns_error` when the resolver failed. Mail to the domain is accepted within a minute of a check that finds both records.

- A stream has one domain.
- A domain of Sendora's own fails with `domain_reserved`.
- The first account to verify a domain keeps it. A claim to a domain that another account has verified fails with `inbound_domain_exists`.
- Removing the domain refuses mail to it at once, and frees the domain for another claim.

### If the records disappear

Sendora checks a verified domain once a day. A check you ask for counts the same way, and a check that ends in `dns_error` changes nothing. When checks in a row find a record missing or wrong:

- The domain keeps receiving through two such checks.
- The third such check ends its verification. Mail to the domain is then deferred, so senders retry and nothing bounces while you fix the records.
- After the grace period on [Limits](https://sendora.se/docs/limits#inbound-mail), mail to the domain is refused until a check finds both records again.

A sending domain follows another rule, which [Domains](https://sendora.se/docs/domains#when-a-record-goes-missing) explains.

## The inbound event

A webhook that takes `inbound` gets one delivery for each received message. Every delivery carries the reference: the message's `inboundMessageId`, the address it was sent to, when it arrived, its sizes and its verdicts. The `inboundContent` you set when you [register the webhook](https://sendora.se/docs/webhooks#register) decides what else comes:

- `full`, the default, adds the message itself: its sender and the other parties, the subject, the headers, the text, the HTML and a description of each attachment.
- `reference` adds nothing. Read the rest through the API.

With `full`, `contentOmitted` is `too_large` when the text or the HTML was left out for size. It is `expired` when the stream no longer keeps the content, so only the reference is left. [Webhook events](https://sendora.se/docs/api/webhook-events#event-inbound) lists every field.

## Search received mail

cURL:

```bash
curl -X POST https://api.sendora.se/v1/inbound/search \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "streamId": "b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
  "limit": 20
}'
```

TypeScript:

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

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

const page = await sendora.inbound.search({
  streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',
  limit: 20,
});

for (const message of page.messages) {
  console.log(
    message.receivedAt,
    message.from?.address,
    message.subject,
  );
}
```

Python:

```python
import os

from sendora import Sendora

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

page = sendora.inbound.search(
    stream_id="b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
    limit=20,
)

for message in page.messages:
    print(
        message.received_at,
        message.from_.address if message.from_ else None,
        message.subject,
    )
```

Response:

```json
{
  "messages": [
    {
      "inboundMessageId": "4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31",
      "streamId": "b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
      "receivedAt": "2026-09-19T08:00:02.000Z",
      "envelopeRecipient": "b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se",
      "mailboxHash": null,
      "sizeBytes": 412,
      "attachmentCount": 0,
      "hasText": true,
      "hasHtml": false,
      "parseIssue": null,
      "authentication": {
        "spf": "pass",
        "spfHelo": "pass",
        "dkim": "pass",
        "dmarc": "pass",
        "dmarcPolicy": null,
        "arc": "none",
        "checkedAt": "2026-09-19T08:00:03.000Z"
      },
      "contentAvailable": true,
      "contentExpiresAt": "2026-10-19T08:00:02.000Z",
      "from": {
        "address": "anna@example.com",
        "name": "Anna Andersson"
      },
      "subject": "A question about my order",
      "date": "2026-09-19T08:00:00.000Z"
    }
  ],
  "next": null
}
```

Every filter is optional: the stream, the sender, the address of yours the message was sent to, the `mailboxHash` and the time it was received. The sender is matched by a keyed hash, so the search stores no address. The results are newest first. Pass `next` as `after` for the next page. Each result has `from`, `subject` and `date` while the content is kept, and `contentAvailable`.

## Read a message

cURL:

```bash
curl https://api.sendora.se/v1/inbound/4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31 \
  -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.inbound.get(
  '4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31',
);

console.log(message.from?.address, message.subject);
console.log(message.authentication.dmarc);
console.log(message.text);
for (const attachment of message.attachments) {
  console.log(attachment.name, attachment.size);
}
```

Python:

```python
import os

from sendora import Sendora

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

message = sendora.inbound.get(
    "4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31"
)

print(
    message.from_.address if message.from_ else None,
    message.subject,
)
print(message.authentication.dmarc)
print(message.text)
for attachment in message.attachments:
    print(attachment.name, attachment.size)
```

Response:

```json
{
  "inboundMessageId": "4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31",
  "streamId": "b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f",
  "receivedAt": "2026-09-19T08:00:02.000Z",
  "envelopeRecipient": "b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se",
  "mailboxHash": null,
  "sizeBytes": 412,
  "attachmentCount": 0,
  "hasText": true,
  "hasHtml": false,
  "parseIssue": null,
  "authentication": {
    "spf": "pass",
    "spfHelo": "pass",
    "dkim": "pass",
    "dmarc": "pass",
    "dmarcPolicy": null,
    "arc": "none",
    "checkedAt": "2026-09-19T08:00:03.000Z"
  },
  "contentAvailable": true,
  "contentExpiresAt": "2026-10-19T08:00:02.000Z",
  "from": {
    "address": "anna@example.com",
    "name": "Anna Andersson"
  },
  "subject": "A question about my order",
  "date": "2026-09-19T08:00:00.000Z",
  "envelope": {
    "sender": "anna@example.com"
  },
  "replyTo": [],
  "to": [
    {
      "address": "b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se",
      "name": null
    }
  ],
  "toCount": 1,
  "cc": [],
  "ccCount": 0,
  "messageIdHeader": "<question-1@example.com>",
  "inReplyTo": null,
  "references": [],
  "headers": [
    {
      "name": "From",
      "value": "Anna Andersson <anna@example.com>"
    },
    {
      "name": "To",
      "value": "b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se"
    },
    {
      "name": "Subject",
      "value": "A question about my order"
    },
    {
      "name": "Message-ID",
      "value": "<question-1@example.com>"
    },
    {
      "name": "Date",
      "value": "Fri, 19 Sep 2026 10:00:00 +0200"
    }
  ],
  "text": "Hi!\n",
  "html": null,
  "attachments": []
}
```

- `to` and `cc` hold as many entries as [Limits](https://sendora.se/docs/limits#inbound-mail) allows. `toCount` and `ccCount` give the whole number.
- `authentication` holds the verdicts: `spf` for the envelope sender, `spfHelo` for the sending server's name, `dkim`, `dmarc` and `arc`. `dmarc` is the verdict on the From address. Only `pass` means that the From domain authenticated the message. With any other verdict, the From address may be forged.
- `dmarcPolicy` is set only when DMARC failed. `checkedAt` says when the checks ran.
- `parseIssue` names what the parser could not handle, or is null. A message the parser could not read still has its envelope, times, sizes and verdicts.
- `contentExpiresAt` says when the content goes. After that, `contentAvailable` is false and the content fields are null.

## Download the raw message and the attachments

`GET /v1/inbound/{id}/raw` returns the message byte for byte as it was received, as `message/rfc822`. `GET /v1/inbound/{id}/attachments/{attachmentId}` returns an attachment's bytes as `application/octet-stream`, whatever type the sender declared. Each entry of the message's `attachments` has its `attachmentId`. The sanitised file name is in `Content-Disposition`. Both come with headers that stop a browser from rendering or running them. After `contentExpiresAt`, both fail with `410 content_expired`.

Over HTTP:

```
curl https://api.sendora.se/v1/inbound/4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31/raw \
  -H "Authorization: Bearer $SENDORA_API_TOKEN" \
  -o message.eml
```

With the TypeScript SDK:

`sendora.inbound.raw(inboundMessageId)` and `sendora.inbound.attachment(inboundMessageId, attachmentId)` return the bytes as a `Uint8Array`, with the same errors and retries as every other call.

With the Python SDK:

`sendora.inbound.raw(inbound_message_id)` and `sendora.inbound.attachment(inbound_message_id, attachment_id)` return the content as `bytes`, with the same errors and retries as every other call.

## What Sendora keeps

Sendora keeps a received message's content for the stream's `contentRetentionDays`: its text, HTML, headers, attachments and raw form. You set the days when you [update the stream](https://sendora.se/docs/api/patch-v1-streams-id), within the range on [Limits](https://sendora.se/docs/limits#inbound-mail). Null means the default that Limits gives. After that, only the reference remains, for the time on [Limits](https://sendora.se/docs/limits#retention).

To erase a person from received mail, use an [erasure request](https://sendora.se/docs/suppressions#erasure-requests). It covers their sent and received mail alike.

The dashboard shows a stream's received mail under the server's **Streams** tab in [Servers](https://app.sendora.se/servers). Every download from there is audited.
