# Sendora documentation Transactional email delivery hosted in Sweden. Base URL https://api.sendora.se; every request carries an API key as a bearer token, a server key (sk_) for everything inside one server and an account key (ak_) for what belongs to the account. The TypeScript SDK is `@sendora/sdk` on npm. The Python SDK is `sendora` on PyPI. The PHP SDK is `sendora/sendora-php` on Packagist. The pages: https://sendora.se/docs. ## Quickstart Send your first message and read it in the log. Three steps take you from an approved account to a delivered message. ## Before you start - Your account is approved, and its first payment has arrived. [Account › Billing](https://app.sendora.se/account/billing) in the dashboard shows where the account stands. Until then a live server's sends fail, with the codes on [Errors](https://sendora.se/docs/errors#when-an-account-cannot-send). - The domain you send from is verified, as [Domains](https://sendora.se/docs/domains) explains. The [Domains](https://app.sendora.se/domains) page shows its two DNS records: a CNAME for the return path and a TXT record for the DKIM key. - To try this without sending real mail, use a [test server](https://sendora.se/docs/test-servers) and its key. A test server works before your account is approved and before payment. ## 1. Get a server key Sign in to the dashboard and open your server under [Servers](https://app.sendora.se/servers). Signup made a server named default, without a key. Create a key on its **API keys** tab. The key is shown once, so put it in your secret store. [Keys](https://sendora.se/docs/keys) explains the two kinds of key. Over HTTP: Send the key as a bearer token on every request: ```bash Authorization: Bearer sk_… ``` With the TypeScript SDK: Install the SDK. It has no dependencies, is ESM, and runs on Node 20.19 or newer, Bun, Deno and Cloudflare Workers: ```sh npm install @sendora/sdk ``` Keep the key in the environment on the server, as `SENDORA_API_TOKEN` below. Never ship it to a browser. The client throws when you create it without a key. With the Python SDK: Install the SDK. It runs on Python 3.11 or newer and has one dependency, httpx2: ```sh pip install sendora ``` Pin the major version you test with, such as `sendora~=2.0` in your requirements. Only a new major version may break your code. Keep the key in the environment on the server, as `SENDORA_API_TOKEN` below. `Sendora()` reads it from there when you give it no key, and raises `ValueError` when there is none. With the PHP SDK: Install the SDK. It runs on PHP 8.2 or newer, with ext-curl and ext-json and no other package: ```sh composer require sendora/sendora-php ``` Composer writes a constraint such as `^0.11`, which takes fixes but no new minor version. Before 1.0.0, a new minor version may break your code. Keep the key in the environment on the server, as `SENDORA_API_TOKEN` below. `new Sendora()` reads it from there when you give it no key, and throws `InvalidArgumentException` when there is none. ## 2. Send a message 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" }, "to": [ "anna@example.com" ], "subject": "Your account is ready", "text": "Hi Anna, your account at Example is ready. Sign in at https://example.se/login.", "tag": "welcome" }' ``` 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', }, to: ['anna@example.com'], subject: 'Your account is ready', text: 'Hi Anna, your account at Example is ready. Sign in at https://example.se/login.', tag: 'welcome', }); 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", }, to=["anna@example.com"], subject="Your account is ready", text=( "Hi Anna, your account at Example is ready. " "Sign in at https://example.se/login." ), tag="welcome", ) print(accepted.message_id) ``` PHP: ```php email->send( from: [ 'email' => 'no-reply@example.se', 'name' => 'Example AB', ], to: ['anna@example.com'], subject: 'Your account is ready', text: 'Hi Anna, your account at Example is ready. ' . 'Sign in at https://example.se/login.', tag: 'welcome', ); echo $accepted->messageId, "\n"; ``` Response: ```json { "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e", "status": "accepted", "submittedAt": "2026-09-15T12:00:00.000Z", "test": false } ``` The `from` address must be on a verified domain. `to`, `cc` and `bcc` take plain addresses or objects with a display name. A message needs a `subject` and at least one of `text` and `html`. The answer comes at once. `accepted` means Sendora has stored the message and will hand it to its mail server. The log, the events and the webhooks all refer to the message by its `messageId`. Delivery, deferral, bounce and complaint arrive later, as events. If your code may retry a send, give it an idempotency key. A retry with the same key returns the first answer instead of sending twice. [Sending](https://sendora.se/docs/sending#idempotency) explains keys and batches. ## 3. Read the message The message log keeps every message with its recipients and its timeline, for the time on [Limits](https://sendora.se/docs/limits#retention). Read one 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) ``` PHP: ```php messages->get( '5f432ffd-c005-499b-aa3a-5b8a088ea20e', ); foreach ($message->recipients as $recipient) { printf( "%s %s\n", $recipient->address, $recipient->status, ); } foreach ($message->events as $event) { printf( "%s %s %s %s\n", $event->at->format('c'), $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", "tracked": false, "opens": 0, "clicks": 0, "firstOpenAt": null, "firstHumanOpenAt": null } ], "tracking": { "recordingOpens": false, "recordingClicks": false, "skipped": null }, "attachments": [], "events": [ { "recipient": "anna@example.com", "type": "Reception", "at": "2026-09-15T12:00:00.000Z", "code": 250, "details": null, "classification": null, "tracking": null }, { "recipient": "anna@example.com", "type": "Delivery", "at": "2026-09-15T12:00:02.000Z", "code": 250, "details": "2.0.0 OK", "classification": null, "tracking": null } ] } ``` The message's `status` turns `injected` once Sendora's mail server has it. Each recipient's `status` moves from `queued` to `delivered`, `deferred` or `bounced` as the receiving server answers. The `events` list is the timeline: `Reception` when Sendora's mail server took the message, `Delivery` when the receiver accepted it, and `Bounce` when it refused it. The server's **Messages** tab in the dashboard shows the same log, searchable by recipient, tag, status and time. ## What next - Register a [webhook](https://sendora.se/docs/webhooks) to get `delivered`, `bounced`, `deferred` and `spam_complaint` events as they happen. - Send from a system that only speaks SMTP, as [SMTP submission](https://sendora.se/docs/smtp) shows. - Look up any route, with its fields and error codes, in the [API reference](https://sendora.se/docs/api). ## 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. - `trackOpens` and `trackLinks` override the stream's [tracking](https://sendora.se/docs/messages#opens-and-clicks) for this message. Without them, the stream decides. ## 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": "

Hi Anna,

Invoice 2026-0912 is attached. It is due on 2026-10-15.

", "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: '

Hi Anna,

Invoice 2026-0912 is attached. It is due on 2026-10-15.

', 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=( "

Hi Anna,

" "

Invoice 2026-0912 " "is attached. It is due on 2026-10-15.

" ), 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) ``` PHP: ```php 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: '

Hi Anna,

' . '

Invoice 2026-0912 ' . 'is attached. It is due on 2026-10-15.

', attachments: [ Attachment::fromFile( __DIR__ . '/invoice-2026-0912.pdf', contentType: 'application/pdf', ), ], headers: [ 'Reply-To' => 'ekonomi@example.se', 'X-Invoice-Id' => '2026-0912', ], tag: 'invoice', metadata: [ 'invoiceId' => '2026-0912', 'customerId' => '4711', ], ); echo $accepted->messageId, "\n"; ``` 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 `` 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`. With the PHP SDK: `Attachment::fromFile()` and `Attachment::fromBytes()` read the content and encode it. An array whose `content` is already base64 goes through as it is. The content id is `contentId`. ## 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) ``` PHP: ```php 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', ); echo $accepted->messageId, "\n"; ``` 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. With the PHP 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 queued job. 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) ``` PHP: ```php 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', ); foreach ($batch->results as $result) { if ($result instanceof AcceptedBatchItem) { printf( "%d %s %s\n", $result->index, $result->messageId, $result->replayed ? 'sent earlier' : 'sent', ); } else { printf( "%d %s %s\n", $result->index, $result->errorCode, $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 whose live servers wait for a 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. ## Send from Laravel The Laravel package, `sendora/sendora-laravel`, sends Laravel's mail through the API. It runs on Laravel 12 with PHP 8.2 or newer, and on Laravel 13 with PHP 8.3 or newer. Install it: ```sh composer require sendora/sendora-laravel ``` Then set the server key and the mailer in `.env`: ```bash SENDORA_API_TOKEN=sk_… MAIL_MAILER=sendora MAIL_FROM_ADDRESS=no-reply@example.se ``` Every mailable, and every notification sent by mail, then goes through the API. The sender, `MAIL_FROM_ADDRESS` or a mailable's own, must be on a verified domain. - A mailable's attachments, Cc, Bcc and Reply-To go through as Laravel sets them. - The `tags` and `metadata` of a mailable's envelope become the message's tag and metadata. A message has one tag, so two different tags fail the send. - `send()` returns the sent message, whose `getMessageId()` is the message's id in Sendora. Queued mail returns null, and Laravel's `MessageSent` event carries its id. A failed send throws Symfony's `TransportException`, with the SDK's exception as its previous one. ## 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). ## 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) ``` PHP: ```php streams->create( kind: 'transactional', name: 'Notices', ); echo $stream->streamId, "\n"; ``` 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, "trackOpens": false, "trackLinks": false } ``` 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) ``` PHP: ```php 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.', ); echo $accepted->messageId, "\n"; ``` 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 "", ) ``` PHP: ```php streams->list(); foreach ($listed->streams as $stream) { printf( "%s %s %s %s\n", $stream->name, $stream->kind, $stream->isDefault ? 'default' : '', $stream->archivedAt?->format('c'), ); } ``` 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, "trackOpens": false, "trackLinks": false }, { "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, "trackOpens": false, "trackLinks": false } ] } ``` 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`. A stream's `trackOpens` and `trackLinks` show whether its mail is [tracked](https://sendora.se/docs/messages#opens-and-clicks). An administrator switches them under the stream's **Settings** tab, and the API only reads them. They are always `false` on an inbound 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. With the PHP 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. ## Broadcasts One message to many recipients, each with their own unsubscribe link. 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](https://sendora.se/docs/streams#create-a-stream) shows. ## Send a broadcast cURL: ```bash 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": "

Hi!

Here is what happened in October.

Unsubscribe

", "tag": "newsletter-2026-10", "messages": [ { "to": [ "anna@example.com" ], "metadata": { "customer": "1042" } }, { "to": [ { "email": "bo@example.com", "name": "Bo Berg" } ] } ] }' ``` TypeScript: ```ts 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: '

Hi!

Here is what happened in October.

Unsubscribe

', 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); ``` Python: ```python 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=( "

Hi!

" "

Here is what happened in October.

" '

' "Unsubscribe

" ), 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) ``` PHP: ```php 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\n" . "Here is what happened in October.\n\n" . 'No more of these? {{ unsubscribe_url }}', html: '

Hi!

' . '

Here is what happened in October.

' . '

' . 'Unsubscribe

', tag: 'newsletter-2026-10', messages: [ [ 'to' => ['anna@example.com'], 'metadata' => ['customer' => '1042'], ], [ 'to' => [ [ 'email' => 'bo@example.com', 'name' => 'Bo Berg', ], ], ], ], idempotencyKey: 'newsletter-2026-10', ); echo $accepted->broadcastId, ' ', $accepted->total, "\n"; ``` Response: ```json { "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. `trackOpens` and `trackLinks` on the broadcast override the stream's [tracking](https://sendora.se/docs/messages#opens-and-clicks) for every message, as on a single send. [Limits](https://sendora.se/docs/limits#broadcasts) 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. With the TypeScript SDK: 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. With the Python SDK: 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. With the PHP SDK: Pass the key as `idempotencyKey:` to `$sendora->broadcasts->send()`. Without it, the SDK makes up a random key, which covers only its own retries. A refused broadcast throws a `SendoraException` with its code in `errorCode`. ## 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](https://sendora.se/acceptable-use) 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](https://sendora.se/docs/suppressions), and your [webhook](https://sendora.se/docs/webhooks) gets the event `unsubscribed`. If complaints pass the rate on [Limits](https://sendora.se/docs/limits#broadcasts), 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 cURL: ```bash 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" } } ] }' ``` TypeScript: ```ts 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); ``` Python: ```python 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) ``` PHP: ```php 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\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', ], ], ], idempotencyKey: 'welcome-2026-10', ); echo $accepted->broadcastId, "\n"; ``` Response: ```json { "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](https://sendora.se/docs/limits#broadcasts) 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. A message's [size](https://sendora.se/docs/limits#broadcasts) counts its values as filled in. If one message would pass it, the whole broadcast fails with `invalid_request`, and the error names that message's position. Nothing else is rendered: there are no conditions, loops or filters. Sendora fills in `unsubscribe_url` and [`tracking_opt_out_url`](https://sendora.se/docs/suppressions#let-a-recipient-ask) itself, so a substitution with either name fails with `invalid_request`. ## Follow a broadcast cURL: ```bash curl https://api.sendora.se/v1/broadcasts/{broadcastId} \ -H "Authorization: Bearer $SENDORA_API_TOKEN" ``` TypeScript: ```ts 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, ); ``` Python: ```python 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 ) ``` PHP: ```php broadcasts->get( '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01', ); printf( "%s %d %d\n", $broadcast->status, $broadcast->released, $broadcast->total, ); ``` Response: ```json { "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, "opened": 0, "openedByPeople": 0, "clicked": 0, "clickedByPeople": 0, "links": [] } ``` - `total`: the messages stored. - `released`: the messages handed to the mail server so far. Sendora releases them at the pace on [Limits](https://sendora.se/docs/limits#broadcasts). 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. - `opened` and `clicked`: the recipients who opened a message of it and who clicked a link, machines' included. A person's click counts as an open too. - `openedByPeople` and `clickedByPeople`: those of them likely to be people. - `links`: each tracked link that has a click, by its `index` in the HTML. Each has `clicked` and `clickedByPeople`, and `url` until it goes with the content. A substitution can give a link another URL per recipient, so `url` is where the first click led. The counts come from the clicks each recipient keeps, as [Limits](https://sendora.se/docs/limits#messages) says, so a recipient past those can be missing from a link. A broadcast that was not tracked reads 0 and an empty `links`. Only this read has them, not the list or a cancel. [Messages and events](https://sendora.se/docs/messages#opens-and-clicks) says how tracking is switched on and how the labels work. Each message of a broadcast is a message in the [log](https://sendora.se/docs/messages), with its own timeline and the `broadcastId`. The search filters by `broadcastId`, and every [webhook](https://sendora.se/docs/webhooks) event about such a message includes it. Follow deliveries, bounces, complaints and unsubscribes there, as for any other message. [List the broadcasts](https://sendora.se/docs/api/get-v1-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 cURL: ```bash curl -X POST https://api.sendora.se/v1/broadcasts/{broadcastId}/cancel \ -H "Authorization: Bearer $SENDORA_API_TOKEN" ``` TypeScript: ```ts 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); ``` Python: ```python 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) ``` PHP: ```php broadcasts->cancel( '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01', ); echo $cancelled->status, ' ', $cancelled->failed, "\n"; ``` Response: ```json { "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`. ## Domains Verify a sending domain with two DNS records. Your account sends only from domains it has verified, and every server of the account can send from them. Verification is two DNS records, added once, and Sendora checks them for you. You never touch your own SPF record. ## Before you start Domains belong to the account. Add and check them in the dashboard under [Domains](https://app.sendora.se/domains), or through the API with an account key (`ak_…`). An administrator creates that key under [Account › API keys](https://app.sendora.se/account/tokens). A server key can only send from the domains, as [Keys](https://sendora.se/docs/keys) explains. ## Add the domain The response has the two records to add. Sendora generates a DKIM key pair for the domain when you add it, and the private key never leaves Sendora. cURL: ```bash curl -X POST https://api.sendora.se/v1/domains \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "domain": "mail.example.se" }' ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const domain = await account.domains.create({ domain: 'mail.example.se', }); console.log( `${domain.returnPath.type} ${domain.returnPath.host} -> ${domain.returnPath.value}`, ); console.log( `${domain.dkim.type} ${domain.dkim.host} -> ${domain.dkim.value}`, ); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) domain = account.domains.create(domain="mail.example.se") for record in (domain.return_path, domain.dkim): print(record.type, record.host, "->", record.value) ``` PHP: ```php domains->create( domain: 'mail.example.se', ); $records = [$domain->returnPath, $domain->dkim]; foreach ($records as $record) { printf( "%s %s -> %s\n", $record->type, $record->host, $record->value, ); } ``` Response: ```json { "domainId": "0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01", "domain": "mail.example.se", "verified": false, "createdAt": "2026-09-15T12:00:00.000Z", "lastCheckedAt": null, "failingSince": null, "returnPath": { "type": "CNAME", "host": "sendora-bounces.mail.example.se", "value": "bounces.sendora.se", "verified": false, "verifiedAt": null }, "dkim": { "type": "TXT", "host": "sendora-a1b2c3d4._domainkey.mail.example.se", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…", "verified": false, "verifiedAt": null } } ``` Add both records at your DNS provider: - `returnPath`: a CNAME from `sendora-bounces.` to `bounces.sendora.se`. Bounce reports and feedback-loop reports for your mail arrive there and become events on your messages. - `dkim`: a TXT record under `._domainkey.` holding the public key. The response gives the selector and the value. Every message is signed with the key, so receivers see your domain vouching for the mail. The domain may also be a subdomain, such as `mail.example.se`, which keeps the records away from the rest of your zone. The From address must use a verified domain exactly: verifying `mail.example.se` does not cover `example.se` or `news.mail.example.se`. Adding a domain the account already has fails with `domain_exists`, which gives the id of the existing one. ## Check the records Sendora looks the records up on its own: an unverified domain every two minutes, a verified one every day, and one in its warning every hour. To see the result now, ask for a check: cURL: ```bash curl -X POST https://api.sendora.se/v1/domains/{domainId}/verify \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const domain = await account.domains.verify( '0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01', ); console.log( domain.verified, domain.check.returnPath, domain.check.dkim, ); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) domain = account.domains.verify( "0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01" ) print( domain.verified, domain.check.return_path, domain.check.dkim, ) ``` PHP: ```php domains->verify( '0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01', ); printf( "%s %s %s\n", $domain->verified ? 'verified' : 'not verified', $domain->check->returnPath, $domain->check->dkim, ); ``` Response: ```json { "domainId": "0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01", "domain": "mail.example.se", "verified": true, "createdAt": "2026-09-15T12:00:00.000Z", "lastCheckedAt": "2026-09-15T12:10:00.000Z", "failingSince": null, "returnPath": { "type": "CNAME", "host": "sendora-bounces.mail.example.se", "value": "bounces.sendora.se", "verified": true, "verifiedAt": "2026-09-15T12:10:00.000Z" }, "dkim": { "type": "TXT", "host": "sendora-a1b2c3d4._domainkey.mail.example.se", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…", "verified": true, "verifiedAt": "2026-09-15T12:10:00.000Z" }, "check": { "returnPath": "ok", "dkim": "ok" } } ``` `check` says what each lookup found: | `check` | Meaning | | ----------- | ------------------------------------------------------------------------------------- | | `ok` | The record is there with the right value. | | `missing` | There is no record. | | `mismatch` | A record is there with another value. | | `dns_error` | The resolver got no trustworthy answer, for example because DNSSEC validation failed. | The domain is `verified` once both records have been seen. ## When a record goes missing A verified domain rides out DNS trouble rather than stopping at the first failed lookup: - A `dns_error` changes nothing. The resolver could not answer, which says nothing about your records, so the domain stays as it was. - A record found `missing` or `mismatch` starts a 24-hour warning. The domain stays `verified` and keeps sending, `failingSince` says when the warning began, Sendora looks the records up every hour, and your account's administrators are mailed. - Any check that finds both records ends the warning. - A check 24 hours or more after `failingSince` that still misses a record ends the verification. Your administrators are mailed again, and mail from the domain is refused with `from_domain_not_verified` until both records are back. Once they are, the next check finds them within minutes. A DNS move is the usual cause. Publish both records at the new provider before you switch, and the domain never enters its warning. ## SPF, DKIM and DMARC - SPF is satisfied by the return-path domain, `sendora-bounces.`, whose records Sendora keeps. It aligns with your domain under DMARC's relaxed mode. - DKIM signs with your domain, through the key above, so it aligns strictly. - DMARC is your own record, `_dmarc.`. Both signals align, so a policy of `p=quarantine` or `p=reject` is safe for mail sent through Sendora. Sendora does not create or change your DMARC record. ## List and remove domains cURL: ```bash curl https://api.sendora.se/v1/domains \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const { domains } = await account.domains.list(); for (const domain of domains) { console.log( domain.domain, domain.verified ? 'verified' : 'waiting for DNS', ); } ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) listed = account.domains.list() for domain in listed.domains: if domain.verified: print(domain.domain, "verified") else: print(domain.domain, "waiting for DNS") ``` PHP: ```php domains->list(); foreach ($listed->domains as $domain) { if ($domain->verified) { echo $domain->domain, " verified\n"; } else { echo $domain->domain, " waiting for DNS\n"; } } ``` Response: ```json { "domains": [ { "domainId": "0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01", "domain": "mail.example.se", "verified": true, "createdAt": "2026-09-15T12:00:00.000Z", "lastCheckedAt": "2026-09-15T12:10:00.000Z", "failingSince": null, "returnPath": { "type": "CNAME", "host": "sendora-bounces.mail.example.se", "value": "bounces.sendora.se", "verified": true, "verifiedAt": "2026-09-15T12:10:00.000Z" }, "dkim": { "type": "TXT", "host": "sendora-a1b2c3d4._domainkey.mail.example.se", "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…", "verified": true, "verifiedAt": "2026-09-15T12:10:00.000Z" } } ] } ``` [Removing a domain](https://sendora.se/docs/api/delete-v1-domains-id) refuses mail from it at once and discards its key. Adding it again creates a new key and new records. ## Tracking domains A tracking domain is the host that tracked links and the open pixel use, such as `links.example.se`. It is a subdomain of a verified sending domain, so links in your mail show your own name. The host carries a stream's mail once tracking is switched on for that stream. Add and check one in the dashboard, on the domain's page under [Domains](https://app.sendora.se/domains), or through the API with an account key. [Limits](https://sendora.se/docs/limits#account) says how many an account may have. ### Add a tracking domain cURL: ```bash curl -X POST https://api.sendora.se/v1/tracking-domains \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "host": "links.example.se" }' ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const domain = await account.trackingDomains.create({ host: 'links.example.se', }); console.log(domain.cname.host, domain.cname.value); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) domain = account.tracking_domains.create( host="links.example.se" ) print(domain.cname.host, domain.cname.value) ``` PHP: ```php trackingDomains->create( host: 'links.example.se', ); printf( "%s %s\n", $domain->cname->host, $domain->cname->value, ); ``` Response: ```json { "trackingDomainId": "3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09", "host": "links.example.se", "sendingDomainId": "0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01", "state": "pending", "createdAt": "2026-11-05T12:00:00.000Z", "lastCheckedAt": null, "certificateReadyAt": null, "failingSince": null, "expiresAt": "2026-11-12T12:00:00.000Z", "cname": { "type": "CNAME", "host": "links.example.se", "value": "track.sendora.se", "check": null }, "caa": { "check": null, "record": null } } ``` Add the record in `cname` at your DNS provider: a CNAME from your host to `track.sendora.se`. If a proxy sits in front of your DNS, as Cloudflare's does, switch it off for this record. A host whose records are not right in time goes, as [Limits](https://sendora.se/docs/limits#account) says. A claim can fail with these codes: | Code | Status | Meaning | | --------------------------------- | ------ | --------------------------------------------------------------------------------------------- | | `domain_reserved` | 400 | The name is Sendora's own, or holds a domain's return path. | | `tracking_domain_exists` | 409 | The account has that host, or a host on that sending domain. `trackingDomainId` gives its id. | | `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. | | `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. | | `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. Choose a subdomain of it. | | `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. | If another account holds the host but its sending domain has lost its verification, your claim takes the host over. Links in the mail that account sent stop working then, so a domain's new owner never serves them. ### Check the records Sendora looks the records up on its own: every minute until the certificate works and for a warning's first hour, every hour for the rest of a warning, and every day once the host is ready. To see the result now, ask for a check: cURL: ```bash curl -X POST https://api.sendora.se/v1/tracking-domains/{trackingDomainId}/verify \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const domain = await account.trackingDomains.verify( '3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09', ); console.log( domain.state, domain.cname.check, domain.caa.check, ); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) domain = account.tracking_domains.verify( "3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09" ) print( domain.state, domain.cname.check, domain.caa.check, ) ``` PHP: ```php trackingDomains->verify( '3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09', ); printf( "%s %s %s\n", $domain->state, $domain->cname->check ?? 'not checked', $domain->caa->check ?? 'not checked', ); ``` Response: ```json { "trackingDomainId": "3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09", "host": "links.example.se", "sendingDomainId": "0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01", "state": "waiting_for_certificate", "createdAt": "2026-11-05T12:00:00.000Z", "lastCheckedAt": "2026-11-05T12:10:00.000Z", "certificateReadyAt": null, "failingSince": null, "expiresAt": null, "cname": { "type": "CNAME", "host": "links.example.se", "value": "track.sendora.se", "check": "ok" }, "caa": { "check": "ok", "record": null } } ``` `cname.check` says what the lookup of the CNAME found: | `check` | Meaning | | ----------- | ----------------------------------------------------------------------------------------------------------- | | `ok` | The chain of CNAME records ends at `track.sendora.se`, and the host has the same addresses. | | `missing` | The name answers nothing. | | `mismatch` | The name points elsewhere, through another CNAME or an A record. An A record fails even if it is Sendora's. | | `proxied` | A proxy answers in front of the name. Switch it off for this record. | | `dns_error` | The resolver got no trustworthy answer. | `caa.check` says whether the CAA records on the host or its nearest parent that has any let Let's Encrypt issue the certificate. When they do not, `caa.record` gives the record to add and the name to add it under: | `check` | Meaning | | ----------------- | --------------------------------------------------------- | | `ok` | There are no such records, or they allow it. | | `no_lets_encrypt` | The records do not allow Let's Encrypt. | | `other_account` | The records allow only another account at Let's Encrypt. | | `no_http_01` | The records do not allow the validation method `http-01`. | | `dns_error` | The resolver got no trustworthy answer. | `state` says where the host stands: | `state` | Meaning | | ------------------------- | -------------------------------------------------------------------------------------------------------- | | `pending` | The records have not been right yet. `expiresAt` says when the host goes. | | `waiting_for_certificate` | The records are right, and the certificate is not working yet. | | `ready` | New mail from the sending domain uses the host. | | `failing` | A check found the records wrong. A host that was ready stays in use until 24 hours after `failingSince`. | | `stopped` | Those 24 hours have passed, and new mail no longer uses the host. | | `sending_domain_lost` | The sending domain is deleted or has lost its verification, so new mail does not use the host. | ### When a tracking domain's records go wrong - A `dns_error` changes nothing. The resolver could not answer, which says nothing about your records. - A check that finds the records wrong after they have been right starts a 24-hour warning, and `failingSince` says when it began. - If the records are still wrong an hour later, your account's administrators are mailed. A new record can look missing for that long. - Any check that finds the records right ends the warning. ### Remove a tracking domain [Removing a tracking domain](https://sendora.se/docs/api/delete-v1-tracking-domains-id) stops new mail from using it at once. Keep the CNAME record for as long as links in sent mail matter, since they stop working without it. ## Keys The two kinds of API key, and how to rotate one. Every request carries an API key as a bearer token. There are two kinds, and an operation takes only one of them. ## Two kinds of key | | Server key | Account key | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Starts with | `sk_`, or `sk_test_` on a [test server](https://sendora.se/docs/test-servers) | `ak_` | | Belongs to | One server | The account | | Does | Sends, reads the message log, and manages the server's streams, suppressions, webhooks and its own keys. An SMTP client signs in with it. | Creates, renames and deletes servers, manages every server's keys, and manages the sending and tracking domains. | | Never | Manages the account's servers or domains | Sends or reads mail | | Made | With its server, or by the server's own key or the account key | By an administrator in the dashboard, under [Account › API keys](https://app.sendora.se/account/tokens). The API cannot make one. | Your application holds a server key. Keys have no narrower scopes: every key of a server can do all that a server key does. A key of the wrong kind fails with `403 wrong_token_kind`, and the message names the kind the operation needs. With the TypeScript SDK: The SDK has a client for each: `Sendora` takes a server key, and `SendoraAccount` an account key. Keep both in the environment on the server, as `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN`. Never ship either to a browser. With the Python SDK: The SDK has a client for each: `Sendora` takes a server key, and `SendoraAccount` an account key. Keep both in the environment on the server, as `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN`. Each client reads its own variable when you give it no key. With the PHP SDK: The SDK has a client for each: `Sendora` takes a server key, and `SendoraAccount` an account key. Keep both in the environment on the server, as `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN`. Each client reads its own variable when you give it no key, from `$_SERVER`, `$_ENV` or `getenv()`, so a framework's `.env` loader works. ## A server comes with its first key A new server comes with a default transactional stream and its first key, whether you create it in the dashboard or with the account key. The response shows the key's value, and only this once: cURL: ```bash curl -X POST https://api.sendora.se/v1/servers \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Invoicing" }' ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const server = await account.servers.create({ name: 'Invoicing', }); console.log(server.serverId); console.log(server.token.token); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) server = account.servers.create(name="Invoicing") print(server.server_id) print(server.token.token) ``` PHP: ```php servers->create(name: 'Invoicing'); echo $server->serverId, "\n"; echo $server->token->token, "\n"; ``` Response: ```json { "serverId": "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70", "name": "Invoicing", "mode": "live", "createdAt": "2026-09-20T12:00:00.000Z", "token": { "tokenId": "3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40", "name": "default", "prefix": "sk_a1b2c3d4e5", "createdAt": "2026-09-20T12:00:00.000Z", "revokedAt": null, "token": "sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" } } ``` Store `token.token` in your secret store. It is never shown again. ## Create and list keys A server holds at most two live keys: the one in use and the one being rotated in. The account holds at most two live account keys the same way. A third fails with `409 token_limit`. A key's name only helps people tell the keys apart. Create a key: cURL: ```bash curl -X POST https://api.sendora.se/v1/servers/{serverId}/tokens \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Invoicing system" }' ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const key = await account.servers.tokens.create( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', { name: 'Invoicing system' }, ); console.log(key.token); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) key = account.servers.tokens.create( "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70", name="Invoicing system", ) print(key.token) ``` PHP: ```php servers->tokens->create( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', name: 'Invoicing system', ); echo $key->token, "\n"; ``` Response: ```json { "tokenId": "9f2d7e6c-5b40-4e8b-8a1c-3b0c9e2f6d4a", "name": "Invoicing system", "prefix": "sk_c3d4e5f6g7", "createdAt": "2026-09-20T12:05:00.000Z", "revokedAt": null, "token": "sk_c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x" } ``` List a server's keys: cURL: ```bash curl https://api.sendora.se/v1/servers/{serverId}/tokens \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const { tokens } = await account.servers.tokens.list( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', ); for (const key of tokens) { console.log( key.name, key.prefix, key.revokedAt ? 'revoked' : 'live', ); } ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) listed = account.servers.tokens.list( "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70" ) for key in listed.tokens: print( key.name, key.prefix, "revoked" if key.revoked_at else "live", ) ``` PHP: ```php servers->tokens->list( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', ); foreach ($listed->tokens as $key) { printf( "%s %s %s\n", $key->name, $key->prefix, $key->revokedAt === null ? 'live' : 'revoked', ); } ``` Response: ```json { "tokens": [ { "tokenId": "3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40", "name": "default", "prefix": "sk_a1b2c3d4e5", "createdAt": "2026-09-20T12:00:00.000Z", "revokedAt": null }, { "tokenId": "9f2d7e6c-5b40-4e8b-8a1c-3b0c9e2f6d4a", "name": "Invoicing system", "prefix": "sk_c3d4e5f6g7", "createdAt": "2026-09-20T12:05:00.000Z", "revokedAt": null } ] } ``` These samples use the account key. A server key manages its own server's keys the same way: | Key | Manages the keys of | Path | | ----------- | ------------------------- | ------------------------------- | | Server key | Its own server | `/v1/tokens` | | Account key | Any server of the account | `/v1/servers/{serverId}/tokens` | With the TypeScript SDK: With a server key, `sendora.tokens.create({ name })`, `sendora.tokens.list()` and `sendora.tokens.revoke(tokenId)` manage the server's own keys. With the Python SDK: With a server key, `sendora.tokens.create(name=...)`, `sendora.tokens.list()` and `sendora.tokens.revoke(token_id)` manage the server's own keys. With the PHP SDK: With a server key, `$sendora->tokens->create(name: ...)`, `$sendora->tokens->list()` and `$sendora->tokens->revoke($tokenId)` manage the server's own keys. ## Rotation is create, switch, revoke 1. Create the new key. 2. Move your systems to it; both keys work meanwhile. 3. Revoke the old one. It stops working at once. cURL: ```bash curl -X DELETE https://api.sendora.se/v1/servers/{serverId}/tokens/{tokenId} \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); await account.servers.tokens.revoke( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', '3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40', ); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) account.servers.tokens.revoke( "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70", "3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40", ) ``` PHP: ```php servers->tokens->revoke( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', '3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40', ); ``` The last live key of a server or of the account cannot be revoked (`409 last_token`), so nothing is ever locked out by mistake. A revoked key stays in the list with its `revokedAt`, so the history is visible. If a key leaks, rotate it at once. You cannot revoke a server's only key, so create the new key first. For an account key, an administrator does the same under [Account › API keys](https://app.sendora.se/account/tokens). Keys are stored hashed and never shown again, so revoke any key you cannot account for. ## Test servers Send from staging and CI without delivering anything, and act out bounces, complaints, opens and clicks. A test server takes mail exactly as a live server does, and delivers none of it. Use one for your staging environment and your CI, and keep a live server for mail that must arrive. A test server works from the moment your account exists, before Sendora approves it and before payment. A send to it is checked as a live send is: the key, the From domain and the suppression list. Its limits are the test servers' own, and a paused or closed account's test servers take nothing. The message is stored in the log, and your webhooks fire. It never reaches a mail server, so nothing reaches a recipient. ## Create a test server In the dashboard, tick **Testserver** when you create the server under [Servers › New server](https://app.sendora.se/servers/new). With the account key, set `mode: "test"`: cURL: ```bash curl -X POST https://api.sendora.se/v1/servers \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "CI", "mode": "test" }' ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const server = await account.servers.create({ name: 'CI', mode: 'test', }); console.log(server.token.token); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) server = account.servers.create(name="CI", mode="test") print(server.token.token) ``` PHP: ```php servers->create( name: 'CI', mode: 'test', ); echo $server->token->token, "\n"; ``` Response: ```json { "serverId": "e4a7c2b9-3d1f-4b6e-9a8c-2f5d7b1e0c63", "name": "CI", "mode": "test", "createdAt": "2026-09-23T09:00:00.000Z", "token": { "tokenId": "c8e1f3a5-7b9d-4c2e-8f60-1a3b5c7d9e2f", "name": "default", "prefix": "sk_test_a1b2c3d4e5", "createdAt": "2026-09-23T09:00:00.000Z", "revokedAt": null, "token": "sk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v" } } ``` A test server's keys start with `sk_test_`, so a key shows its kind wherever it is written down. The server has a **Test** label in the dashboard. A server's mode is fixed when you create it. An update that sets `mode` fails with `400 mode_immutable`. ## Send with it Send as you would on a live server, with the test server's key. The answer, the message in the log and every webhook have `test: true`: 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" }, "to": [ "hardbounce+signup-test@simulator.sendora.se" ], "subject": "Confirm your address", "text": "Confirm at https://example.se/confirm/abc123." }' ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); const { messageId, test } = await sendora.email.send({ from: { email: 'no-reply@example.se', name: 'Example AB', }, to: ['hardbounce+signup-test@simulator.sendora.se'], subject: 'Confirm your address', text: 'Confirm at https://example.se/confirm/abc123.', }); console.log(messageId, test); ``` 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", }, to=["hardbounce+signup-test@simulator.sendora.se"], subject="Confirm your address", text="Confirm at https://example.se/confirm/abc123.", ) print(accepted.message_id, accepted.test) ``` PHP: ```php email->send( from: [ 'email' => 'no-reply@example.se', 'name' => 'Example AB', ], to: ['hardbounce+signup-test@simulator.sendora.se'], subject: 'Confirm your address', text: 'Confirm at https://example.se/confirm/abc123.', ); printf( "%s %s\n", $accepted->messageId, $accepted->test ? 'test' : 'live', ); ``` Response: ```json { "messageId": "0b6f2d8e-4a1c-4e7b-9d3f-5c8a2e6b1f40", "status": "accepted", "submittedAt": "2026-09-23T09:01:00.000Z", "test": true } ``` [SMTP submission](https://sendora.se/docs/smtp) works too, with the test server's key as both user name and password. The SMTP reply to the message says that it is a test. ## The simulator's addresses A test server reports every recipient as delivered, without sending anything, except at `simulator.sendora.se`. There, the part before the `@` decides what happens: | Address | What happens | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `hardbounce@simulator.sendora.se` | A hard bounce: the `bounced` webhook with `hard: true`, and the address is suppressed on that [stream](https://sendora.se/docs/streams) | | `softbounce@simulator.sendora.se` | A soft bounce, a full mailbox: the `bounced` webhook with `hard: false`, and no suppression | | `deferred@simulator.sendora.se` | The `deferred` webhook, then delivered a minute later | | `complaint@simulator.sendora.se` | Delivered, then a spam complaint: the `spam_complaint` webhook, and the address is suppressed | | `opened@simulator.sendora.se` | Delivered, then opened 5 seconds later: the `opened` webhook, if the message is tracked | | `clicked@simulator.sendora.se` | Delivered, opened 5 seconds later and clicked 5 seconds after that: the `opened` and `clicked` webhooks, if the message is tracked | | any other address | Delivered | Case does not matter. A `+label` after the name is kept, so `hardbounce+signup-test@simulator.sendora.se` bounces like `hardbounce@`. A suppression covers the whole address, label included, as on a live server. Give each test run its own label, so that a bounce in one run never blocks the next. A live server refuses these addresses before anything is stored. Sent with a live server's key, the same request fails with `test_address_on_live_server`: 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" }, "to": [ "hardbounce+signup-test@simulator.sendora.se" ], "subject": "Confirm your address", "text": "Confirm at https://example.se/confirm/abc123." }' ``` TypeScript: ```ts import { Sendora, SendoraError } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); try { await sendora.email.send({ from: { email: 'no-reply@example.se', name: 'Example AB', }, to: ['hardbounce+signup-test@simulator.sendora.se'], subject: 'Confirm your address', text: 'Confirm at https://example.se/confirm/abc123.', }); } catch (error) { if ( error instanceof SendoraError && error.code === 'test_address_on_live_server' ) { console.log(error.addresses); } else { throw error; } } ``` Python: ```python import os from sendora import Sendora, SendoraError sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) try: sendora.email.send( from_={ "email": "no-reply@example.se", "name": "Example AB", }, to=[ "hardbounce+signup-test@simulator.sendora.se" ], subject="Confirm your address", text=( "Confirm at " "https://example.se/confirm/abc123." ), ) except SendoraError as error: if error.code == "test_address_on_live_server": print(error.addresses) else: raise ``` PHP: ```php email->send( from: [ 'email' => 'no-reply@example.se', 'name' => 'Example AB', ], to: [ 'hardbounce+signup-test@simulator.sendora.se', ], subject: 'Confirm your address', text: 'Confirm at ' . 'https://example.se/confirm/abc123.', ); } catch (SendoraException $error) { $code = 'test_address_on_live_server'; if ($error->errorCode !== $code) { throw $error; } echo implode(', ', $error->addresses), "\n"; } ``` Response: ```json { "error": "test_address_on_live_server", "message": "Addresses at simulator.sendora.se act out an outcome on a test server and are never sent to from a live one. Send them from a test server.", "addresses": [ "hardbounce+signup-test@simulator.sendora.se" ] } ``` ### Opens and clicks `opened@` and `clicked@` act out what live mail records when it is [tracked](https://sendora.se/docs/messages#opens-and-clicks). Switch tracking on for the message's stream before you send, or set `trackOpens` and `trackLinks` on the send. A test server needs no tracking domain. Each address acts out only what live mail would record, and is otherwise only delivered: - An open needs the message's opens tracked and an HTML part. - A click needs its links tracked and a link that tracking would rewrite. It takes the first one, so a link marked `data-sendora-no-track` and the unsubscribe link are passed over. Without such a link, nothing is clicked. - An address on the server's [not-tracked list](https://sendora.se/docs/suppressions#the-not-tracked-list) is never opened or clicked. Each open and click reads as a person's: `automated` is `unlikely`, and `agent`, `client` and `device` are `null`. The log and the webhooks show them as they show a live message's, with `test: true`. The `opened` and `clicked` webhooks reach only a webhook that names them in its `events`, as [Webhooks](https://sendora.se/docs/webhooks#events) says. ## Limits and cost Test mail is free, and it never counts toward your plan or the account's monthly cap. All of the account's test servers share limits of their own, on [Limits](https://sendora.se/docs/limits#test-servers). A refusal under them has the scope `test`. Write to support if your CI needs more. A test server counts toward the number of servers the account may have. A test server has no inbound stream, so receive mail on a live server. Creating one fails with `422 stream_kind_not_allowed`. A test server keeps messages for less time than a live server, as [Limits](https://sendora.se/docs/limits#retention) gives. The content of attachments is deleted as soon as the simulator has the message. ## Going live A test server never becomes live. Create a live server and its key, and move your production configuration to them. Then make your application refuse a test key where mail must arrive: Over HTTP: A test key starts with `sk_test_`, and a live server's key with `sk_` alone. Check for `sk_test_` when your application starts. With the TypeScript SDK: `isTestKey` tells the two kinds of key apart: ```ts import { isTestKey, Sendora } from '@sendora/sdk'; const token = process.env.SENDORA_API_TOKEN ?? ''; if (process.env.NODE_ENV === 'production' && isTestKey(token)) { throw new Error('SENDORA_API_TOKEN is a test key; nothing would be delivered'); } const sendora = new Sendora({ token }); ``` With the Python SDK: `is_test_key` tells the two kinds of key apart: ```python import os from sendora import Sendora, is_test_key token = os.environ["SENDORA_API_TOKEN"] production = os.environ.get("APP_ENV") == "production" if production and is_test_key(token): message = ( "SENDORA_API_TOKEN is a test key; " "nothing would be delivered" ) raise RuntimeError(message) sendora = Sendora(token) ``` With the PHP SDK: `Keys::isTestKey()` tells the two kinds of key apart: ```php server->get(); if ( getenv('APP_ENV') === 'production' && $server->mode === 'test' ) { throw new RuntimeException( 'This key belongs to a test server', ); } foreach ($server->limits as $limit) { printf( "%s %s %d of %d\n", $limit->scope, $limit->period, $limit->used, $limit->limit, ); } ``` Response: ```json { "serverId": "e4a7c2b9-3d1f-4b6e-9a8c-2f5d7b1e0c63", "name": "CI", "mode": "test", "createdAt": "2026-09-23T09:00:00.000Z", "limits": [ { "scope": "test", "period": "minute", "limit": 300, "used": 1, "resetsAt": "2026-09-23T09:02:00.000Z" }, { "scope": "test", "period": "month", "limit": 10000, "used": 1, "resetsAt": "2026-10-01T00:00:00.000Z" } ], "inboundCap": null, "sendingDomains": [ { "domain": "example.se", "failingSince": null } ] } ``` ## 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 tracked copy's pixel loaded | unchanged | `Open` | `opened` | | A tracked link was followed | unchanged | `Click` | `clicked` | 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) ``` PHP: ```php messages->get( '5f432ffd-c005-499b-aa3a-5b8a088ea20e', ); foreach ($message->recipients as $recipient) { printf( "%s %s\n", $recipient->address, $recipient->status, ); } foreach ($message->events as $event) { printf( "%s %s %s %s\n", $event->at->format('c'), $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", "tracked": false, "opens": 0, "clicks": 0, "firstOpenAt": null, "firstHumanOpenAt": null } ], "tracking": { "recordingOpens": false, "recordingClicks": false, "skipped": null }, "attachments": [], "events": [ { "recipient": "anna@example.com", "type": "Reception", "at": "2026-09-15T12:00:00.000Z", "code": 250, "details": null, "classification": null, "tracking": null }, { "recipient": "anna@example.com", "type": "Delivery", "at": "2026-09-15T12:00:02.000Z", "code": 250, "details": "2.0.0 OK", "classification": null, "tracking": null } ] } ``` ## Opens and clicks The log shows the opens and clicks of mail that is tracked. An administrator switches tracking on for a stream under its **Settings** tab in the dashboard, for opens, clicks or both. The account's first switch asks them to accept the data processing agreement and to confirm consent. Tracking then applies to mail that leaves after the switch, if its from domain has a [tracking domain](https://sendora.se/docs/domains#tracking-domains) that is ready. A [test server](https://sendora.se/docs/test-servers#opens-and-clicks) needs no tracking domain, and two of its addresses act out an open and a click. A message can override its stream. A send, a batch message or a broadcast takes `trackOpens` and `trackLinks`: `true` tracks that kind, and `false` leaves it out. A field you leave out follows the stream. Over [SMTP](https://sendora.se/docs/smtp#set-the-stream-tag-metadata-and-tracking), the headers `X-Sendora-Track-Opens` and `X-Sendora-Track-Links` do the same. A stream shows its own switches as `trackOpens` and `trackLinks`, and only the dashboard changes them. | Code | Status | When | | ------------------------ | ------ | ---------------------------------------------------------------------- | | `tracking_not_confirmed` | 409 | A field is `true`, and no administrator has switched tracking on yet | | `tracking_unavailable` | 403 | A field is `true`, and Sendora has withdrawn tracking from the account | A `false` never fails, so you can always send without tracking. Each recipient has these fields, in a message you read by its id and in a search: | Field | What it says | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `tracked` | Whether tracking applied to this recipient's copy when it left. It does not say to opens, links or both, or that the HTML held a link to track | | `opens`, `clicks` | How many were recorded, machines' included | | `firstOpenAt` | The first open, or the first click that was likely a person's; `null` before that | | `firstHumanOpenAt` | The same, counting only what was likely a person; `null` before that | A message you read by its id also has `tracking`: | Field | What it says | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `recordingOpens` | Whether new opens of this message are recorded now. Its own setting decides, then its broadcast's, then its stream's at this moment. So this can change after sending, and says nothing of what a copy carries | | `recordingClicks` | The same for clicks on its links | | `skipped` | Why tracking stayed out of at least one copy, or `null`. `no_tracking_domain`: the from domain had no working [tracking domain](https://sendora.se/docs/domains#tracking-domains). `gmail_clipping`: see below | Tracked links make a message longer. Where they would take the HTML past where Gmail clips it, the links stay as written and the message reads `gmail_clipping`. The pixel still goes in. An `Open` or a `Click` on the timeline has its `code`, `details` and `classification` set to `null`, and these fields in `tracking`, which is `null` on any other event: | Field | What it says | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `automated` | Whether a machine rather than a person did it: `likely`, `unlikely`, or `unknown` where nobody can tell | | `agent` | The proxy or machine, where Sendora knows it: `apple_privacy`, `gmail_prefetch`, `gmail_proxy`, `yahoo_proxy`, `outlook_prefetch`, `proton_proxy`, `scanner` or `crawler` | | `reasons` | Why it was labelled a machine's: `privacy_proxy`, `prefetch_signature`, `delivery_timing`, `link_burst`, `scanner_network`, `bot_user_agent` or `many_networks` | | `client` | The mail program, where Sendora knows it: `apple_mail`, `gmail`, `outlook`, `yahoo`, `thunderbird`, `proton`, `browser` or `other` | | `device` | The kind of device, where Sendora knows it: `desktop`, `mobile` or `tablet` | | `first` | The recipient's first event of this kind | | `firstHuman` | The recipient's first event of this kind that was likely a person's | | `link` | The link clicked, or `null` on an `Open`: its `index` in the HTML, the same in every copy, and its `url` while the text and HTML are kept | A label is a judgement. Apple's privacy protection fetches every image whether or not anyone reads the mail, so those opens read `unknown`. A mail gateway that scans one link late can pass as a person. A person's click also counts as an open, though it adds nothing to `opens`. The timeline may not hold every open and click, while `opens` and `clicks` count them all. [Limits](https://sendora.se/docs/limits#messages) gives how many a recipient keeps and how many a read returns. ## Search the log Every filter is optional: | Filter | Matches | | ------------- | ------------------------------------------------------------------------------------ | | `recipient` | Messages to this address, or with this part of one, 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, or in any of a list of statuses | | `from` | Messages submitted at or after this time | | `to` | Messages submitted before this time | A whole address in `recipient` matches exactly. Three characters or more of one match anywhere in an address, such as `kommun.se`. `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}") ``` PHP: ```php messages->search( tag: 'welcome', from: '2026-09-01T00:00:00Z', limit: 20, ); foreach ($page->messages as $message) { printf( "%s %s %s\n", $message->submittedAt->format('c'), $message->subject, $message->recipients[0]->status ?? '', ); } if ($page->next === null) { echo "last page\n"; } else { echo "next page after {$page->next}\n"; } ``` 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", "tracked": false, "opens": 0, "clicks": 0, "firstOpenAt": null, "firstHumanOpenAt": null } ] } ], "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`. With the PHP SDK: `$sendora->messages->searchAll()` takes the same filters and walks every page for you, as a generator. Its `from` and `to` also take a `DateTimeInterface`. ## 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, with the URL of each click, whose `link` keeps its `index`. The message, its recipients and its events go 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). ## 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) ``` PHP: ```php streams->create( kind: 'inbound', name: 'Support', ); echo $stream->inboundAddress, "\n"; ``` 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, "trackOpens": false, "trackLinks": false } ``` 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 `inbound` too. 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). Past the account's [cap on received mail](https://sendora.se/docs/limits#the-cap-on-received-mail), Sendora defers new mail, so the sending server retries. After a cancellation, it also defers new mail once the account's month holds the emails that the monthly fee includes, as [Limits](https://sendora.se/docs/limits#sending-rates-and-the-monthly-cap) says. 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) ``` PHP: ```php inboundDomains->create( streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f', domain: 'post.example.se', ); foreach ([$domain->mx, $domain->txt] as $record) { printf( "%s %s -> %s\n", $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 ) ``` PHP: ```php inboundDomains->verify( '6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60', ); printf( "%s %s %s\n", $checked->verified ? 'verified' : 'not 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, ) ``` PHP: ```php inbound->search( streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f', limit: 20, ); foreach ($page->messages as $message) { printf( "%s %s %s\n", $message->receivedAt->format('c'), $message->from?->address, $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) ``` PHP: ```php inbound->get( '4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31', ); printf( "%s %s\n", $message->from?->address, $message->subject, ); echo $message->authentication->dmarc, "\n"; echo $message->text, "\n"; foreach ($message->attachments as $attachment) { echo $attachment->name, ' ', $attachment->size, "\n"; } ``` 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": "", "inReplyTo": null, "references": [], "headers": [ { "name": "From", "value": "Anna Andersson " }, { "name": "To", "value": "b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se" }, { "name": "Subject", "value": "A question about my order" }, { "name": "Message-ID", "value": "" }, { "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. With the PHP SDK: `$sendora->inbound->raw($inboundMessageId)` and `$sendora->inbound->attachment($inboundMessageId, $attachmentId)` return the content as a string of 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. ## Webhooks Events pushed to your endpoint, how to verify them, and retries and replays. A webhook is an HTTPS URL of yours that Sendora posts events to as they happen: a delivery, a bounce, a complaint, a received message. This page shows how to register one, check that each request comes from Sendora, and recover the deliveries that failed. ## Rules for your endpoint - Verify the signature against the raw body before you parse it. The steps are below. - Answer with any 2xx status, then do your work. Any other status fails the attempt, a redirect included, and so does no answer within the timeout on [Limits](https://sendora.se/docs/limits#webhooks). - Expect the same delivery more than once, and key your handling on its `id`. - Expect deliveries out of order. Sendora attempts them in order for each webhook, but a retried delivery can arrive after later ones. To order a message's events, compare their `occurredAt`. ## Register cURL: ```bash curl -X POST https://api.sendora.se/v1/webhooks \ -H "Authorization: Bearer $SENDORA_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.se/hooks/sendora", "events": [ "delivered", "bounced", "deferred", "spam_complaint" ] }' ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); const { webhookId, secret } = await sendora.webhooks.create({ url: 'https://example.se/hooks/sendora', events: [ 'delivered', 'bounced', 'deferred', 'spam_complaint', ], }); console.log(webhookId); console.log(secret); // shown this once: store it beside the token ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) webhook = sendora.webhooks.create( url="https://example.se/hooks/sendora", events=[ "delivered", "bounced", "deferred", "spam_complaint", ], ) print(webhook.webhook_id) # Shown this once: store it beside the token. print(webhook.secret) ``` PHP: ```php webhooks->create( url: 'https://example.se/hooks/sendora', events: [ 'delivered', 'bounced', 'deferred', 'spam_complaint', ], ); echo $webhook->webhookId, "\n"; // Shown this once: store it beside the token. echo $webhook->secret, "\n"; ``` Response: ```json { "webhookId": "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70", "url": "https://example.se/hooks/sendora", "events": [ "delivered", "bounced", "deferred", "spam_complaint" ], "streamId": null, "enabled": true, "inboundContent": "full", "createdAt": "2026-09-15T12:00:00.000Z", "secrets": [ { "secretId": "4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d", "createdAt": "2026-09-15T12:00:00.000Z" } ], "secret": "whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a" } ``` The response includes `secret` once: the webhook's first signing secret. Store it with your other secrets. - The URL must use HTTPS and point to a public host. If it resolves to a private address, each delivery fails at once and becomes dead, without retries. - A server has one webhook per URL. Registering a URL again fails with `webhook_exists`, which includes the existing webhook's id. - Leave out `events` to receive every event except `opened` and `clicked`, so `tracking_opted_out` is among them. Name those two to receive them, since one recipient can cause many of them. - A webhook's events cannot be changed. Instead, remove the webhook and create it again, which gives it a new secret. Events in between are not delivered. - A webhook keeps the events it was created with. One created before an event existed gets that event only once you create it again. - With `streamId`, the webhook receives the message events of that one [stream](https://sendora.se/docs/streams). Cap events belong to no stream, so they reach the webhook either way. - `inboundContent` decides how much of a received message the `inbound` event carries, as [Inbound](https://sendora.se/docs/inbound#the-inbound-event) explains. You can switch a webhook off in the dashboard. While it is off, its `enabled` is false, Sendora queues no events for it, and its pending deliveries become dead. ## Events Each delivery is one event, posted as JSON. The body has the event's name in `event`, the delivery's `id` and `attempt`, and the event's own fields. [Webhook events](https://sendora.se/docs/api/webhook-events) lists every field of every event, with an example of each. Each event's name below links to its fields. | Event | Sent when | | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`delivered`](https://sendora.se/docs/api/webhook-events#event-delivered) | The receiving server accepted the message for the recipient. | | [`bounced`](https://sendora.se/docs/api/webhook-events#event-bounced) | The receiving server refused the message, or Sendora gave up retrying. After a hard bounce, the recipient is on the stream's [suppression list](https://sendora.se/docs/suppressions). | | [`deferred`](https://sendora.se/docs/api/webhook-events#event-deferred) | The receiving server asked Sendora to try later. Sendora keeps trying, so several may follow. | | [`spam_complaint`](https://sendora.se/docs/api/webhook-events#event-spam_complaint) | The recipient reported the message as spam. The recipient is suppressed. | | [`unsubscribed`](https://sendora.se/docs/api/webhook-events#event-unsubscribed) | The recipient unsubscribed from a [broadcast stream](https://sendora.se/docs/broadcasts) and is suppressed on it. The event comes a minute after the click, since the recipient can undo it for that long. | | [`opened`](https://sendora.se/docs/api/webhook-events#event-opened) | The pixel of a tracked copy loaded, for a person or a machine. Only a webhook that names this event gets it. | | [`clicked`](https://sendora.se/docs/api/webhook-events#event-clicked) | A tracked link was followed, for a person or a machine. Only a webhook that names this event gets it. | | [`tracking_opted_out`](https://sendora.se/docs/api/webhook-events#event-tracking_opted_out) | The recipient asked not to be tracked, and is on the server's [not-tracked list](https://sendora.se/docs/suppressions#the-not-tracked-list). | | [`cap_warning`](https://sendora.se/docs/api/webhook-events#event-cap_warning) | The server or the account has used most of its monthly cap, at the share on [Limits](https://sendora.se/docs/limits#sending-rates-and-the-monthly-cap). | | [`cap_reached`](https://sendora.se/docs/api/webhook-events#event-cap_reached) | A send was refused because the monthly cap is used up. | | [`inbound`](https://sendora.se/docs/api/webhook-events#event-inbound) | A message arrived on an [inbound stream](https://sendora.se/docs/inbound). | | [`inbound_cap_warning`](https://sendora.se/docs/api/webhook-events#event-inbound_cap_warning) | The account has received most of its monthly cap on received mail, at the share on [Limits](https://sendora.se/docs/limits#the-cap-on-received-mail). | | [`inbound_cap_reached`](https://sendora.se/docs/api/webhook-events#event-inbound_cap_reached) | Received mail reached the account's monthly cap. Mail to the account is refused for now, so senders retry, until the month ends or the cap is raised. | Only Sendora support can lift a spam complaint or an unsubscribe, as [Suppressions](https://sendora.se/docs/suppressions#lift-a-suppression) explains. When you get `unsubscribed`, take the recipient off your own list too. A message event has `test` set to true when the message was on a [test server](https://sendora.se/docs/test-servers). Its events were simulated, and nothing was sent. `tracking_opted_out` goes on every account, since a recipient can [ask not to be tracked](https://sendora.se/docs/suppressions#let-a-recipient-ask) from untracked mail too. Unlike `opened` and `clicked`, it reaches a webhook that leaves out `events`. No event goes when the recipient asks from mail that has left the log, or after an erasure, since it could name nobody. `opened` and `clicked` go only for mail that is tracked. Each one carries the labels on [Messages and events](https://sendora.se/docs/messages#opens-and-clicks), which say whether a person or a machine caused it. A recipient's opens and clicks past the number on [Limits](https://sendora.se/docs/limits#messages) send no event. A `clicked` event's `details.link.url` is `null` once the message's text and HTML have gone, so a replay after that has no URL either. ## Verify the signature Every request carries `Sendora-Signature: t=,v1=[,v1=]`. To verify it: 1. Read `t` and every `v1` from the header. There is one `v1` for each live secret of the webhook, the newest first. 2. Compute an HMAC-SHA256 of `.` in hex. Its key is your whole secret, `whsec_` included. 3. Compare it with each `v1` in constant time. One match is enough. 4. Refuse the request if `t` is more than five minutes from your clock, so a captured request cannot be replayed. Only then parse the body. Sendora signs each attempt as it sends it, so a retry or a replay carries a new `t` and passes step 4. The sample shows the steps in shell, and the one call that does all four in each SDK: The recipe, in shell: ```bash # Sendora-Signature: t=,v1=[,v1=] # One v1 per live secret of the webhook, the newest first; each is # HMAC-SHA256 with that secret over ".". expected=$(printf '%s.%s' "$T" "$RAW_BODY" | openssl dgst -sha256 -hmac "$SENDORA_WEBHOOK_SECRET" | sed 's/^.* //') # Compare in constant time in your language, refuse a t more than five minutes from your clock, then parse the body. # One matching v1 is enough; $V1S holds every v1 of the header, one per line. printf '%s\n' "$V1S" | grep -qx "$expected" && echo "signed by Sendora" ``` TypeScript: ```ts import { verifyWebhook } from '@sendora/sdk'; /* * A route handler for any framework that hands you a Fetch Request: * Next.js, Hono, SvelteKit, Cloudflare Workers. With Express or Fastify, * keep the raw body and pass it and the header the same way. */ export async function POST( request: Request, ): Promise { const event = await verifyWebhook({ secret: process.env.SENDORA_WEBHOOK_SECRET, signature: request.headers.get('sendora-signature'), body: await request.text(), }); if (event.event === 'bounced' && event.details.hard) { console.log( `${event.recipient} is undeliverable: ${event.details.classification ?? 'unknown'}`, ); } return new Response(null, { status: 204 }); } ``` Python: ```python import os from collections.abc import Mapping from sendora import ( BouncedEvent, WebhookVerificationError, verify_webhook, ) # A handler for any framework: give it the raw body # exactly as it arrived and the request's headers. def receive( body: bytes, headers: Mapping[str, str] ) -> int: # Header names ignore case, and a plain dict does not. lowered = { name.lower(): value for name, value in headers.items() } try: event = verify_webhook( body, signature=lowered.get("sendora-signature"), secret=os.environ["SENDORA_WEBHOOK_SECRET"], ) except WebhookVerificationError: return 400 if ( isinstance(event, BouncedEvent) and event.details.hard ): print( f"{event.recipient} is undeliverable: " f"{event.details.classification or 'unknown'}" ) return 204 ``` PHP: ```php getContent() and the header the same way. $header = $_SERVER['HTTP_SENDORA_SIGNATURE'] ?? null; try { $event = WebhookVerifier::verify( (string) file_get_contents('php://input'), signature: is_string($header) ? $header : null, secret: (string) getenv('SENDORA_WEBHOOK_SECRET'), ); } catch (WebhookVerificationException) { http_response_code(400); exit; } if ( $event instanceof BouncedEvent && $event->details->hard ) { error_log(sprintf( '%s is undeliverable: %s', $event->recipient, $event->details->classification ?? 'unknown', )); } http_response_code(204); ``` With the TypeScript SDK: `verifyWebhook` returns the event, typed by its `event` field, so `details` has the right type. An event newer than your release of the SDK comes as an `unknown` event, with its name in `name` and the payload in `data`, rather than an error. It throws `stale_signature` when the signature is more than five minutes old, and `invalid_signature` when no signature in the header matches. Set `toleranceSeconds` to allow another age. With a client at hand, `sendora.webhooks.receive(request, secret)` does the same from a Fetch `Request` in one call. With the Python SDK: `verify_webhook` returns the event as a class of its own, such as `BouncedEvent`, so its `details` are typed. An event newer than your release of the SDK comes as an `UnknownEvent`, with the payload in `data`, rather than an error. It raises `WebhookVerificationError` with `stale_signature` when the signature is more than five minutes old, and with `invalid_signature` when no signature in the header matches. Pass `tolerance`, in seconds, to allow another age. The raw body is `request.get_data()` in Flask and `await request.body()` in FastAPI. In Django, read it with `request.read()`, since `request.body` refuses a body over `DATA_UPLOAD_MAX_MEMORY_SIZE`, 2.5 MB by default. An `inbound` event with its content can be larger, as [Limits](https://sendora.se/docs/limits#inbound-mail) shows. Pass the body and `request.headers` to `receive`, and answer with the status it returns. In Flask, pass `dict(request.headers)`, since type checkers do not take Werkzeug's headers as a `Mapping`. Exempt the Django view from the CSRF check with `csrf_exempt`, since Sendora sends no CSRF token. With the PHP SDK: `WebhookVerifier::verify()` returns the event as a class of its own, such as `BouncedEvent`, so its `details` are typed. An event newer than your release of the SDK comes as an `UnknownEvent`, with the payload in `data`, rather than an error. It throws a `WebhookVerificationException` with `stale_signature` when the signature is more than five minutes old, and with `invalid_signature` when no signature in the header matches. Pass `tolerance:`, in seconds, to allow another age. The raw body is `file_get_contents('php://input')` in plain PHP, and `$request->getContent()` in Symfony and Laravel. The header is `$_SERVER['HTTP_SENDORA_SIGNATURE']`, or `$request->headers->get('Sendora-Signature')` in either framework. An `inbound` event with its content can be larger than nginx's default `client_max_body_size` of 1 MB, as [Limits](https://sendora.se/docs/limits#inbound-mail) shows, so raise it for the webhook's route. In Laravel, the package's route does all of this, as the next section shows. A route of your own needs an exemption from CSRF protection, since Sendora sends no CSRF token. ## Receive in Laravel The Laravel package, `sendora/sendora-laravel`, receives the deliveries for you. Its route checks each signature and fires a Laravel event for each delivery, so your code listens as it does to any Laravel event. 1. Register the webhook with the address of the package's route, such as `https://example.se/sendora/webhook`. 2. Put its secret in `SENDORA_WEBHOOK_SECRET`, and deploy. If the app caches its config or its routes, run `config:cache` and `route:cache` again. 3. Listen for the events you want, with a listener in `app/Listeners` or with `Event::listen()`. The route is `POST /sendora/webhook`, named `sendora.webhook`. It exists once a secret is set, and sits outside the `web` middleware group, so Laravel's CSRF check never applies to it. It answers a bad or stale signature with 400 and fires nothing. Otherwise it answers 204 once the listeners that are not queued have run, or 500 when one of them throws, so Sendora retries. Queue slow work, since Sendora waits only as long as [Limits](https://sendora.se/docs/limits#webhooks) says. | Event | Laravel event in `Sendora\Laravel\Events` | | --------------------- | ----------------------------------------- | | `delivered` | `MessageDelivered` | | `bounced` | `MessageBounced` | | `deferred` | `MessageDeferred` | | `spam_complaint` | `SpamComplaintReceived` | | `unsubscribed` | `RecipientUnsubscribed` | | `opened` | `MessageOpened` | | `clicked` | `LinkClicked` | | `tracking_opted_out` | `RecipientOptedOut` | | `cap_warning` | `CapWarning` | | `cap_reached` | `CapReached` | | `inbound` | `InboundEmailReceived` | | `inbound_cap_warning` | `InboundCapWarning` | | `inbound_cap_reached` | `InboundCapReached` | | any other | `UnknownWebhookReceived` | Each Laravel event holds the delivery's event in `$event->webhook`, as `WebhookVerifier::verify()` returns it, and every one implements `WebhookReceived`. To roll the secret, list the new one beside the old in `SENDORA_WEBHOOK_SECRET`, separated by a comma, as the next section explains. ## Roll the secret A webhook can have two live secrets at once, so you can replace one without a gap: 1. Create a new secret. From then on, every delivery is signed with both. cURL: ```bash curl -X POST https://api.sendora.se/v1/webhooks/{webhookId}/secrets \ -H "Authorization: Bearer $SENDORA_API_TOKEN" ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); const rolled = await sendora.webhooks.createSecret( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', ); console.log(rolled.secretId, rolled.secret); ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) rolled = sendora.webhooks.create_secret( "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70" ) print(rolled.secret_id, rolled.secret) ``` PHP: ```php webhooks->createSecret( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', ); echo $rolled->secretId, ' ', $rolled->secret, "\n"; ``` Response: ```json { "secretId": "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e", "createdAt": "2026-09-20T12:00:00.000Z", "secret": "whsec_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f" } ``` 2. Move your receiver to the new secret. Deliveries verify with either one meanwhile. 3. Delete the old secret by its id, which the webhook's `secrets` lists. cURL: ```bash curl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId}/secrets/{secretId} \ -H "Authorization: Bearer $SENDORA_API_TOKEN" ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); await sendora.webhooks.deleteSecret( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', '4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d', ); ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) sendora.webhooks.delete_secret( "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70", "4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d", ) ``` PHP: ```php webhooks->deleteSecret( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', '4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d', ); ``` A third live secret fails with `secret_limit`. Deleting the last live secret fails with `last_secret`, so every delivery carries a signature. You can also roll the secret on the webhook's page in the dashboard, under the server's [Webhooks](https://app.sendora.se/servers) tab. ## Retries, dead letters and replay Sendora retries a failed attempt with growing delays, for about a day, on the schedule on [Limits](https://sendora.se/docs/limits#webhooks). After the last retry, the delivery is `dead`. It stays listed until you replay it. cURL: ```bash curl 'https://api.sendora.se/v1/webhooks/{webhookId}/deliveries?status=dead&limit=50' \ -H "Authorization: Bearer $SENDORA_API_TOKEN" ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); const { deliveries } = await sendora.webhooks.deliveries( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', { status: 'dead', limit: 50, }, ); for (const delivery of deliveries) { console.log( delivery.deliveryId, delivery.event, delivery.attempts, delivery.lastError, ); } ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) page = sendora.webhooks.deliveries( "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70", status="dead", limit=50, ) for delivery in page.deliveries: print( delivery.delivery_id, delivery.event, delivery.attempts, delivery.last_error, ) ``` PHP: ```php webhooks->deliveries( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', status: 'dead', limit: 50, ); foreach ($page->deliveries as $delivery) { printf( "%s %s %d %s\n", $delivery->deliveryId, $delivery->event, $delivery->attempts, $delivery->lastError, ); } ``` Response: ```json { "deliveries": [ { "deliveryId": "9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c60", "event": "bounced", "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e", "inboundMessageId": null, "status": "dead", "attempts": 9, "nextAttemptAt": null, "lastStatusCode": 503, "lastError": "http_503", "deliveredAt": null, "createdAt": "2026-09-14T12:00:02.000Z" } ], "next": null } ``` A replay queues a delivery again, whatever its state, with the same `id` and event. Its `attempt` keeps counting. The [reference](https://sendora.se/docs/api/post-v1-webhooks-id-deliveries-deliveryid-replay) has the call. ## Remove cURL: ```bash curl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId} \ -H "Authorization: Bearer $SENDORA_API_TOKEN" ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); await sendora.webhooks.delete( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', ); ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) sendora.webhooks.delete( "7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70" ) ``` PHP: ```php webhooks->delete( '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70', ); ``` Deliveries still pending for the webhook are dropped with it. ## Suppressions Addresses a stream no longer sends to, why they are there, and how to lift one, and the list of addresses a server never tracks. Each [stream](https://sendora.se/docs/streams) of a server keeps its own list of addresses it will not send to. If any recipient of a message is suppressed, Sendora refuses the whole message with `recipient_suppressed` and sends it to no one. The error lists the suppressed addresses and the stream. A [broadcast](https://sendora.se/docs/broadcasts) instead drops suppressed addresses and sends to the rest. The suppression calls take `streamId`, and without it they mean the server's default transactional stream. ## How an address gets there | Reason | Added when | Who can lift it | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- | | `hard_bounce` | A receiver refused the address for good, for instance because the mailbox does not exist. The `bounced` webhook tells you which message. | You | | `spam_complaint` | The recipient reported a message as spam, and the receiver told Sendora through its feedback loop. The `spam_complaint` webhook tells you. | Sendora support only | | `unsubscribe` | The recipient used the unsubscribe link of a message on a [broadcast stream](https://sendora.se/docs/broadcasts), or their mail client's one-click button. The `unsubscribed` [webhook](https://sendora.se/docs/webhooks) tells you. | Sendora support only | | `manual` | You added the address in the dashboard. The API cannot add one. | You | After one hard bounce, every later send to that recipient on the stream fails with `recipient_suppressed`. A soft bounce or a deferral never suppresses a recipient. ## Read the list cURL: ```bash curl 'https://api.sendora.se/v1/suppressions?limit=100' \ -H "Authorization: Bearer $SENDORA_API_TOKEN" ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); const { suppressions } = await sendora.suppressions.list({ limit: 100, }); for (const entry of suppressions) { console.log( entry.address, entry.reason, entry.createdAt, ); } ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) page = sendora.suppressions.list(limit=100) for entry in page.suppressions: print(entry.address, entry.reason, entry.created_at) ``` PHP: ```php suppressions->list(limit: 100); foreach ($page->suppressions as $entry) { printf( "%s %s %s\n", $entry->address, $entry->reason, $entry->createdAt->format('c'), ); } ``` Response: ```json { "suppressions": [ { "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "address": "bounced@example.com", "reason": "hard_bounce", "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e", "createdAt": "2026-09-14T12:00:05.000Z" } ], "next": null } ``` The list is newest first, in the page sizes on [Limits](https://sendora.se/docs/limits#page-sizes). Pass `next` as `after` for the next page. Each entry has the `streamId` of its list. To read another stream's list, pass that stream's `streamId` in the query. A stream of another server fails with `stream_not_found`. Each stream's **Suppressions** tab, under its server's Streams tab in [Servers](https://app.sendora.se/servers), shows the list. There you add addresses, and lift the entries you may lift. ## Lift a suppression Lift a hard bounce or a manual entry when the recipient can take mail again, for instance once they have confirmed a corrected mailbox. The address travels in the request body, never in a URL. `streamId` in the body names the stream, and without it the default one. cURL: ```bash curl -X POST https://api.sendora.se/v1/suppressions/delete \ -H "Authorization: Bearer $SENDORA_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "address": "bounced@example.com" }' ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); await sendora.suppressions.delete({ address: 'bounced@example.com', }); ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) sendora.suppressions.delete(address="bounced@example.com") ``` PHP: ```php suppressions->delete( address: 'bounced@example.com', ); ``` Lifting a spam complaint or the recipient's own unsubscribe fails with `spam_complaint_locked` or `unsubscribe_locked`. To have one lifted, write to [support@sendora.se](mailto:support@sendora.se) with the recipient's own request and the entry's `streamId` and `messageId` from the list. Leave the address out, since the stream and the message are enough to find the entry. ## The not-tracked list Each server keeps a list of addresses that are never tracked, on any of its streams. Mail to a listed address still goes out, without tracked links or a pixel. Opens and clicks of mail already sent to it are not recorded either. This list has nothing to do with the suppression lists above, and an address can be on both. The list works whether tracking is on or not, so you can list an address before your first tracked mail. | Source | Added when | Who can remove it | | ----------- | -------------------------------------- | ---------------------- | | `manual` | You added the address. | You | | `recipient` | The recipient asked not to be tracked. | You, after new consent | Add an address with the server's key. The address travels in the request body, never in a URL. An address already on the list changes nothing: the entry comes back as it stands, so a repeat is safe. cURL: ```bash curl -X POST https://api.sendora.se/v1/tracking-opt-outs \ -H "Authorization: Bearer $SENDORA_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "address": "anna@example.com" }' ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); const entry = await sendora.trackingOptOuts.add({ address: 'anna@example.com', }); console.log(entry.source, entry.createdAt); ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) entry = sendora.tracking_opt_outs.add( address="anna@example.com", ) print(entry.source, entry.created_at) ``` PHP: ```php trackingOptOuts->add( address: 'anna@example.com', ); printf( "%s %s\n", $entry->source, $entry->createdAt->format('c'), ); ``` Response: ```json { "address": "anna@example.com", "source": "manual", "messageId": null, "createdAt": "2026-11-06T12:00:00.000Z" } ``` Read the list, 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 'https://api.sendora.se/v1/tracking-opt-outs?limit=100' \ -H "Authorization: Bearer $SENDORA_API_TOKEN" ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); const { trackingOptOuts } = await sendora.trackingOptOuts.list({ limit: 100 }); for (const entry of trackingOptOuts) { console.log(entry.address, entry.source); } ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) page = sendora.tracking_opt_outs.list(limit=100) for entry in page.tracking_opt_outs: print(entry.address, entry.source) ``` PHP: ```php trackingOptOuts->list(limit: 100); foreach ($page->trackingOptOuts as $entry) { printf( "%s %s\n", $entry->address ?? 'erased', $entry->source, ); } ``` Response: ```json { "trackingOptOuts": [ { "address": "anna@example.com", "source": "manual", "messageId": null, "createdAt": "2026-11-06T12:00:00.000Z" } ], "next": null } ``` Remove an address when it may be tracked again. Remove an entry whose `source` is `recipient` only after the recipient has given new consent. An address that is not on the list fails with `not_found`. cURL: ```bash curl -X POST https://api.sendora.se/v1/tracking-opt-outs/delete \ -H "Authorization: Bearer $SENDORA_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "address": "anna@example.com" }' ``` TypeScript: ```ts import { Sendora } from '@sendora/sdk'; const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN, }); await sendora.trackingOptOuts.delete({ address: 'anna@example.com', }); ``` Python: ```python import os from sendora import Sendora sendora = Sendora(os.environ["SENDORA_API_TOKEN"]) sendora.tracking_opt_outs.delete( address="anna@example.com", ) ``` PHP: ```php trackingOptOuts->delete( address: 'anna@example.com', ); ``` Each server's **Not tracked** tab, in [Servers](https://app.sendora.se/servers), shows the list. There you add, find and remove addresses too. ### Let a recipient ask Write `{{ tracking_opt_out_url }}` in the text or the HTML of a message on any stream, where the link should go. Sendora then sends one copy per recipient, each with the recipient's own link, so a message with large attachments costs a copy per recipient. A substitution with that name fails with `invalid_request`. The link opens a page in Swedish and English that says what its button does. Opening the page changes nothing, so a mail scanner that follows links cannot opt anyone out. The button puts the address on the list with the `source` `recipient`, and your [webhook](https://sendora.se/docs/webhooks#events) gets `tracking_opted_out`. Pressing it again changes nothing. If you had added the address yourself, the entry becomes the recipient's, and the dashboard warns you before you remove it. The link keeps working after the message has left the log, and after an erasure, until the account closes. Sendora then knows only a keyed hash of the address, so no webhook goes. The entry has `address` set to `null`, unless you had added the address yourself. Later mail to the address stays untracked. ## Erasure requests When a recipient asks you to erase their data, erase them with an account key. Every message to or from the address, sent and received, loses its content, and the address becomes a pseudonym in the log of every server. cURL: ```bash curl -X POST https://api.sendora.se/v1/erasures \ -H "Authorization: Bearer $SENDORA_ACCOUNT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "address": "anna@example.com" }' ``` TypeScript: ```ts import { SendoraAccount } from '@sendora/sdk'; const account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN, }); const erasure = await account.erasures.create({ address: 'anna@example.com', }); console.log(erasure.erasureId, erasure.status); ``` Python: ```python import os from sendora import SendoraAccount account = SendoraAccount( os.environ["SENDORA_ACCOUNT_TOKEN"] ) erasure = account.erasures.create( address="anna@example.com" ) print(erasure.erasure_id, erasure.status) ``` PHP: ```php erasures->create( address: 'anna@example.com', ); echo $erasure->erasureId, ' ', $erasure->status, "\n"; ``` Response: ```json { "erasureId": "6f1d2c3b-4a5e-4f60-8b7a-9c8d7e6f5a41", "status": "pending", "createdAt": "2026-10-04T12:00:00.000Z", "completedAt": null, "pseudonym": null, "messages": null, "receivedMessages": null } ``` The erasure returns `pending`, and Sendora carries it out within a minute. [Read the erasure](https://sendora.se/docs/api/get-v1-erasures-id) to see it `done`, with the pseudonym and how many messages lost their content. On the suppression lists, a hard bounce is removed. A spam complaint, an unsubscribe or a manual entry keeps only a keyed hash of the address, so the recipient stays protected from further mail without the address being stored. An erasure blocks nothing else: later mail to or from the address is sent and received as before. On the not-tracked list, an entry also keeps only a keyed hash of the address, so later mail to the address stays untracked. The list shows that entry with `address` set to `null`, and adding the address again or the recipient asking again does not bring it back. To remove the entry, name the address: the keyed hash finds it. Nobody can remove it without the address, since nobody has consent from a person they cannot name. ## SMTP submission Send from a system that only speaks SMTP, with the same key and rules. Use SMTP when a system cannot call an HTTP API, such as an ERP or a printer. A message sent over SMTP goes through the same checks as an API send. It appears in the same log, gets the same events and counts against the same limits. To move a system that signs in to a Microsoft 365 mailbox with a password, follow [Move from Microsoft 365](https://sendora.se/docs/microsoft-365). ## Connect | Setting | Value | | ---------------------- | ---------------------------------------------------------------------------------- | | Host | `smtp.sendora.se` | | Port | 465 with TLS from the start, or 587 with STARTTLS | | User name and password | A server key, `sk_…` or a [test server](https://sendora.se/docs/test-servers)'s `sk_test_…`, as both | | Authentication | `PLAIN` or `LOGIN`, once TLS is on | The `From` address must be on one of the server's verified domains. The [message limits](https://sendora.se/docs/limits#messages) apply as they do over the API, with `To`, `Cc` and `Bcc` counted together. TypeScript: ```ts import nodemailer from 'nodemailer'; /** Sends one message over SMTP with a server's API token as both user name and password. */ export async function sendOverSmtp( token: string, ): Promise { const transport = nodemailer.createTransport({ host: 'smtp.sendora.se', port: 587, secure: false, requireTLS: true, auth: { user: token, pass: token }, }); const info = await transport.sendMail({ from: '"Example AB" ', to: 'anna@example.com', subject: 'Your account is ready', text: 'Hi Anna, your account at Example is ready.', headers: { 'X-Sendora-Tag': 'welcome', 'X-Sendora-Metadata-Customer': '4711', }, }); return info.response; // "250 2.0.0 OK: queued as " } ``` Python: ```python import smtplib import ssl from email.message import EmailMessage # Sends one message over SMTP with a server's API # token as both user name and password. def send_over_smtp(token: str) -> None: message = EmailMessage() message["From"] = "Example AB " message["To"] = "anna@example.com" message["Subject"] = "Your account is ready" message["X-Sendora-Tag"] = "welcome" message["X-Sendora-Metadata-Customer"] = "4711" message.set_content( "Hi Anna, your account at Example is ready." ) # Without a context of its own, starttls() would # accept any certificate, and without a timeout # smtplib waits forever on a stalled connection. context = ssl.create_default_context() with smtplib.SMTP( "smtp.sendora.se", 587, timeout=30 ) as smtp: smtp.starttls(context=context) smtp.login(token, token) smtp.send_message(message) ``` PHP: ```php from('Example AB ') ->to('anna@example.com') ->subject('Your account is ready') ->text( 'Hi Anna, your account at Example is ready.', ); $email->getHeaders() ->add(new TagHeader('welcome')) ->add(new MetadataHeader('Customer', '4711')); $mailer->send($email); } ``` Sendora delivers only to the envelope recipients, the addresses your client gives in `RCPT TO`. The `To` and `Cc` headers decide how each of them is shown, and an envelope recipient that neither header names is sent as Bcc. An address that only a header names gets nothing. ## Send from Django Django sends over SMTP out of the box and checks the server's certificate when it starts TLS. In `settings.py`, use the server key from the environment as both user name and password. `DEFAULT_FROM_EMAIL` must be on a verified domain, and so must `SERVER_EMAIL`, the address Django sends its error mail from. ```python import os # In settings.py, for Django's SMTP backend, its # default: the server key as user name and password. EMAIL_HOST = "smtp.sendora.se" EMAIL_PORT = 587 EMAIL_USE_TLS = True EMAIL_HOST_USER = os.environ["SENDORA_API_TOKEN"] EMAIL_HOST_PASSWORD = os.environ["SENDORA_API_TOKEN"] EMAIL_TIMEOUT = 30 DEFAULT_FROM_EMAIL = "Example AB " SERVER_EMAIL = DEFAULT_FROM_EMAIL ``` Django 6.1 replaces the `EMAIL_*` settings with `MAILERS` and refuses to start with both. On 6.1, put the same values in `MAILERS["default"]["OPTIONS"]` as `host`, `port`, `use_tls`, `username`, `password` and `timeout`. `DEFAULT_FROM_EMAIL` and `SERVER_EMAIL` stay as they are. ## Send from Laravel or Symfony Laravel and Symfony send over SMTP with their own mailers. Use the server key as both user name and password, on port 465 with TLS from the start. Set `SENDORA_API_TOKEN` to the key in `.env`, and the settings below read it. The `From` address must be on a verified domain. In Laravel, set the `smtp` mailer in `.env`: ```bash MAIL_MAILER=smtp MAIL_SCHEME=smtps MAIL_HOST=smtp.sendora.se MAIL_PORT=465 MAIL_USERNAME="${SENDORA_API_TOKEN}" MAIL_PASSWORD="${SENDORA_API_TOKEN}" MAIL_FROM_ADDRESS=no-reply@example.se ``` A mailable's `tags` and `metadata` become the message's tag and metadata, as the next section says. Give a mailable one tag, since a message with two different tags is refused. To send through the API instead, with each message's id in hand, use the Laravel package, as [Send from Laravel](https://sendora.se/docs/sending#send-from-laravel) shows. In Symfony, set `MAILER_DSN` in `.env`: ```bash MAILER_DSN=smtps://${SENDORA_API_TOKEN}:${SENDORA_API_TOKEN}@smtp.sendora.se:465 ``` Add the tag with Symfony's `TagHeader` and each metadata entry with its `MetadataHeader`. ## Set the stream, tag, metadata and tracking | Header | Sets | | -------------------------- | ------------------------------------------------------------------------------------------------ | | `X-Sendora-Stream` | The [stream](https://sendora.se/docs/streams) the message goes on, by its id | | `X-Sendora-Tag` | The message's tag | | `X-Sendora-Metadata-` | One metadata entry: `X-Sendora-Metadata-Customer: 4711` sets `{ "customer": "4711" }` | | `X-Sendora-Track-Opens` | Whether the message's opens are [tracked](https://sendora.se/docs/messages#opens-and-clicks), as `true` or `false` | | `X-Sendora-Track-Links` | Whether the message's links are tracked, as `true` or `false` | Without `X-Sendora-Stream`, the message goes on the server's default transactional stream. Sendora also reads `X-Tag` and `X-Metadata-`, which Laravel's and Symfony's mailers write for a message's tags and metadata. `X-Sendora-Tag` and `X-Sendora-Metadata-` win over them. A message has one tag: the same tag given twice counts once, and two different tags are refused with `550 5.6.0`. A metadata key is lowercased, since a header's name ignores case. The two tracking headers override the stream's switches for this message, as `trackOpens` and `trackLinks` do over the API. Without them, the stream decides. Each is `true` or `false` in any case, and the same value given twice counts once. Another value, an empty one, or both `true` and `false` are refused with `550 5.6.0`. A `true` is refused with `550 5.7.1` until an administrator has switched tracking on for a stream in the dashboard. It is also refused where Sendora has withdrawn tracking from the account. Attachments are ordinary MIME parts. Sendora also keeps `Reply-To`, `In-Reply-To`, `References`, `List-Unsubscribe`, `List-Unsubscribe-Post` and your own `X-` headers, but not the headers that this section names, which it reads. A `References` header longer than 998 characters keeps its first id and its latest ones. Sendora drops other headers, such as `Message-ID`, `Received` or `DKIM-Signature`, and writes its own. The API refuses these headers instead of dropping them. ## SMTP replies Sendora accepts or refuses a message before the session ends. A `250` means the message is stored and already in the log, and Sendora never refuses it after that. Delivery outcomes, such as a bounce, arrive later as events. A 5xx reply means a retry will not help. A 4xx reply means a retry may succeed. | Reply | When | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `250 2.0.0 OK: queued as ` | Accepted. The id is the one the log and the webhooks use. | | `530 5.7.0` | The client sent a message without authenticating. | | `535 5.7.8` | The key is wrong, revoked or an [account key](https://sendora.se/docs/keys), or the user name and password differ. | | `550 5.7.1` | The `From` domain is not verified, or the account is paused. On a live server, the account is not approved, or it waits for a payment, as [Errors](https://sendora.se/docs/errors#when-an-account-cannot-send) describes. It also covers a stream in `X-Sendora-Stream` that is unknown, archived, paused or inbound, and a tracking header's `true` that the account cannot use. | | `550 5.1.1` | A recipient is suppressed, or a live server was given an address at `simulator.sendora.se`. The text lists the addresses. | | `550 5.6.0` | The API would refuse the message as invalid, or it breaks the [rules of a broadcast stream](https://sendora.se/docs/broadcasts#rules-of-a-broadcast-stream). The text gives the reasons. | | `552 5.3.4` | The message is over the [size limit](https://sendora.se/docs/limits#messages). | | `452 4.5.3` | The message has more recipients than [a message may have](https://sendora.se/docs/limits#messages). Split them over several messages. | | `451 4.7.0` | The per-minute limit or the monthly cap is reached. The text says when to try again. | | `451 4.3.2` | Sending is disabled for everyone for the moment. | | `451 4.3.0` | A temporary failure on Sendora's side. | ## Errors The error body, every error code, and what stops an account from sending. Every error from the API is JSON, with an `error` code for your program and a `message` for a person. A request that fails validation also has `issues`, one per invalid field: ```json { "error": "invalid_request", "message": "The request is invalid: to.0: Invalid email address", "issues": [{ "path": "to.0", "message": "Invalid email address" }] } ``` Some errors add fields, such as the suppressed recipients or when a limit resets. Each operation's page in the [reference](https://sendora.se/docs/api) lists them. Two refusals are plain text, not JSON: `429 Too Many Requests` and `413 Request Entity Too Large`. They come from the proxy in front of the API, as [Limits](https://sendora.se/docs/limits#requests-per-second) explains. A body over the size limit meets the proxy first, so it gets the plain-text 413. ## Retry or fix - `rate_limited` (429): wait the seconds in `retryAfter`, which the `Retry-After` header also gives, then send again. The refused message was not stored. If you meet this limit often, ask support to raise it. - `sending_disabled` (503): sending is disabled for everyone for the moment. Retry after the seconds in the `Retry-After` header. - `monthly_cap_reached` (429): wait until `resetsAt`, or have an administrator of your account raise the cap under [Account › Usage](https://app.sendora.se/account/usage). [Limits](https://sendora.se/docs/limits#sending-rates-and-the-monthly-cap) says how far they can raise it. After a cancellation, the code can also mean that the month holds all the emails that the monthly fee includes, and the `message` says so. Then only undoing the cancellation under [Account › Billing](https://app.sendora.se/account/billing) lets the live servers send more. - A 5xx status: retry with a growing delay, under the same idempotency key so the message cannot go twice. - Any other code: read its meaning in the table below. Most need a fix to the request or the account, and fail the same way if sent again unchanged. On `rate_limited` and `monthly_cap_reached`, `scope` says whose limit it is: the account's (`account`), the server's own (`server`) or the test servers' (`test`). Test mail never counts toward the account's monthly cap. A [webhook](https://sendora.se/docs/webhooks) can warn you before the cap is used up. With the TypeScript SDK: The SDK throws `SendoraError` for every failed call. It has the same `code`, the HTTP `status` and the body's extra fields, such as `issues`, `retryAfter`, `scope`, `limit`, `cap`, `used`, `resetsAt`, `suppressed` and `existingId`. `retryable` says whether trying again could succeed. A lost connection or a timeout is the same error, with `connection_failed` or `timeout` and no status. The SDK retries rate limits, `sending_disabled` and server errors itself for a few seconds. If `retryAfter` is longer than that, it throws at once so you can schedule the retry. With the Python SDK: The SDK raises a `SendoraError` for every failed call. Its class depends on the status, such as `RateLimitError` for 429. It has the same `code`, the HTTP `status` and the body's extra fields, such as `issues`, `retry_after`, `scope`, `limit`, `cap`, `used`, `resets_at`, `suppressed` and `existing_id`. `retryable` says whether trying again could succeed. A lost connection is an `APIConnectionError` with `connection_failed`, and a timeout an `APITimeoutError` with `timeout`, both without a status. The SDK retries rate limits, `sending_disabled` and server errors itself for a few seconds. If `retry_after` is longer than that, it raises at once so you can schedule the retry. With the PHP SDK: The SDK throws a `SendoraException` for every failed call. Its class depends on the status, such as `RateLimitException` for 429. The code is in `errorCode` and the HTTP status in `status`, with the body's extra fields beside them, such as `issues`, `retryAfter`, `scope`, `limit`, `cap`, `used`, `resetsAt`, `suppressed` and `existingId`. `isRetryable()` says whether trying again could succeed. A lost connection is an `ApiConnectionException` with `connection_failed`, and a timeout an `ApiTimeoutException` with `timeout`, both without a status. The SDK retries rate limits, `sending_disabled` and server errors itself for a few seconds. If `retryAfter` is longer than that, it throws at once so you can schedule the retry. ## When an account cannot send - `payment_required` (402): the account's live servers wait for a payment. They send once the account's first payment has arrived. If an invoice is still unpaid after its retries, they stop until it is paid. If the subscription has ended, no payment reopens it, and an administrator starts the subscription again. If Sendora has paused the payment, support tells you why. [Account › Billing](https://app.sendora.se/account/billing) in the dashboard shows where the account stands. A [test server](https://sendora.se/docs/test-servers) works meanwhile. - `account_not_active` (403): Sendora has not approved the account yet. Every account is reviewed by hand before a live server can send. A [test server](https://sendora.se/docs/test-servers) works before that. - `account_paused` (403): Sendora has paused the account. Support tells you why. Over SMTP, the same refusals arrive as SMTP replies, which [SMTP submission](https://sendora.se/docs/smtp#smtp-replies) lists. ## Error codes | Code | Status | Meaning | | --- | --- | --- | | `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. | | `account_paused` | 403 | The account is paused by Sendora. | | `broadcast_not_open` | 409 | The broadcast is completed or cancelled already; nothing is left to cancel. | | `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. | | `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. | | `default_stream` | 409 | The default stream of a server cannot be archived. | | `domain_exists` | 409 | The account already has that domain. | | `domain_reserved` | 400 | The name is Sendora’s own, or a return-path host; no account can receive on it. | | `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. | | `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. | | `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. | | `inbound_domain_exists` | 409 | The stream already has a domain, or another account holds the name verified. | | `inbound_stream_exists` | 409 | A server has one inbound stream at a time; archive it to make another. | | `invalid_request` | 400 | The body, the query or a header does not match what the route takes. | | `last_secret` | 409 | The only live secret of a webhook cannot be deleted; create another first. | | `last_token` | 409 | This is the only live key of the server; create another before revoking it. | | `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. | | `mode_immutable` | 400 | A server’s mode is fixed when it is created; create a new server for the other mode. | | `monthly_cap_reached` | 429 | The monthly cap of the account, the server or the test servers is used up. After a cancellation, the account's cap is the emails that the monthly fee includes. | | `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. | | `not_found` | 404 | No such broadcast of this server. | | `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. | | `rate_limited` | 429 | More was sent within a minute than the limit allows. | | `read_only_connection` | 403 | A connected app may only read here: the account is read only through a connection, and a live server takes changes only if the person allowed them when connecting. | | `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. | | `request_too_large` | 413 | The body exceeds 10 MB. | | `secret_limit` | 409 | The webhook already holds two live secrets: one in use and one to roll to. Delete one before creating another. | | `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. | | `server_exists` | 409 | The account already has a server with that name. | | `server_in_flight` | 409 | Messages of the server are still being delivered; try again once they have left. | | `server_limit` | 409 | The account has as many servers as it may; Sendora raises the cap on request. | | `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. | | `spam_complaint_locked` | 403 | The recipient reported a message as spam; only Sendora support lifts that, at the recipient’s own request. | | `stream_archived` | 422 | The stream is archived and takes no new messages. | | `stream_exists` | 409 | The server already has a stream with this name, archived or not. | | `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. | | `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; the streamId names a transactional one. | | `stream_not_found` | 422 | The streamId names no stream of this server. | | `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. | | `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. | | `substitution_missing` | 422 | The subject, text or HTML names a {{ key }} that a message does not give. | | `test_address_on_live_server` | 422 | Addresses at simulator.sendora.se act out an outcome on a test server; a live server never sends to them. | | `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. | | `token_limit` | 409 | The server or the account already holds two live keys: one in use and one to rotate to. Revoke one before creating another. | | `tracking_domain_exists` | 409 | The account already has that host, or a host on that sending domain. | | `tracking_domain_limit` | 409 | The account has as many tracking domains as it may have. | | `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. A tracking domain is a subdomain of one. | | `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of this account. | | `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. | | `tracking_not_confirmed` | 409 | The request sets trackOpens or trackLinks to true before an administrator has first switched tracking on in the dashboard. | | `tracking_unavailable` | 403 | The request sets trackOpens or trackLinks to true, and tracking is not open to the account. | | `unauthorized` | 401 | The key is missing, malformed or revoked. | | `unsubscribe_locked` | 403 | The recipient unsubscribed themselves; only Sendora support lifts that, at the recipient’s own request. | | `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. | | `webhook_exists` | 409 | The server already has a webhook for that URL. | | `wrong_token_kind` | 403 | The key is of the other kind: a server key (sk_) where an account key (ak_) is needed, or the reverse. The message names the kind the operation takes. | ## Limits Every limit an account meets, and its number. Every limit an account meets, with its number. When a send meets one, [Errors](https://sendora.se/docs/errors) says what the refusal looks like. To raise a per-minute limit, the test servers' limits, the number of servers or a limit marked "unless Sendora sets another", write to support. ## Sending rates and the monthly cap Emails are counted one per recipient, across `to`, `cc` and `bcc`. An account that pays by card counts its month from the day and time of its first payment to the same day and time the next month. When the next month has no such day, its month ends on that month's last day. Any other account's month is the calendar month in UTC. `GET /v1/server` and [Account › Usage](https://app.sendora.se/account/usage) say when the month ends. | Limit | Value | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Per minute, per account | 300 by default | | Monthly cap | Set at approval from the monthly volume the application named: 10,000, 50,000, 250,000 or 1,000,000; above that, a figure Sendora sets | | Highest monthly cap | 10 times the cap approval set, unless Sendora sets another | | Warning | A `cap_warning` webhook at 80 percent of the cap, once a month | | After a cancellation | The 10,000 emails that the monthly fee includes, sent and received together | - An administrator moves the monthly cap under [Account › Usage](https://app.sendora.se/account/usage), anywhere up to the highest. Write to support to go past that. - Once an account has cancelled its subscription, its month may hold no more than the emails that the monthly fee includes. A send past them fails with `monthly_cap_reached` until the subscription ends, unless the cancellation is undone under [Account › Billing](https://app.sendora.se/account/billing). It sends no `cap_reached` webhook. - After a cancellation, mail to the account's inbound addresses and domains is deferred once its month holds those emails, as past the [cap on received mail](https://sendora.se/docs/limits#the-cap-on-received-mail). No `inbound_cap_reached` webhook goes. - An account approved without an application starts at the monthly cap of the smallest volume. - Sendora can give a server a per-minute limit and a monthly cap of its own, only ever tighter than the account's. - A broadcast counts in full against the monthly cap as soon as Sendora accepts it. The per-minute limit does not apply to broadcasts. ## Test servers | Limit | Value | | ------------------------------------------ | ----------------- | | Per minute, all the account's test servers | 300 by default | | Per month, all the account's test servers | 10,000 by default | Test mail never counts toward the account's monthly cap. The test servers' month is always the calendar month in UTC. A refusal under these limits has the scope `test`. ## Account | Object | Limit | | ------------------------------- | ------------------------------------------ | | Servers | 10 by default, more on request | | Keys | 2 live per server, and 2 live account keys | | Webhook secrets | 2 live per webhook | | Inbound streams | One live per server | | Tracking domains | 10 per account, one per sending domain | | A tracking domain's first check | Its records must be right within 7 days | | Names of servers, streams, keys | Up to 100 characters | Sending domains, webhooks and transactional and broadcast streams have no limit of their own. ## Requests | Limit | Value | | ----------------- | ------------------------------------------------------- | | Request body | 10 MB, and 64 MB for `POST /v1/broadcasts` | | `Idempotency-Key` | Up to 255 printable characters, remembered for 24 hours | | Batch | Up to 100 messages | ## Messages | Limit | Value | | --------------------- | --------------------------------------------------------------- | | Recipients | 50, across `to`, `cc` and `bcc` | | Size | 10 MB as built, with attachments counted after base64 encoding | | Subject | 998 characters | | Attachments | 20 | | Custom headers | 20 | | Metadata | 20 entries, keys of up to 40 characters, values of up to 500 | | Tag | Up to 100 characters | | Opens and clicks kept | 100 of each per recipient; `opens` and `clicks` go on counting | | Opens and clicks read | 500 per message; each recipient's first and earliest come first | ## Broadcasts | Limit | Value | | ------------- | ------------------------------------------------------------------------------------------------------------------------ | | Messages | 50,000 per broadcast | | Size | 50 MB for the whole broadcast as built, and 10 MB for each message in it with its substitutions filled in | | Substitutions | 20 per message, each up to 500 characters | | Pace | 600 messages a minute per broadcast stream, unless Sendora sets another | | The brake | The stream pauses when complaints pass 0.1 percent of its deliveries in the last 24 hours, once those are at least 1,000 | ## SMTP submission | Limit | Value | | ----------- | ---------------------------------------- | | Connections | 200 at once on each listener | | Idle | A connection is closed after 60 seconds | | Message | 10 MB and 50 recipients, as over the API | ## Inbound mail | Limit | Value | | ------------ | ------------------------------------------------------------------------------------------- | | Size | 35 MB per message | | Rate | 300 recipients a minute per account, unless Sendora sets another | | Recipients | 50 per delivery from a sending server | | Connections | 300 a minute from one sending server | | Content kept | The stream's `contentRetentionDays`, 1 to 30 days, 30 by default | | Addresses | `to` and `cc` hold up to 100 entries each, with the whole number in `toCount` and `ccCount` | | Domain grace | Mail to a verified domain whose records went missing is deferred for 72 hours, then refused | If a received message passes one of these limits, Sendora still keeps it whole. `parseIssue` says which limit, and you can download the raw message while the stream keeps its content: | Parser limit | Value | | --------------- | ------------------------------------------- | | Header block | 256 KB and 1,000 lines | | Parts | 500 MIME parts, nested up to 50 deep | | Text and HTML | 1 MB of text and 2 MB of HTML once decoded | | Decoded content | 4 times the size of the message as received | | Time | 20 seconds to parse | | Attachments | The first 100 are kept | | Header values | The first 2,000 characters are kept | | Address lists | The first 1,000 addresses are kept | If a message passes the header, part, size or time limit, its parsed text, HTML and attachments are empty. If it passes one of the last three, Sendora parses it and leaves out the rest. ## The cap on received mail | Limit | Value | | ----------- | ---------------------------------------------------------------------------------------------------- | | Monthly cap | The account's monthly cap on sending, until an administrator moves it, up to the highest monthly cap | | Warning | An `inbound_cap_warning` webhook at 80 percent of the cap, once a month | - Received mail counts toward this cap alone. It never counts toward the monthly cap on sending, and sent mail never counts toward this one. - The cap counts the same month as sending does. - An administrator moves the cap under [Account › Usage](https://app.sendora.se/account/usage). Until then, it follows the monthly cap on sending. - Past the cap, Sendora refuses new mail to the account's inbound addresses and domains for now, so the sending server retries, until the month ends or the cap is raised. A sending server gives up after some days, so mail can be lost. An `inbound_cap_reached` webhook goes when the cap is reached. ## Retention | What | Kept for | | -------------------------------------------------------------------------- | ------------------------------------------------------------- | | A message's text and HTML | 30 days | | Its attachments' content | Until Sendora's mail server has the message, or it has failed | | The message, its recipients and events, and a received message's reference | 13 months | | On a test server | 7 days for the text and HTML, 30 days for the rest | ## Webhooks | Limit | Value | | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Timeout | 10 seconds for an answer; a redirect is not followed and counts as a failure | | Retries | After 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours; then the delivery is dead until you replay it | ## Page sizes | Lists | Page size | | -------------------------------------------------- | --------------------------- | | Messages, received messages, broadcasts | Up to 100, 50 by default | | Suppressions, not-tracked list, webhook deliveries | Up to 1,000, 100 by default | ## Sign-in | Limit | Per 15 minutes | | ------------- | ----------------------------- | | Sign-in tries | 10 per address, 30 per client | | Mailed links | 3 per address, 10 per client | | Invitations | 3 per address, 20 per client | | SSO sign-ins | 30 per client | ## SSO and SCIM | Limit | Value | | --------------- | ----------------------------- | | SSO connections | 5 per account | | SSO domains | 20 per account | | Sign-in tests | 20 per account per 15 minutes | | Links to IT | 5 per account per 15 minutes | | SCIM tokens | 2 live per account | | SCIM requests | 1,200 per token in 1 minute | | SCIM lists | Up to 100 users a page | ## Requests per second Before any request reaches the API, the proxy holds each client to 20 requests a second, with bursts of up to 50, and each body to the sizes under Requests. Its refusals are plain text, not JSON: `429 Too Many Requests` and `413 Request Entity Too Large`. ## MCP server Connect claude.ai, ChatGPT, Claude Code, Cursor, VS Code, Codex or Claude Desktop to Sendora's docs and tools. Sendora's MCP server gives a coding agent, such as Claude Code, Cursor or VS Code, Sendora's documentation and tools while it builds your integration. The agent can read the guides and the API reference, send a test mail and read what happened to it, and set up streams, webhooks and sending domains. It runs two ways, with the same tools: - Locally, as `npx -y @sendora/mcp`, over stdio. The documentation is built into the package, so it answers offline. - Hosted, at `https://mcp.sendora.se/mcp`. You connect your app with OAuth, or it sends your key as a bearer token. Without either, `https://mcp.sendora.se/docs` serves the documentation. ## Before you start - The local server needs Node 20.19 or newer. The hosted server and the Claude Desktop bundle need nothing installed. - For anything beyond the documentation, the agent needs a connection that you approve with OAuth, or a key. [Connect with OAuth](#connect-with-oauth) says when each fits. - With OAuth, Codex CLI must be 0.147.0 or later, which `codex --version` shows. Cursor must be up to date, since some older versions, such as 3.19.7, cannot connect. ## Connect with OAuth With OAuth, you add the hosted server to your app, then sign in to Sendora and approve the app. You need no key, so no key sits on your machine. Sendora holds the app to what you approve. This is how claude.ai and ChatGPT connect. Use a key instead for the local server or Claude Desktop's bundle, or for an agent that adds sending domains with the account key. Then go on to [Choose a key](#choose-a-key). Open your app's steps: ### claude.ai and Claude Desktop On a Team or Enterprise plan, an Owner adds Sendora under **Organization settings › Connectors**. There the Owner selects **Add**, then **Custom**, then **Web** if Claude asks, and goes on from step 2. Each member then opens **Customize › Connectors** and starts at step 3. 1. Open **Customize › Connectors** and select **Add custom connector**. 2. Name it Sendora and enter `https://mcp.sendora.se/mcp` as the server URL. Leave the other settings as they are, and select **Add**. 3. Select **Connect** beside Sendora. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says. 4. In a chat, select **+**, then **Connectors**, and turn Sendora on. The connector also works in Claude's mobile apps. ### ChatGPT 1. In ChatGPT on the web, open **Plugins**, select **Add**, then **Add custom MCP server**. 2. Name it Sendora. Under **Connection**, enter `https://mcp.sendora.se/mcp`. 3. Choose OAuth as the authentication, with no client ID or secret. Select **I understand and want to continue**, then **Create as a plugin**. 4. When ChatGPT asks, select **Continue to Sendora**. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says. 5. In a chat, type `@` and choose Sendora. ### Claude Code 1. Add Sendora to `.mcp.json` at your project's root. The timeout lets `send_email` wait up to a minute to learn whether the message was delivered. ```json { "mcpServers": { "sendora": { "type": "http", "url": "https://mcp.sendora.se/mcp", "timeout": 90000 } } } ``` 2. In Claude Code, run `/mcp`, select sendora and authenticate. Claude Code opens your browser. 3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says. ### VS Code 1. Add Sendora to `.vscode/mcp.json` in your project, or to the file that **MCP: Open User Configuration** opens: ```json { "servers": { "sendora": { "type": "http", "url": "https://mcp.sendora.se/mcp" } } } ``` 2. Run **MCP: List Servers** from the Command Palette, select sendora and start it. When VS Code asks to authenticate, allow it, and your browser opens. 3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says. ### Cursor 1. Add Sendora to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in your project: ```json { "mcpServers": { "sendora": { "url": "https://mcp.sendora.se/mcp" } } } ``` 2. Open **Customize** in Cursor's sidebar, and connect Sendora's MCP server there. Cursor opens your browser. 3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says. ### Codex 1. Add Sendora to `~/.codex/config.toml`. The timeout lets `send_email` wait its full minute. ```toml [mcp_servers.sendora] url = "https://mcp.sendora.se/mcp" tool_timeout_sec = 90 ``` 2. Run `codex mcp login sendora`. Codex opens your browser. 3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says. ### Approve the app Sendora's approval page opens in your browser, always at `app.sendora.se`. If you are not signed in, Sendora asks you to sign in first. The page names the app, where it comes from, such as claude.ai, and the account you connect to. Nothing is ticked when the page opens. Tick at least one server or the account: - The page lists each server you may open yourself, marked live or test. Tick those the app may reach. - An administrator may also tick the account. The app can then read the account's sending domains and list the servers you tick. It can never add a sending domain or delete a server. - The box for changes on live servers lets the app send real mail and make changes on the live servers you tick. Without it, the app only reads them. Then approve, and your browser returns to the app. If you cancel instead, the app is told that you refused. On each server you tick, the app gets the tools that the server's key would, with the box for changes as its [switch](#turn-on-changes). If you later lose access to a server, the app loses it too. What the app reads, such as the message log, leaves Sendora, and the app's provider handles it from then on. The page warns you when its answer goes to a program on your own computer, as it does for Claude Code, VS Code and Codex. Approve only if you started the connection yourself just now. Cursor registers itself with Sendora, so for Cursor the page says that Sendora cannot confirm who made the app. It may also say that the answer goes to `www.cursor.com`, which passes it on to Cursor on your computer. ### End a connection Your connections are under [Account › Connected apps](https://app.sendora.se/account/apps), with what each reaches and when it ends. End one there, and the app loses its access within a minute. To change what an app reaches, end its connection and connect the app again. A connection also ends 90 days after you approve it, or after 30 days without use. Until then, the app renews its access by itself. VS Code may first ask you to click **Authenticate**, and then goes on without a new approval. Once a connection has ended, connect the app again. An administrator sees every connection in the account and can end any of them. At the bottom of the same page, an administrator can also turn connected apps off for the whole account. That ends every connection, and nobody can connect an app until an administrator turns them on again. Resetting your password through the mailed link also ends your connections. ### If the app cannot connect | What you see | Why | What to do | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | Cursor shows "redirect_uris must list one or more URIs, and each must have no fragment or credentials and be https, or http on localhost, 127.0.0.1 or [::1]." | Some older versions of Cursor, such as 3.19.7, also offer Sendora a `cursor://` address, which Sendora refuses | Update Cursor, restart it and connect again | | After you approve, the browser says "Authentication complete", but Codex reports "failed to handle OAuth callback" and "Authorization server response missing required issuer" | A bug in Codex 0.143.0 to 0.146.1 | Update Codex to 0.147.0 or later, then run `codex mcp login sendora` again | ## Choose a key The MCP server lists only the tools your key allows, so the agent cannot call any other: - With a test server's key (`sk_test_…`), the agent can do everything on that server, and no mail is delivered. Start here. A [test server](https://sendora.se/docs/test-servers) works before your account is approved. It takes mail as a live one does, and its simulator's addresses act out bounces, deferrals, complaints, opens and clicks. - With a live server's key (`sk_…`), the agent reads the server's message log, received mail and settings. Once you [turn on changes](#turn-on-changes), it can also send real mail to real people and make other changes. - With the account key (`ak_…`), the agent reads the account's servers and sending domains. With changes on, it can also add a sending domain or delete a test server, never a live one. - With no key, the agent reads the documentation alone. | What the tool does | No key | Test key | Live key | Account key | | --- | --- | --- | --- | --- | | Reads the documentation | yes | yes | yes | yes | | Reads | no | yes | yes | yes | | Changes | no | yes | with changes on | with changes on | | Shows a new key or signing secret | no | yes | no | no | | Cannot be undone | no | yes | no | with changes on | **Cannot be undone** covers revoking a key, deleting a webhook or a test server, archiving a stream, and sending or cancelling a broadcast. A new key or a webhook's signing secret reaches the agent only on a test server. With a live key, the agent sends you to the dashboard to make one. [Keys](https://sendora.se/docs/keys) says where each key is made. ## Install Set your key as the environment variable `SENDORA_API_TOKEN`, from your shell profile or your secret manager. The configurations for Claude Code, Cursor and Codex read it from there. VS Code and Claude Desktop ask for the key instead and keep it in their own secret storage. Either way, no file you commit holds the key. Open your client's setup: ### Claude Code Add Sendora to `.mcp.json` at your project's root. Claude Code fills in `${SENDORA_API_TOKEN}` from its environment when it connects, so the file holds no key and you can commit it. Locally: ```json { "mcpServers": { "sendora": { "type": "stdio", "command": "npx", "args": ["-y", "@sendora/mcp"], "env": { "SENDORA_API_TOKEN": "${SENDORA_API_TOKEN}" } } } } ``` Or hosted, with a timeout that lets `send_email` wait up to a minute to learn whether the message was delivered: ```json { "mcpServers": { "sendora": { "type": "http", "url": "https://mcp.sendora.se/mcp", "headers": { "Authorization": "Bearer ${SENDORA_API_TOKEN}" }, "timeout": 90000 } } } ``` Don't add the server with `claude mcp add` and the key in `--header` or `--env`. Your shell puts the key's value in the command, and Claude Code writes it to its configuration. Without a key, one command adds the documentation: ```bash claude mcp add --transport http sendora-docs https://mcp.sendora.se/docs ``` ### Cursor [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=sendora&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBzZW5kb3JhL21jcCJdLCJlbnYiOnsiU0VORE9SQV9BUElfVE9LRU4iOiIke2VudjpTRU5ET1JBX0FQSV9UT0tFTn0ifX0=) installs the local server in one click, with the key from `SENDORA_API_TOKEN`. By hand, edit `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` for every project. Cursor fills in `${env:SENDORA_API_TOKEN}` from its environment. Locally: ```json { "mcpServers": { "sendora": { "type": "stdio", "command": "npx", "args": ["-y", "@sendora/mcp"], "env": { "SENDORA_API_TOKEN": "${env:SENDORA_API_TOKEN}" } } } } ``` Or hosted: ```json { "mcpServers": { "sendora": { "url": "https://mcp.sendora.se/mcp", "headers": { "Authorization": "Bearer ${env:SENDORA_API_TOKEN}" } } } } ``` ### VS Code [Add to VS Code](vscode:mcp/install?%7B%22name%22%3A%22sendora%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40sendora%2Fmcp%22%5D%2C%22env%22%3A%7B%22SENDORA_API_TOKEN%22%3A%22%24%7Binput%3Asendora-key%7D%22%7D%2C%22inputs%22%3A%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22sendora-key%22%2C%22description%22%3A%22Sendora%20key%22%2C%22password%22%3Atrue%7D%5D%7D) installs the local server in your profile in one click. By hand, edit `.vscode/mcp.json` in your project. Either way, VS Code asks for the key the first time the server starts, and keeps it in its secret storage. Locally: ```json { "inputs": [ { "type": "promptString", "id": "sendora-key", "description": "Sendora key", "password": true } ], "servers": { "sendora": { "type": "stdio", "command": "npx", "args": ["-y", "@sendora/mcp"], "env": { "SENDORA_API_TOKEN": "${input:sendora-key}" } } } } ``` Or hosted: ```json { "inputs": [ { "type": "promptString", "id": "sendora-key", "description": "Sendora key", "password": true } ], "servers": { "sendora": { "type": "http", "url": "https://mcp.sendora.se/mcp", "headers": { "Authorization": "Bearer ${input:sendora-key}" } } } } ``` ### Codex Edit `~/.codex/config.toml`. Don't use `codex mcp add --env`, which writes the key's value into the file. Codex passes a local server a few system variables, such as `PATH` and `HOME`, and of the rest only those you name. It gives a tool a minute unless told otherwise, so both entries set `tool_timeout_sec` to let `send_email` wait its full minute. Locally: ```toml [mcp_servers.sendora] command = "npx" args = ["-y", "@sendora/mcp"] env_vars = ["SENDORA_API_TOKEN"] tool_timeout_sec = 90 ``` Or hosted: ```toml [mcp_servers.sendora] url = "https://mcp.sendora.se/mcp" bearer_token_env_var = "SENDORA_API_TOKEN" tool_timeout_sec = 90 ``` ### Claude Desktop [Download the bundle](https://sendora.se/downloads/sendora-mcp.mcpb) and open it. Claude Desktop asks whether to install it, and runs it on the Node it brings. 1. Paste your key into **Sendora key**, which Claude Desktop keeps in your operating system's secure storage. Leave it empty for the documentation alone. 2. Tick **Allow changes** only when you want changes on. Each release comes with a new bundle here, which you install over the old one. To connect Claude Desktop without a key, [connect with OAuth](#connect-with-oauth) instead. ## Check the connection Ask the agent which Sendora server it is connected to. With a server's key, it calls `get_server`, which returns the server's name and mode, whether changes are on, its limits and the sending domains its mail may come from. With the account key, it calls `list_servers`. | What you see | Why | What to do | | ----------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | The agent has only `search_docs` and `read_doc` | No key reached the server | Set `SENDORA_API_TOKEN` in the environment the client starts from, or enter the key in VS Code's prompt or Claude Desktop's **Sendora key**. Then start the client again | | The client shows the server as failed | The key did not reach the server, or the API refused it | Check that the client has the key, and that the key is still live in the dashboard | Then give the agent a task: - With a test server's key, ask: "Send a test mail to any address you like, and tell me whether it was delivered." - With a live server's key, ask: "Why did the last message to `anna@example.com` bounce?" - With no key, ask: "Add a password-reset mail to this app with `@sendora/sdk`." ## Turn on changes A live server's key and the account key list only the reading tools until you turn on changes for the connection: - Locally, add `--allow-writes` after `@sendora/mcp` in the arguments, or set `SENDORA_ALLOW_WRITES=true` in the server's environment. - Hosted, send the header `Sendora-Allow-Writes: true` beside the key. - In Claude Desktop, tick **Allow changes**. - Through OAuth, tick the box for changes on live servers when you approve the app. The account stays read only. Any value but `true` leaves changes off. A test server's key needs no switch. With changes on, the MCP server tells the agent to confirm with you before anything reaches a real person. Your client also asks before any call you have not allowed. The switch decides which tools the MCP server lists. It does not limit the key itself. An agent that runs commands or reads files on your machine can read a key kept in its environment, and call the API with it directly. So give an agent a live key only if you would trust it with everything the key can do. VS Code's prompt and Claude Desktop's bundle keep the key out of your environment. ## Received mail A live server's key lists the tools that read the mail your inbound stream receives. The sender wrote everything in that mail, and an agent reading it may also have your terminal, your files and your other MCP servers. So the tools limit what the agent sees: - `search_inbound` returns only two of the addresses the sender chose: the From address and the address the mail was sent to. - `read_inbound_message` returns the rest between two markers with a new random value each time. Before them comes a notice that everything between the markers is data from the sender, not instructions. This lessens prompt injection without preventing it. A model can still do what a mail asks: run a command, read a file, call another server's tool, answer the mail, or send your key somewhere. The two addresses sit outside the markers, and the sender wrote them too. The From address is wholly theirs. In the address the mail was sent to, they chose the text after `+` on a stream's address at `inbound.sendora.se`, and the whole local part on your own domain. - Read what the agent proposes before you approve it. - Keep changes off unless the task needs them. - Give an agent that reads received mail no more tools than the task needs. - If you think a key reached someone it should not have, revoke it in the dashboard. ## Tools The table lists every tool of the MCP server. **Keys** says which keys list each one: `test` for a test server's key, `live` for a live server's and `account` for the account key. "With changes on" marks a tool that needs the switch. | Tool | What it does | Keys | | --- | --- | --- | | `search_docs` | Search the Sendora docs | any, or none | | `read_doc` | Read a Sendora document | any, or none | | `get_server` | Read the server this key belongs to | test, live | | `send_email` | Send one email | test, live with changes on | | `search_messages` | Search the message log | test, live | | `get_message` | Read one message and its timeline | test, live | | `list_suppressions` | List a stream's suppression list | test, live | | `remove_suppression` | Lift a suppression | test, live with changes on | | `list_tracking_opt_outs` | List the not-tracked addresses | test, live | | `add_tracking_opt_out` | Add a not-tracked address | test, live with changes on | | `remove_tracking_opt_out` | Remove a not-tracked address | test, live with changes on | | `create_stream` | Add a stream | test, live with changes on | | `archive_stream` | Archive a stream | test | | `send_broadcast` | Send a test broadcast | test | | `get_broadcast` | Read a broadcast | test, live | | `cancel_broadcast` | Cancel a broadcast | test | | `list_webhooks` | List the server's webhooks | test, live | | `list_webhook_deliveries` | List a webhook's deliveries | test, live | | `replay_webhook_delivery` | Replay a webhook delivery | test, live with changes on | | `create_webhook` | Add a webhook | test | | `delete_webhook` | Delete a webhook | test | | `list_keys` | List the server's keys | test, live | | `create_key` | Create a key | test | | `revoke_key` | Revoke a key | test | | `search_inbound` | Search received mail | live | | `read_inbound_message` | Read a received message | live | | `list_inbound_domains` | List the domains for receiving | live | | `check_inbound_domain` | Check a domain for receiving | live | | `add_inbound_domain` | Add a domain for receiving | live with changes on | | `list_servers` | List the account's servers | account | | `list_domains` | List the sending domains | account | | `check_domain` | Check a sending domain | account | | `add_domain` | Add a sending domain | account with changes on | | `list_tracking_domains` | List the tracking domains | account | | `check_tracking_domain` | Check a tracking domain | account | | `add_tracking_domain` | Add a tracking domain | account with changes on | | `delete_test_server` | Delete a test server | account with changes on | ## Screens In a client that shows MCP Apps, such as claude.ai, ChatGPT, Claude Desktop, VS Code or Cursor, four tools also show their answer on a screen: - `send_email` and `get_message` show the message's timeline: each recipient's status, the stream, and every event with its time and the receiver's answer. - `add_domain` and `check_domain` show the domain's two DNS records, whether each checks out, and a button that checks them again. Every other client gets the same answer as text. A screen loads nothing from anywhere else. ## Documents and prompts The documentation is built into the MCP server at each release, from the same sources as these pages: - the guides, as `sendora://docs/…` - the API reference, as `sendora://reference/…` - the error codes, as `sendora://errors` - the TypeScript SDK's README and rules for agents, as `sendora://sdk/readme` and `sendora://sdk/skill` - the Python SDK's, as `sendora://sdk/python/readme` and `sendora://sdk/python/skill` - the PHP SDK's, as `sendora://sdk/php/readme` and `sendora://sdk/php/skill` - the Laravel package's README, as `sendora://sdk/laravel/readme` `sendora://index` lists them all. `search_docs` searches every document but the rules, which an agent reads whole. `read_doc` reads one document whole. The server has three prompts, which a client offers as commands: - `set_up_sendora`: install the SDK for the project's language, keep the key in the environment, send a test through a test server and verify webhooks. - `why_not_delivered`: read a message's timeline, the suppression list and the error codes, and explain what happened. - `verify_webhooks`: implement signature verification for the webhook endpoint and test it. ## The official server Sendora's MCP server is `@sendora/mcp` on npm, the hosted server at `https://mcp.sendora.se/mcp` and `https://mcp.sendora.se/docs`, and the bundle on this site, and nothing else. The MCP Registry lists it as `se.sendora/mcp`, a name that only sendora.se can publish. A server under another name is not Sendora's, and it could read every key and message it is given. ## Changelog What each release of the SDKs changed, and what to change in your code when you upgrade. What each release of the SDKs changed, newest first. A major version can break code written for the one before it, and its entry says what to change. ## TypeScript SDK The package is `@sendora/sdk` on npm. ### 5.6.1 The README and the skill say that `opened@` and `clicked@simulator.sendora.se` act out an open and a click on a test server's tracked mail. No call changes. ### 5.6.0 `email.send()`, each message of `email.sendBatch()` and `broadcasts.send()` take `trackOpens` and `trackLinks`. They switch [tracking](https://sendora.se/docs/messages#opens-and-clicks) on or off for that mail, whatever its stream's switches say. Each `Stream` has `trackOpens` and `trackLinks`, its own switches. There are two new error codes, `tracking_not_confirmed` and `tracking_unavailable`, and a batch item's `error` can be either. No call changes. ### 5.5.1 The README no longer says that tracking is not open to accounts. It says that an administrator switches tracking on for a stream in the dashboard. No call changes. ### 5.5.0 A new webhook event, `tracking_opted_out`, with the type `TrackingOptedOutEvent`: a recipient [asked not to be tracked](https://sendora.se/docs/suppressions#let-a-recipient-ask). `webhooks.create()` without `events` takes it. A `switch` over `WebhookEventName` needs the new value. An entry's `address` can also be `null` when the recipient asked from mail that had left the log. No call changes. ### 5.4.0 `sendora.trackingOptOuts` lists, adds and removes the addresses on a server's [not-tracked list](https://sendora.se/docs/suppressions#the-not-tracked-list): `list()`, `listAll()`, `add()` and `delete()`. No call changes. ### 5.3.0 Two webhook events, `opened` and `clicked`, with the types `OpenedEvent` and `ClickedEvent`. A webhook gets them only if it names them, so `webhooks.create()` without `events` takes every other event. A `switch` over `WebhookEventName` needs the two new values. No call changes. See [Webhooks](https://sendora.se/docs/webhooks#events). ### 5.2.0 Each recipient of a message has `tracked`, `opens`, `clicks`, `firstOpenAt` and `firstHumanOpenAt`. `messages.get()` returns `tracking`, and its timeline holds `Open` and `Click` events, each with its `tracking`. `broadcasts.get()` returns `BroadcastDetail`, a `Broadcast` with `opened`, `openedByPeople`, `clicked`, `clickedByPeople` and `links`. See [Opens and clicks](https://sendora.se/docs/messages#opens-and-clicks). A `switch` over `MessageEventType` needs the two new values. No call changes. ### 5.1.0 `account.trackingDomains` adds, lists, reads, checks and removes [tracking domains](https://sendora.se/docs/domains#tracking-domains). There are five new error codes: `tracking_domain_exists`, `tracking_domain_limit`, `tracking_host_is_sending_domain`, `tracking_host_not_verified` and `tracking_host_taken`. `trackingDomainId` on an error gives the id of the host that `tracking_domain_exists` found. No other call changes. ### 5.0.2 The README says what `payment_required` means in each of its four cases: the first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. No call changes. ### 5.0.1 The type comments on a limit's `period`, `inboundCap` and the received-mail cap's events say what an account's month is. An account that pays by card counts a month from the day and time of its first payment, and any other account the calendar month in UTC. No call changes. ### 5.0.0 The error code `unpaid_limit_reached` is gone, because the API no longer returns it. Two changes break code written for 4.x that uses it: - `SendoraErrorCode` and a batch item's `error` no longer include `unpaid_limit_reached`. - `SendoraError`, its `toJSON()` and a batch item's result no longer have `unpaidOre`, `limitOre` or `priceOre`. A 402 from a send, a batch or a broadcast is now always `payment_required`, which is not `retryable`. No call changes. ### Upgrade from 4.x 1. Install the new version with `npm install @sendora/sdk@5`. 2. Remove each comparison with `unpaid_limit_reached` and each read of `unpaidOre`, `limitOre` or `priceOre`. TypeScript reports every one. ### 4.4.1 A path id that is empty, `.` or `..` throws `TypeError` before any request. Such an id made the URL skip a segment of the path, which the API then refused. No call changes. ### 4.4.0 `sendora.messages.search()` finds part of an address: a `recipient` of three characters or more that is not a whole address matches anywhere in one, such as `kommun.se`. A whole address still matches exactly. Its `status` also takes an array, for messages with a recipient in any of the states. ### 4.3.0 A new error code, `unpaid_limit_reached`: a send would take what the account owes for emails past its limit. The error and a batch item's result carry `unpaidOre`, `limitOre` and `priceOre`, in hundredths of a krona excluding VAT. They carry `retryAfter` while a charge under way makes room, and only then is the error `retryable`. No call changes. ### 4.2.0 The account's monthly cap on received mail. `sendora.server.get()` returns it as `inboundCap`, with its `limit`, `used` and `resetsAt`, apart from the limits on sending; it is null on a test server. Two events say that received mail nears or reaches it: `inbound_cap_warning` and `inbound_cap_reached`, typed as `InboundCapWarningEvent` and `InboundCapReachedEvent`. [Limits](https://sendora.se/docs/limits#the-cap-on-received-mail) says what the cap does. ### 4.1.1 The README says what `payment_required` means in both of its cases: the account's first payment has not arrived, or an unpaid charge has stopped live sending. No call changes. ### 4.1.0 Three error codes that a connected app meets when it calls through Sendora's MCP server: `not_for_connections`, `read_only_connection` and `test_server_only`. A key never meets them. No call changes. ### 4.0.0 Two changes break code written for 3.x: - Values that said tenant now say account. The [error codes](https://sendora.se/docs/errors#when-an-account-cannot-send) `tenant_paused` and `tenant_not_active` are now `account_paused` and `account_not_active`. A limit's `scope` is `account` where it was `tenant`. That holds on `rate_limited` and `monthly_cap_reached`, in the limits that `sendora.server.get()` returns, and on the `cap_warning` and `cap_reached` events. - `verifyWebhook` and `webhooks.receive` no longer throw `unexpected_response` for an event the SDK does not know. They return an `UnknownEvent`: its `event` is `unknown`, `name` holds the event's name, and `data` holds the payload as it came. A signed body that is no event at all still throws `unexpected_response`. [Verify the signature](https://sendora.se/docs/webhooks#verify-the-signature) shows the verifier. ### Upgrade from 3.x 1. Install the new version with `npm install @sendora/sdk@4`. 2. Where you compare `error.code` with `tenant_paused` or `tenant_not_active`, compare it with `account_paused` or `account_not_active` instead. 3. Where you compare a `scope` with `tenant`, compare it with `account`. 4. If your code checks that a `switch` over `event.event` handles every event, add a case for `unknown`. ## Python SDK The package is `sendora` on PyPI. ### 2.6.1 The README and the skill say that `opened@` and `clicked@simulator.sendora.se` act out an open and a click on a test server's tracked mail. No call changes. ### 2.6.0 `email.send`, each message of `email.send_batch` and `broadcasts.send` take `track_opens` and `track_links`. They switch [tracking](https://sendora.se/docs/messages#opens-and-clicks) on or off for that mail, whatever its stream's switches say. Each `Stream` has `track_opens` and `track_links`, its own switches. There are two new error codes, `tracking_not_confirmed` and `tracking_unavailable`. No call changes. ### 2.5.1 The README no longer says that tracking is not open to accounts. It says that an administrator switches tracking on for a stream in the dashboard. No call changes. ### 2.5.0 A new webhook event, `tracking_opted_out`, with the type `TrackingOptedOutEvent`: a recipient [asked not to be tracked](https://sendora.se/docs/suppressions#let-a-recipient-ask). `webhooks.create()` without `events` takes it. An entry's `address` can also be `None` when the recipient asked from mail that had left the log. No call changes. ### 2.4.0 `sendora.tracking_opt_outs` lists, adds and removes the addresses on a server's [not-tracked list](https://sendora.se/docs/suppressions#the-not-tracked-list): `list()`, `list_all()`, `add()` and `delete()`. No call changes. ### 2.3.0 Two webhook events, `opened` and `clicked`, with the classes `OpenedEvent` and `ClickedEvent`. A webhook gets them only if it names them, so `webhooks.create()` without `events` takes every other event. No call changes. See [Webhooks](https://sendora.se/docs/webhooks#events). ### 2.2.0 Each `MessageRecipient` has `tracked`, `opens`, `clicks`, `first_open_at` and `first_human_open_at`. `messages.get()` returns `tracking`, and its timeline holds `Open` and `Click` events, each with its `tracking`. `broadcasts.get()` returns `BroadcastDetail`, a `Broadcast` with `opened`, `opened_by_people`, `clicked`, `clicked_by_people` and `links`. See [Opens and clicks](https://sendora.se/docs/messages#opens-and-clicks). No call changes. ### 2.1.0 `account.tracking_domains` adds, lists, reads, checks and removes [tracking domains](https://sendora.se/docs/domains#tracking-domains). There are five new error codes: `tracking_domain_exists`, `tracking_domain_limit`, `tracking_host_is_sending_domain`, `tracking_host_not_verified` and `tracking_host_taken`. No other call changes. ### 2.0.2 The README says what `payment_required` means in each of its four cases: the first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. No call changes. ### 2.0.1 The docstrings of `ServerLimit.period`, `InboundCap.limit` and a refused batch item's `resets_at` say what an account's month is. An account that pays by card counts a month from the day and time of its first payment, and any other account the calendar month in UTC. No call changes. ### 2.0.0 The error code `unpaid_limit_reached` is gone, because the API no longer returns it. Two changes break code written for 1.x that uses it: - `SendoraErrorCode` and a batch item's `code` no longer include `unpaid_limit_reached`. - `SendoraError` and `RefusedBatchItem` no longer have `unpaid_ore`, `limit_ore` or `price_ore`, so reading one raises `AttributeError`. A 402 from a send, a batch or a broadcast is now always `payment_required`, which has no `retry_after` and is not `retryable`. No call changes. ### Upgrade from 1.x 1. Install the new version with `pip install "sendora~=2.0"`. 2. Remove each comparison with `unpaid_limit_reached` and each read of `unpaid_ore`, `limit_ore` or `price_ore`. ### 1.4.1 A path id that is empty, `.` or `..` raises `ValueError` before any request. Such an id made the HTTP client drop segments of the path: `account.servers.tokens.revoke(server_id, "..")` deleted the server, and `sendora.webhooks.delete_secret(webhook_id, "..")` deleted the webhook. Upgrade if an id can come from input you do not control. ### 1.4.0 `sendora.messages.search()` finds part of an address: a `recipient` of three characters or more that is not a whole address matches anywhere in one, such as `kommun.se`. A whole address still matches exactly. Its `status` also takes a list, for messages with a recipient in any of the states. ### 1.3.0 A new error code, `unpaid_limit_reached`: a send would take what the account owes for emails past its limit. The error and a batch item's result carry `unpaid_ore`, `limit_ore` and `price_ore`, in hundredths of a krona excluding VAT. They carry `retry_after` while a charge under way makes room, and only then is the error `retryable`. No call changes. ### 1.2.0 The account's monthly cap on received mail. `sendora.server.get()` returns it as `inbound_cap`, with its `limit`, `used` and `resets_at`, apart from the limits on sending; it is `None` on a test server. Two events say that received mail nears or reaches it: `inbound_cap_warning` and `inbound_cap_reached`, read as `InboundCapWarningEvent` and `InboundCapReachedEvent`. [Limits](https://sendora.se/docs/limits#the-cap-on-received-mail) says what the cap does. ### 1.1.1 The README says what `payment_required` means in both of its cases: the account's first payment has not arrived, or an unpaid charge has stopped live sending. No call changes. ### 1.1.0 Three error codes that a connected app meets when it calls through Sendora's MCP server: `not_for_connections`, `read_only_connection` and `test_server_only`. A key never meets them. No call changes. ### 1.0.0 The first stable release. From 1.0.0 on, only a new major version can break your code: a renamed or removed method, argument or field, or a changed error code. A minor version adds methods, fields, codes or events, and a patch fixes a bug. ### Upgrade from 0.x 1. Install the new version with `pip install "sendora~=1.0"`. 2. If you wrote for a version before 0.9.0, compare `error.code` with `account_paused` or `account_not_active` where you compared it with `tenant_paused` or `tenant_not_active`. 3. If you wrote for a version before 0.9.0, compare a `scope` with `account` where you compared it with `tenant`. ## PHP SDK The package is `sendora/sendora-php` on Packagist. ### 1.7.1 The README and the skill say that `opened@` and `clicked@simulator.sendora.se` act out an open and a click on a test server's tracked mail. No call changes. ### 1.7.0 The mailer driver reads `X-Sendora-Track-Opens` and `X-Sendora-Track-Links` as the send's `trackOpens` and `trackLinks`, as [SMTP submission](https://sendora.se/docs/smtp#set-the-stream-tag-metadata-and-tracking) does. Each is `true` or `false`. Another value fails the send before anything goes out. No call changes. ### 1.6.0 `email->send()`, each message of `email->sendBatch()` and `broadcasts->send()` take `trackOpens` and `trackLinks`. They switch [tracking](https://sendora.se/docs/messages#opens-and-clicks) on or off for that mail, whatever its stream's switches say. The two arguments come last, after `idempotencyKey`. Each `Stream` has `trackOpens` and `trackLinks`, its own switches. There are two new error codes, `ErrorCode::TrackingNotConfirmed` and `ErrorCode::TrackingUnavailable`. No call changes. ### 1.5.1 The README no longer says that tracking is not open to accounts. It says that an administrator switches tracking on for a stream in the dashboard. No call changes. ### 1.5.0 A new webhook event, `tracking_opted_out`, with the class `TrackingOptedOutEvent`: a recipient [asked not to be tracked](https://sendora.se/docs/suppressions#let-a-recipient-ask). `webhooks->create()` without `events` takes it. An entry's `address` can also be `null` when the recipient asked from mail that had left the log. No call changes. ### 1.4.0 `$sendora->trackingOptOuts` lists, adds and removes the addresses on a server's [not-tracked list](https://sendora.se/docs/suppressions#the-not-tracked-list): `list()`, `listAll()`, `add()` and `delete()`. No call changes. ### 1.3.0 Two webhook events, `opened` and `clicked`, with the classes `OpenedEvent` and `ClickedEvent`. A webhook gets them only if it names them, so `webhooks->create()` without `events` takes every other event. No call changes. See [Webhooks](https://sendora.se/docs/webhooks#events). ### 1.2.0 Each `MessageRecipient` has `tracked`, `opens`, `clicks`, `firstOpenAt` and `firstHumanOpenAt`. `messages->get()` returns `tracking`, and its timeline holds `Open` and `Click` events, each with its `tracking`. `broadcasts->get()` returns `BroadcastDetail`, a `Broadcast` with `opened`, `openedByPeople`, `clicked`, `clickedByPeople` and `links`. See [Opens and clicks](https://sendora.se/docs/messages#opens-and-clicks). No call changes. ### 1.1.0 `$account->trackingDomains` adds, lists, reads, checks and removes [tracking domains](https://sendora.se/docs/domains#tracking-domains). There are five new error codes: `tracking_domain_exists`, `tracking_domain_limit`, `tracking_host_is_sending_domain`, `tracking_host_not_verified` and `tracking_host_taken`. No other call changes. ### 1.0.3 The README says what `payment_required` means in each of its four cases: the first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. No call changes. ### 1.0.2 The docblocks of `LimitPeriod`, `ServerLimit`, `InboundCap` and `RefusedBatchItem` say what an account's month is. An account that pays by card counts a month from the day and time of its first payment, and any other account the calendar month in UTC. No call changes. ### 1.0.1 The README says that a webhook event's constructor is outside the semver promise, as an answer's is, since a new property adds a parameter. Build either with named arguments in your tests. No call changes. ### 1.0.0 The first stable release. From 1.0.0 on, only a new major version can break your code: a renamed or removed method, argument or property, or a changed error code. A minor version adds methods, arguments, properties, codes or events, and a patch fixes a bug. ### Upgrade from 0.x 1. Install the new version with `composer require sendora/sendora-php:^1.0`. 2. If you wrote for a version before 0.11.0, remove each comparison with `ErrorCode::UnpaidLimitReached` and each read of `unpaidOre`, `limitOre` or `priceOre`. PHPStan reports every one. ## Laravel package The package is `sendora/sendora-laravel` on Packagist, and it installs the PHP SDK. ### 1.4.0 A Laravel event, `RecipientOptedOut`, for the `tracking_opted_out` webhook: a recipient [asked not to be tracked](https://sendora.se/docs/suppressions#let-a-recipient-ask). The package requires `sendora/sendora-php` 1.5.0 or a later 1.x release. No call changes. ### 1.3.0 The container holds `TrackingOptOuts`, and the facade has `trackingOptOuts()`, for a server's [not-tracked list](https://sendora.se/docs/suppressions#the-not-tracked-list). The package requires `sendora/sendora-php` 1.4.0 or a later 1.x release. No call changes. ### 1.2.0 Two Laravel events, `MessageOpened` and `LinkClicked`, for the [`opened` and `clicked` webhooks](https://sendora.se/docs/webhooks#events). The package requires `sendora/sendora-php` 1.3.0 or a later 1.x release. ### 1.1.0 The container holds `TrackingDomains`, the account client's resource for [tracking domains](https://sendora.se/docs/domains#tracking-domains). The package requires `sendora/sendora-php` 1.1.0 or a later 1.x release. ### 1.0.0 The first stable release. From 1.0.0 on, only a new major version can break your code: a renamed or removed class, method, setting or event, or a changed default. It takes any 1.x release of the PHP SDK. To upgrade from 0.x, run `composer require sendora/sendora-laravel:^1.0`, which takes the PHP SDK to 1.x as well. Nothing of this package changes for your code, and the PHP SDK's upgrade above says what changes in its own calls. ## SSO and SCIM Sign your organisation's people in through its own identity provider, by SAML or OpenID Connect, and keep the account's users in step with SCIM. Your organisation's people can sign in to Sendora with their usual work account, through your identity provider. Sendora connects to it by SAML 2.0 or OpenID Connect. With SCIM, your identity provider also adds people to the account and deactivates them when they leave. This page is for whoever sets up the identity provider, often IT. It gives the values to copy, what Sendora reads from your provider, each provider's steps and the SCIM that Sendora serves. ## Before you start - An administrator of the Sendora account works under [Account › Sign-in](https://app.sendora.se/account/sso). Instead, they can mail you a link to IT from there. It opens the same pages in your browser for seven days, without an account of your own. It works only in the first browser that opens it, and an administrator can revoke it. - You need administrator rights in your identity provider. - You need to publish a DNS record at your organisation's email domain. ## How the setup goes 1. Prove that the account controls your email domain, with one DNS record. 2. Create a connection to your identity provider. Its page shows Sendora's values to copy into your provider first, and asks for your provider's details last. 3. Test the sign-in. The test signs nobody in to Sendora. 4. An administrator switches the connection on. The sign-in screen then shows a button for each address at your domain, once the person has given it. 5. If you want, require SSO, and connect SCIM. An account can hold several connections, for example one per provider, up to the count in [Limits](https://sendora.se/docs/limits#sso-and-scim). Each covers the domains you attach to it. ## Prove your domain Add the domain under [Account › Sign-in](https://app.sendora.se/account/sso). Sendora shows one TXT record, such as: ``` _sendora-sso.example.com TXT "sendora-sso=8f14e45fceea167a5a36dedd4bea2543" ``` Publish it, then check the domain. Keep the record, since Sendora checks it again every day. If it goes missing, the administrators get a mail with the record. After seven days without it, the domain is no longer verified, and a connection left with no verified domain switches off. - A domain covers its exact name only. `mail.example.com` is a domain of its own. - Only one Sendora account can verify a domain. - A verified sending domain does not count. Sending as a domain is not the same as signing its people in. ## Connect by SAML Each connection has its own values for your provider. The connection's page shows each one to copy. | Value | Is | | ------------------------------------ | --------------------------------------------------------------- | | Entity ID | `https://app.sendora.se/dashboard/sso/saml/` | | ACS URL (Assertion Consumer Service) | `https://app.sendora.se/dashboard/sso/saml//acs` | | Metadata URL | The entity ID, which also serves Sendora's metadata | | Sign-on URL | `https://app.sendora.se/login?sso_domain=` | Sendora signs its requests, and its metadata carries its certificate. If your provider reads metadata from a URL, give it Sendora's metadata URL, so it follows a new certificate by itself. Give Sendora your provider's metadata, as a URL or as a file. - Sendora reads a URL again every day and takes new certificates as your provider publishes them. So publish a new certificate in the metadata a day before your provider signs with it. If the URL cannot be read for three days, the administrators get a mail. - Sendora reads a file once. When your provider changes its certificate, give the connection the new metadata. On a connection that is on, it takes effect once a test with it passes. Your provider must sign the response or the assertion, with RSA and SHA-256 or stronger. It must send the person's email address, and it should send their name. - Sendora takes the address from the first of these attributes that the assertion carries: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress`, `email`, `mail` or `urn:oid:0.9.2342.19200300.100.1.3`. Without any of them, it takes the NameID. - It takes the name from `http://schemas.microsoft.com/identity/claims/displayname`, `displayName` or `urn:oid:2.16.840.1.113730.3.1.241`. - Without a display name, it joins a given name and a surname: `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` and `.../surname`, `givenName` and `sn`, `firstName` and `lastName`, or `urn:oid:2.5.4.42` and `urn:oid:2.5.4.4`. - Without any name, the sign-in still works, and the user has no name in Sendora. - The NameID is who the person is to Sendora, so keep it the same for each person. If one changes, an administrator can unlink the person under [Users](https://app.sendora.se/users), and their next sign-in links them again. ## Connect by OpenID Connect Create a web app with the authorization code flow in your provider, with these values. | Value | Is | | ------------ | -------------------------------------------------------------------- | | Redirect URI | `https://app.sendora.se/dashboard/sso/oidc//callback` | | Scopes | `openid email profile` | Then give Sendora: - the issuer's URL, whose `/.well-known/openid-configuration` Sendora must reach from the internet; - the client ID and the client secret, sent as HTTP Basic (`client_secret_basic`) or in the form (`client_secret_post`); - the date the secret expires, if it does. Sendora mails the administrators 30 and 7 days before. Sendora uses PKCE and a nonce on every sign-in. It reads the person from the ID token, or from the userinfo endpoint when the ID token has no address. - `sub` is who the person is to Sendora. - `email` is the address. Sendora refuses one whose `email_verified` is false. - The name is `name`, or else `given_name` and `family_name`. Without one, the sign-in still works. ## Each provider's steps The connection's page shows these steps too, with its own values filled in. Entra ID, Google Workspace, Okta and AD FS connect by SAML, and Keycloak by OpenID Connect. ### Microsoft Entra ID 1. In the Microsoft Entra admin center, as at least a Cloud Application Administrator, go to **Entra ID**, **Enterprise apps** and **All applications**. Choose **New application**, then **Create your own application**. 2. Name the app Sendora. Choose **Integrate any other application you don't find in the gallery (Non-gallery)**, then **Create**. 3. In the app, choose **Single sign-on** under **Manage**, then **SAML**. Under **Basic SAML Configuration**, choose **Edit**. Fill in **Identifier (Entity ID)**, **Reply URL (Assertion Consumer Service URL)** and **Sign on URL** with Sendora's values, and choose **Save**. 4. Leave **Attributes & Claims** as they are. The app already sends the address, the given name and the surname. A person with no address in Entra gets their user principal name, which works when it is their address. 5. Under **Properties**, set **Assignment required?** to **Yes**. Then under **Users and groups**, choose **Add user/group** and assign the people or groups who sign in to Sendora. Assigning groups needs Entra ID P1 or P2. 6. Back under **Single sign-on**, copy **App Federation Metadata Url** from **SAML Certificates**, and give it to Sendora. Then test the sign-in in Sendora. Entra's own **Test** button works only once the connection is on. ### Google Workspace 1. In the Google Admin console, as a super administrator, go to **Apps** and **Web and mobile apps**. Choose **Add app**, then **Add custom SAML app**. Name it Sendora and choose **Continue**. 2. On **Google Identity Provider details**, choose **Download Metadata** under **Option 1**, and keep the file for Sendora. Choose **Continue**. 3. On **Service provider details**, fill in **ACS URL** and **Entity ID** with Sendora's values, and leave **Start URL** empty. Choose **EMAIL** as the **Name ID format** and **Basic Information > Primary email** as the **Name ID**. Choose **Continue**. 4. Under **Attribute mapping**, map **Basic Information > First Name** to `firstName` and **Last Name** to `lastName`. Choose **Finish**. 5. A new app is off. Open it, choose **User access**, and turn it on for everyone or for the groups who sign in to Sendora. Choose **Save**. 6. Give Sendora the metadata file. The change can take up to a day to apply, though usually less. Google's **Test SAML login** cannot complete Sendora's test, so test the sign-in in Sendora. ### Okta 1. In the Okta Admin Console, go to **Applications and Resources** and **Applications**. Choose **Create App Integration**. 2. Choose **SAML 2.0** as the **Sign-in method**, then **Next**. Name the app Sendora and choose **Next**. 3. Under **Configure SAML**, fill in **Single sign-on URL** with the ACS URL and **Audience URI (SP Entity ID)** with the entity ID. Leave **Use this for Recipient URL and Destination URL** ticked. 4. Choose **EmailAddress** as the **Name ID format** and **Email** as the **Application username**. Leave **Show Advanced Settings** as it is, and choose **Next**. 5. Under **Feedback**, tick **This is an internal app that we have created**, then choose **Finish**. 6. The app opens on its **Sign On** tab. Under **Attribute statements**, choose **Add expression**. Add `firstName` with the expression `user.profile.firstName`, and `lastName` with `user.profile.lastName`. 7. Under **Assignments**, assign the people or groups who sign in to Sendora. 8. Back on the **Sign On** tab, copy **Metadata URL** from **Metadata details**, and give it to Sendora. ### AD FS 1. In **AD FS Management**, choose **Relying Party Trusts**, then **Add Relying Party Trust** under **Actions**. Choose **Claims aware** and **Start**. 2. Choose **Import data about the relying party published online or on a local network**, and give it Sendora's metadata URL. AD FS then reads Sendora's other values itself. If AD FS cannot reach the internet, save the metadata from a browser and import it from the file instead. 3. Name the trust Sendora. Under **Choose Access Control Policy**, choose **Permit everyone** or **Permit specific group**. Go on to **Finish**, with **Configure claims issuance policy for this application** ticked. 4. Under **Issuance Transform Rules**, add a rule from the template **Send LDAP Attributes as Claims**, with **Active Directory** as the attribute store. Send **E-Mail-Addresses** as **E-Mail Address**, **Given-Name** as **Given Name** and **Surname** as **Surname**. 5. Add a rule from the template **Transform an Incoming Claim** that turns **E-Mail Address** into **Name ID**, with the format **Email**. 6. Open the trust's **Monitoring** tab, and keep **Monitor relying party** and **Automatically update relying party** ticked. AD FS then reads Sendora's metadata again when Sendora changes its certificate. 7. Give Sendora your federation service's metadata, at `https:///FederationMetadata/2007-06/FederationMetadata.xml`. If Sendora cannot reach it from the internet, save it from a browser and give Sendora the file. ### Keycloak 1. In the Keycloak admin console, choose your realm. Go to **Clients** and choose **Create client**. 2. Leave **Client type** as **OpenID Connect**, enter `sendora` as the **Client ID** and choose **Next**. 3. Turn on **Client authentication**, leave **Standard flow** ticked and choose **Next**. 4. Fill in **Valid redirect URIs** with Sendora's redirect URI and **Home URL** with the sign-on URL. Leave the other fields empty and choose **Save**. 5. Under the **Credentials** tab, copy **Client Secret**. 6. Give Sendora the realm's URL as the issuer, such as `https://keycloak.example.com/realms/example`, with the client ID and the secret. Sendora talks to that URL directly, so Keycloak must be reachable from the internet. Sendora accepts only an address that Keycloak has verified. Turn on **Email verified** for each person, or **Trust Email** for people who come from LDAP or another identity provider. ## Test and switch on Test the sign-in from the connection's page. The test opens your provider's sign-in and signs nobody in to Sendora. Its result shows what your provider sent: the id, the address and the name, and whether the address is at a verified domain. Only whoever started the test sees the result, for an hour. | A failed test says | Check | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `provider_error` | Your provider stopped the sign-in. Check that the person may use the app. | | `discovery_failed` | Sendora could not read the issuer's configuration. Check the issuer's URL. | | `token_exchange_failed` | Your provider refused the client ID or the secret. | | `id_token_invalid` | The ID token failed Sendora's checks. Check the issuer's URL and the client ID. | | `userinfo_failed` | Sendora could not read the userinfo endpoint. Try again shortly. | | `email_missing` | Your provider sent no address. Let the app read the person's email. | | `email_unverified` | Your provider says the address is not verified. | | `response_invalid` | Sendora could not accept the response. Check that your provider uses Sendora's metadata. | | `signature_invalid` | The signature does not match the connection's certificate. Give the connection your provider's metadata again, and sign with SHA-256. | | `response_expired` | The response was not valid at this moment. Check your provider's clock. | | `audience_mismatch` | The response was meant for another service. Check Sendora's entity ID and ACS URL in your provider. | | `issuer_mismatch` | The response came from another provider. Give the connection your provider's metadata again. | An administrator switches the connection on, once three things hold: - the connection has a verified domain; - a test has passed since its settings last changed; - Sendora has approved the account. Through a link to IT, you can set up, change and test a connection while it is off, and create SCIM tokens. You cannot switch a connection on, change one that is on, require SSO or revoke a token. Once a connection is on, a change of its provider settings waits for a test with the new settings to pass. So an edit cannot lock its people out. A certificate that your provider publishes in its metadata is taken as it is. ## Sign in A person gives their address on the sign-in screen and selects **Continue**, or selects **Sign in with SSO** and gives their work address. At a domain of a connection that is on, a button then starts the sign-in at your provider. Only an address at one of the connection's verified domains signs in through it. A tile in Microsoft's My Apps or Okta's dashboard works too. Its response signs nobody in by itself, since Sendora starts every sign-in. Sendora sends the browser to the sign-in screen with the button, and one click there starts a sign-in that your provider's session completes. The first time, Sendora finds the person's user: - A user whom SCIM created, or whom this connection created, is linked at once. - Any other user with the address, such as one an administrator invited, gets a mail first. Its link confirms who they are and lasts 15 minutes. If it runs out, signing in again mails a new one. - A person with no user gets one as a member with no servers, if the connection creates users. Otherwise an administrator invites them first. A sign-in through SSO lasts at most 12 hours. A person whose address is in another Sendora account cannot sign in here. ## Require SSO An administrator can require SSO on a connection that is on. It covers the people at its domains and those linked to it. - A password fails for everyone it covers. - A member signs in only through your provider. A request for a mailed link gets the SSO button instead. - An administrator keeps the mailed link, and that session lasts 12 hours. So while your provider is down, an administrator who can read their mail still signs in, and can switch the requirement off. - Switching the requirement on ends these people's other sessions and their connected apps, such as an AI assistant. - People at other domains, keys, SMTP and webhooks are unaffected. If no administrator can sign in, [Sendora's support](https://sendora.se/contact) can lift the requirement. ## SCIM With SCIM, your provider adds people to the account as members with no servers, keeps their names and addresses current, and deactivates them when they leave. A deactivated person cannot sign in in any way, and their sessions end at once. Roles and servers stay with the account's administrators. ### Connect your provider An administrator, or you through a link to IT, creates a SCIM token under [Account › Sign-in](https://app.sendora.se/account/sso). Sendora shows it once. To replace a token, create the new one before you revoke the old, so provisioning never stops. | Setting | Value | | -------- | ------------------------------------------- | | Base URL | `https://api.sendora.se/scim/v2` | | Token | The SCIM token, `scim_…`, as a bearer token | Every request carries `Authorization: Bearer scim_…`. Answers are `application/scim+json`, and errors use the error schema of RFC 7644. In Microsoft Entra ID, provision from the enterprise app that signs your people in by SAML. An app that signs in by OpenID Connect cannot provision, so with one, create a separate enterprise app for provisioning alone, as in the first two of Entra's steps above. 1. In the enterprise app, open **Provisioning** and set the mode to **Automatic**. 2. Enter the base URL as **Tenant URL** and the token as **Secret Token**. Choose **Test Connection**, then **Save**. 3. Under **Mappings**, turn off **Provision Microsoft Entra ID Groups**, since Sendora keeps no groups. A group assigned to the app still provisions its members. 4. If your users' principal names are not their addresses, map **mail** to **userName** under the users' mapping. Sendora finds the people already in the account by their address, and creating one of them again fails with 409 `uniqueness`. 5. Turn provisioning on. It provisions the users assigned to the app, directly or through a group. In Okta, use the SAML integration from above, since Okta offers SCIM only on SAML integrations. 1. Under the app's **General** tab, edit **App Settings**, choose **SCIM** as **Provisioning** and save. 2. Under the **Provisioning** tab, edit **Integration**. Enter the base URL as **SCIM connector base URL** and `userName` as **Unique identifier field for users**. 3. Under **Supported provisioning actions**, tick **Push New Users** and **Push Profile Updates**, and leave the rest unticked. Sendora keeps no groups. 4. Choose **HTTP Header** as **Authentication Mode**, and enter the token after **Bearer**. Choose **Test Connector Configuration**, then **Save**. 5. Under **To App**, choose **Edit**, enable **Create Users**, **Update User Attributes** and **Deactivate Users**, and save. 6. If people were assigned to the app before you turned provisioning on, choose **Provision User** under **Assignments**. Okta finds the people already in Sendora by their address. AD FS and Keycloak have no SCIM client of their own. Google Workspace may not offer SCIM for a custom SAML app. ### Endpoints | Endpoint | Does | | ---------------------------------------------------------- | ---------------------------------------------------- | | `GET /ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | Describe what Sendora serves | | `GET /Users` | Lists or filters, with `startIndex` and `count` | | `POST /Users` | Creates a member with no servers | | `GET /Users/:id` | Reads one | | `PUT /Users/:id` | Replaces the attributes it carries | | `PATCH /Users/:id` | Changes one | | `DELETE /Users/:id` | Removes one | | `GET /Groups` | Returns an empty list, since Sendora keeps no groups | Any other `/Groups` request fails with 501. A filter takes `eq` on `userName`, `externalId`, `id` and `emails[type eq "work"].value`, joined by `and`. Anything else fails with 400 `invalidFilter`. A `userName` or an address matches in any case, and an `externalId` matches exactly. SCIM sees only the account's users with an address at one of its verified domains. A consultant at another domain is invisible to it, so assign only people at your domains to the app. Creating anyone else fails with 400 `invalidValue`. A user whom SCIM has not created or changed has their address as their `userName`. So your provider finds the people who were there first, when it looks them up by their address. ### The user - `id` is the Sendora user's id. - `userName` and `externalId` are kept as your provider sent them. - The address is the primary or work email, or else the `userName`. It must be at a verified domain, or the request fails with 400 `invalidValue`. - `name.formatted` sets the full name, or else the given and family names, or else `displayName`. - `active` is false while the user is deactivated. - `password`, `roles`, `groups` and `entitlements` are ignored. ### What each request does - `POST` creates a member with no servers and mails nothing. An address that any Sendora account already holds fails with 409 `uniqueness`. - `PUT` replaces each attribute it carries. One it leaves out keeps its value, `active` included. - `PATCH` takes `add`, `replace` and `remove`, with or without a path. `active` may be a boolean or the string `"True"` or `"False"`. A path to an attribute that Sendora does not keep changes nothing. A request Sendora cannot read fails whole with 400. - `active: false` deactivates the user and ends their sessions and connected apps. `active: true` lets them in again. Only SCIM deactivates a user, and only SCIM reactivates one. - A new address moves a member's address, if it is free and at a verified domain. An administrator's address never changes through SCIM, and such a request fails with 400 `mutability`. - `DELETE` removes the user. For the account's last administrator it fails with 409, and they stay deactivated instead. - Deactivating the last active administrator still happens, since a leaver must lose access. Sendora's support then helps the account name a new one. ## When certificates and secrets expire Sendora mails the account's administrators 30 and 7 days before a connection's last certificate expires, or its OIDC secret on the date you gave. Your provider's metadata may list more than one certificate, so the mail goes before the last of them expires. A connection that is off gets no mail. With a metadata URL, a certificate that your provider publishes is taken without any step from you. With a file, give the connection the new metadata, as under [Connect by SAML](#connect-by-saml). [Limits](https://sendora.se/docs/limits#sso-and-scim) lists the counts and rates for SSO and SCIM. ## Move from Microsoft 365 Move a system that sends through a Microsoft 365 mailbox with a password to Sendora's SMTP. Move a system that sends email by signing in to a Microsoft 365 mailbox with a user name and password, such as a printer, an ERP or a booking system. The system keeps sending over SMTP, through Sendora. Its server and its credentials change, and its From address may. ## What Microsoft changes Microsoft is turning off password sign-in for SMTP, which it calls SMTP AUTH Basic authentication. Its Exchange Team Blog set out the timeline on 27 January 2026: - Until the end of December 2026, nothing changes. - From the end of December 2026, password sign-in for SMTP is off by default in existing Microsoft 365 organisations. An administrator can still turn it back on. - In organisations created after December 2026, it is unavailable by default, and OAuth is the way to sign in. - In the second half of 2027, Microsoft will announce the date it is removed for good. A system that can sign in with OAuth can keep sending through Microsoft 365. This guide is for one that takes only a user name and a password. ## Before you start - A Sendora account. A test server works from the moment the account exists. A live server sends once your account is approved and its first payment has arrived, which [Account › Billing](https://app.sendora.se/account/billing) in the dashboard shows. - Access to the DNS of the domain your system sends from. - Access to the system's mail settings. ## Move the system 1. Add the domain your system sends from under [Domains](https://app.sendora.se/domains), and add the two DNS records it shows, as [Domains](https://sendora.se/docs/domains) explains. The records leave your MX and SPF records as they are, so Microsoft 365 keeps receiving the domain's mail. 2. Create a test server under [Servers › New server](https://app.sendora.se/servers/new), with **Testserver** ticked, and create a key on its **API keys** tab. The key starts with `sk_test_`. A [test server](https://sendora.se/docs/test-servers) takes mail as a live server does and delivers none of it. 3. In the system's mail settings, replace Microsoft 365's values with Sendora's: | Setting | Microsoft 365 | Sendora | | ------------------- | ---------------------------------- | ------------------------------------------------- | | Server | `smtp.office365.com` | `smtp.sendora.se` | | Port and encryption | 587 with STARTTLS | 587 with STARTTLS, or 465 with TLS from the start | | User name | The mailbox's address | The server key | | Password | The mailbox's password | The server key | | From address | The mailbox, or one it may send as | Any address on a verified domain | 4. Send a message from the system, such as its test mail. Find it on the server's **Messages** tab under [Servers](https://app.sendora.se/servers). 5. Once your account is approved and its first payment has arrived, create a key on your live server's **API keys** tab, such as the default server that signup made. Put it in the system in place of the test key. 6. Once the system sends through Sendora, its mailbox no longer needs password sign-in for SMTP, so your Microsoft 365 administrator can leave it off. ## What changes for the system - Replies go where they went before. They reach the From or `Reply-To` address, so a mailbox in Microsoft 365 still receives them. - Microsoft 365 kept a copy of each message in the mailbox's Sent Items. Sendora keeps no copy you can read. Its log shows each message's recipients, statuses and timeline, for the time [Retention](https://sendora.se/docs/limits#retention) gives. - Microsoft 365 put a bounce in the mailbox as a report. Sendora records it on the message, sends it to your webhooks, and adds an address that bounced hard to the [suppression list](https://sendora.se/docs/suppressions). - Sendora's [limits](https://sendora.se/docs/limits) apply instead of Microsoft's. ## If a message is refused Sendora accepts or refuses each message before the session ends. `535 5.7.8` means the key is wrong or revoked. `550 5.7.1` means the From domain is not verified, or that a live server's account is not yet approved or paid. [SMTP replies](https://sendora.se/docs/smtp#smtp-replies) lists every reply. ## API reference Every route, field, constraint and error code, generated from the running code: https://sendora.se/docs/api. The OpenAPI 3.1 document: https://sendora.se/docs/openapi.json. - POST /v1/email: [Send one message](https://sendora.se/docs/api/post-v1-email) (TypeScript: `sendora.email.send(message)`, Python: `sendora.email.send(**message)`, PHP: `$sendora->email->send(...$message)`) - POST /v1/email/batch: [Send up to 100 messages](https://sendora.se/docs/api/post-v1-email-batch) (TypeScript: `sendora.email.sendBatch(messages)`, Python: `sendora.email.send_batch(messages)`, PHP: `$sendora->email->sendBatch($messages)`) - GET /v1/broadcasts: [List the broadcasts](https://sendora.se/docs/api/get-v1-broadcasts) (TypeScript: `sendora.broadcasts.list(query)`, Python: `sendora.broadcasts.list(limit=..., after=...)`, PHP: `$sendora->broadcasts->list(limit: $limit, after: $after)`) - POST /v1/broadcasts: [Send a broadcast](https://sendora.se/docs/api/post-v1-broadcasts) (TypeScript: `sendora.broadcasts.send(broadcast)`, Python: `sendora.broadcasts.send(**broadcast)`, PHP: `$sendora->broadcasts->send(...$broadcast)`) - GET /v1/broadcasts/{id}: [Get a broadcast](https://sendora.se/docs/api/get-v1-broadcasts-id) (TypeScript: `sendora.broadcasts.get(broadcastId)`, Python: `sendora.broadcasts.get(broadcast_id)`, PHP: `$sendora->broadcasts->get($broadcastId)`) - POST /v1/broadcasts/{id}/cancel: [Cancel a broadcast](https://sendora.se/docs/api/post-v1-broadcasts-id-cancel) (TypeScript: `sendora.broadcasts.cancel(broadcastId)`, Python: `sendora.broadcasts.cancel(broadcast_id)`, PHP: `$sendora->broadcasts->cancel($broadcastId)`) - GET /v1/messages/{id}: [Read a message](https://sendora.se/docs/api/get-v1-messages-id) (TypeScript: `sendora.messages.get(messageId)`, Python: `sendora.messages.get(message_id)`, PHP: `$sendora->messages->get($messageId)`) - POST /v1/messages/search: [Search the message log](https://sendora.se/docs/api/post-v1-messages-search) (TypeScript: `sendora.messages.search(filters)`, Python: `sendora.messages.search(**filters)`, PHP: `$sendora->messages->search(...$filters)`) - POST /v1/inbound/search: [Search received messages](https://sendora.se/docs/api/post-v1-inbound-search) (TypeScript: `sendora.inbound.search(filters)`, Python: `sendora.inbound.search(**filters)`, PHP: `$sendora->inbound->search(...$filters)`) - GET /v1/inbound/{id}: [Read a received message](https://sendora.se/docs/api/get-v1-inbound-id) (TypeScript: `sendora.inbound.get(inboundMessageId)`, Python: `sendora.inbound.get(inbound_message_id)`, PHP: `$sendora->inbound->get($inboundMessageId)`) - GET /v1/inbound/{id}/raw: [Download the raw message](https://sendora.se/docs/api/get-v1-inbound-id-raw) (TypeScript: `sendora.inbound.raw(inboundMessageId)`, Python: `sendora.inbound.raw(inbound_message_id)`, PHP: `$sendora->inbound->raw($inboundMessageId)`) - GET /v1/inbound/{id}/attachments/{attachmentId}: [Download an attachment](https://sendora.se/docs/api/get-v1-inbound-id-attachments-attachmentid) (TypeScript: `sendora.inbound.attachment(inboundMessageId, attachmentId)`, Python: `sendora.inbound.attachment(inbound_message_id, attachment_id)`, PHP: `$sendora->inbound->attachment($inboundMessageId, $attachmentId)`) - GET /v1/inbound/domains: [List the inbound domains](https://sendora.se/docs/api/get-v1-inbound-domains) (TypeScript: `sendora.inboundDomains.list()`, Python: `sendora.inbound_domains.list()`, PHP: `$sendora->inboundDomains->list()`) - POST /v1/inbound/domains: [Claim a domain for an inbound stream](https://sendora.se/docs/api/post-v1-inbound-domains) (TypeScript: `sendora.inboundDomains.create({ streamId, domain })`, Python: `sendora.inbound_domains.create(stream_id=..., domain=...)`, PHP: `$sendora->inboundDomains->create(streamId: $streamId, domain: $domain)`) - GET /v1/inbound/domains/{id}: [Read an inbound domain](https://sendora.se/docs/api/get-v1-inbound-domains-id) (TypeScript: `sendora.inboundDomains.get(inboundDomainId)`, Python: `sendora.inbound_domains.get(inbound_domain_id)`, PHP: `$sendora->inboundDomains->get($inboundDomainId)`) - DELETE /v1/inbound/domains/{id}: [Remove an inbound domain](https://sendora.se/docs/api/delete-v1-inbound-domains-id) (TypeScript: `sendora.inboundDomains.delete(inboundDomainId)`, Python: `sendora.inbound_domains.delete(inbound_domain_id)`, PHP: `$sendora->inboundDomains->delete($inboundDomainId)`) - POST /v1/inbound/domains/{id}/verify: [Check the records now](https://sendora.se/docs/api/post-v1-inbound-domains-id-verify) (TypeScript: `sendora.inboundDomains.verify(inboundDomainId)`, Python: `sendora.inbound_domains.verify(inbound_domain_id)`, PHP: `$sendora->inboundDomains->verify($inboundDomainId)`) - GET /v1/streams: [List the streams](https://sendora.se/docs/api/get-v1-streams) (TypeScript: `sendora.streams.list()`, Python: `sendora.streams.list()`, PHP: `$sendora->streams->list()`) - POST /v1/streams: [Create a stream](https://sendora.se/docs/api/post-v1-streams) (TypeScript: `sendora.streams.create({ kind, name })`, Python: `sendora.streams.create(kind=..., name=...)`, PHP: `$sendora->streams->create(kind: $kind, name: $name)`) - GET /v1/streams/{id}: [Read a stream](https://sendora.se/docs/api/get-v1-streams-id) (TypeScript: `sendora.streams.get(streamId)`, Python: `sendora.streams.get(stream_id)`, PHP: `$sendora->streams->get($streamId)`) - PATCH /v1/streams/{id}: [Update a stream](https://sendora.se/docs/api/patch-v1-streams-id) (TypeScript: `sendora.streams.update(streamId, { name, contentRetentionDays })`, Python: `sendora.streams.update(stream_id, name=..., content_retention_days=...)`, PHP: `$sendora->streams->update($streamId, name: $name, contentRetentionDays: $contentRetentionDays)`) - POST /v1/streams/{id}/archive: [Archive a stream](https://sendora.se/docs/api/post-v1-streams-id-archive) (TypeScript: `sendora.streams.archive(streamId)`, Python: `sendora.streams.archive(stream_id)`, PHP: `$sendora->streams->archive($streamId)`) - GET /v1/server: [Read this key's server](https://sendora.se/docs/api/get-v1-server) (TypeScript: `sendora.server.get()`, Python: `sendora.server.get()`, PHP: `$sendora->server->get()`) - GET /v1/servers: [List the servers](https://sendora.se/docs/api/get-v1-servers) (TypeScript: `account.servers.list()`, Python: `account.servers.list()`, PHP: `$account->servers->list()`) - POST /v1/servers: [Create a server](https://sendora.se/docs/api/post-v1-servers) (TypeScript: `account.servers.create({ name })`, Python: `account.servers.create(name=...)`, PHP: `$account->servers->create(name: $name)`) - GET /v1/servers/{id}: [Read a server](https://sendora.se/docs/api/get-v1-servers-id) (TypeScript: `account.servers.get(serverId)`, Python: `account.servers.get(server_id)`, PHP: `$account->servers->get($serverId)`) - PATCH /v1/servers/{id}: [Rename a server](https://sendora.se/docs/api/patch-v1-servers-id) (TypeScript: `account.servers.update(serverId, { name })`, Python: `account.servers.update(server_id, name=...)`, PHP: `$account->servers->update($serverId, name: $name)`) - DELETE /v1/servers/{id}: [Delete a server](https://sendora.se/docs/api/delete-v1-servers-id) (TypeScript: `account.servers.delete(serverId)`, Python: `account.servers.delete(server_id)`, PHP: `$account->servers->delete($serverId)`) - GET /v1/servers/{id}/tokens: [List the keys of a server](https://sendora.se/docs/api/get-v1-servers-id-tokens) (TypeScript: `account.servers.tokens.list(serverId)`, Python: `account.servers.tokens.list(server_id)`, PHP: `$account->servers->tokens->list($serverId)`) - POST /v1/servers/{id}/tokens: [Create a key for a server](https://sendora.se/docs/api/post-v1-servers-id-tokens) (TypeScript: `account.servers.tokens.create(serverId, { name })`, Python: `account.servers.tokens.create(server_id, name=...)`, PHP: `$account->servers->tokens->create($serverId, name: $name)`) - GET /v1/servers/{id}/tokens/{tokenId}: [Read a key of a server](https://sendora.se/docs/api/get-v1-servers-id-tokens-tokenid) (TypeScript: `account.servers.tokens.get(serverId, tokenId)`, Python: `account.servers.tokens.get(server_id, token_id)`, PHP: `$account->servers->tokens->get($serverId, $tokenId)`) - DELETE /v1/servers/{id}/tokens/{tokenId}: [Revoke a key of a server](https://sendora.se/docs/api/delete-v1-servers-id-tokens-tokenid) (TypeScript: `account.servers.tokens.revoke(serverId, tokenId)`, Python: `account.servers.tokens.revoke(server_id, token_id)`, PHP: `$account->servers->tokens->revoke($serverId, $tokenId)`) - GET /v1/domains: [List the sending domains](https://sendora.se/docs/api/get-v1-domains) (TypeScript: `account.domains.list()`, Python: `account.domains.list()`, PHP: `$account->domains->list()`) - POST /v1/domains: [Add a sending domain](https://sendora.se/docs/api/post-v1-domains) (TypeScript: `account.domains.create({ domain })`, Python: `account.domains.create(domain=...)`, PHP: `$account->domains->create(domain: $domain)`) - GET /v1/domains/{id}: [Read a sending domain](https://sendora.se/docs/api/get-v1-domains-id) (TypeScript: `account.domains.get(domainId)`, Python: `account.domains.get(domain_id)`, PHP: `$account->domains->get($domainId)`) - DELETE /v1/domains/{id}: [Remove a sending domain](https://sendora.se/docs/api/delete-v1-domains-id) (TypeScript: `account.domains.delete(domainId)`, Python: `account.domains.delete(domain_id)`, PHP: `$account->domains->delete($domainId)`) - POST /v1/domains/{id}/verify: [Check the records now](https://sendora.se/docs/api/post-v1-domains-id-verify) (TypeScript: `account.domains.verify(domainId)`, Python: `account.domains.verify(domain_id)`, PHP: `$account->domains->verify($domainId)`) - GET /v1/tracking-domains: [List the tracking domains](https://sendora.se/docs/api/get-v1-tracking-domains) (TypeScript: `account.trackingDomains.list()`, Python: `account.tracking_domains.list()`, PHP: `$account->trackingDomains->list()`) - POST /v1/tracking-domains: [Add a tracking domain](https://sendora.se/docs/api/post-v1-tracking-domains) (TypeScript: `account.trackingDomains.create({ host })`, Python: `account.tracking_domains.create(host=...)`, PHP: `$account->trackingDomains->create(host: $host)`) - GET /v1/tracking-domains/{id}: [Read a tracking domain](https://sendora.se/docs/api/get-v1-tracking-domains-id) (TypeScript: `account.trackingDomains.get(trackingDomainId)`, Python: `account.tracking_domains.get(tracking_domain_id)`, PHP: `$account->trackingDomains->get($trackingDomainId)`) - DELETE /v1/tracking-domains/{id}: [Remove a tracking domain](https://sendora.se/docs/api/delete-v1-tracking-domains-id) (TypeScript: `account.trackingDomains.delete(trackingDomainId)`, Python: `account.tracking_domains.delete(tracking_domain_id)`, PHP: `$account->trackingDomains->delete($trackingDomainId)`) - POST /v1/tracking-domains/{id}/verify: [Check the records now](https://sendora.se/docs/api/post-v1-tracking-domains-id-verify) (TypeScript: `account.trackingDomains.verify(trackingDomainId)`, Python: `account.tracking_domains.verify(tracking_domain_id)`, PHP: `$account->trackingDomains->verify($trackingDomainId)`) - POST /v1/erasures: [Erase a person](https://sendora.se/docs/api/post-v1-erasures) (TypeScript: `account.erasures.create({ address })`, Python: `account.erasures.create(address=...)`, PHP: `$account->erasures->create(address: $address)`) - GET /v1/erasures/{id}: [Read an erasure](https://sendora.se/docs/api/get-v1-erasures-id) (TypeScript: `account.erasures.get(erasureId)`, Python: `account.erasures.get(erasure_id)`, PHP: `$account->erasures->get($erasureId)`) - GET /v1/suppressions: [List the suppressed addresses](https://sendora.se/docs/api/get-v1-suppressions) (TypeScript: `sendora.suppressions.list(query)`, Python: `sendora.suppressions.list(stream_id=..., limit=..., after=...)`, PHP: `$sendora->suppressions->list(streamId: $streamId, limit: $limit, after: $after)`) - POST /v1/suppressions/delete: [Lift a suppression](https://sendora.se/docs/api/post-v1-suppressions-delete) (TypeScript: `sendora.suppressions.delete({ address })`, Python: `sendora.suppressions.delete(address=...)`, PHP: `$sendora->suppressions->delete(address: $address)`) - GET /v1/tracking-opt-outs: [List the not-tracked addresses](https://sendora.se/docs/api/get-v1-tracking-opt-outs) (TypeScript: `sendora.trackingOptOuts.list(query)`, Python: `sendora.tracking_opt_outs.list(limit=..., after=...)`, PHP: `$sendora->trackingOptOuts->list(limit: $limit, after: $after)`) - POST /v1/tracking-opt-outs: [Add a not-tracked address](https://sendora.se/docs/api/post-v1-tracking-opt-outs) (TypeScript: `sendora.trackingOptOuts.add({ address })`, Python: `sendora.tracking_opt_outs.add(address=...)`, PHP: `$sendora->trackingOptOuts->add(address: $address)`) - POST /v1/tracking-opt-outs/delete: [Remove a not-tracked address](https://sendora.se/docs/api/post-v1-tracking-opt-outs-delete) (TypeScript: `sendora.trackingOptOuts.delete({ address })`, Python: `sendora.tracking_opt_outs.delete(address=...)`, PHP: `$sendora->trackingOptOuts->delete(address: $address)`) - GET /v1/webhooks: [List the webhooks](https://sendora.se/docs/api/get-v1-webhooks) (TypeScript: `sendora.webhooks.list()`, Python: `sendora.webhooks.list()`, PHP: `$sendora->webhooks->list()`) - POST /v1/webhooks: [Create a webhook](https://sendora.se/docs/api/post-v1-webhooks) (TypeScript: `sendora.webhooks.create({ url, events })`, Python: `sendora.webhooks.create(url=..., events=...)`, PHP: `$sendora->webhooks->create(url: $url, events: $events)`) - GET /v1/webhooks/{id}: [Read a webhook](https://sendora.se/docs/api/get-v1-webhooks-id) (TypeScript: `sendora.webhooks.get(webhookId)`, Python: `sendora.webhooks.get(webhook_id)`, PHP: `$sendora->webhooks->get($webhookId)`) - DELETE /v1/webhooks/{id}: [Remove a webhook](https://sendora.se/docs/api/delete-v1-webhooks-id) (TypeScript: `sendora.webhooks.delete(webhookId)`, Python: `sendora.webhooks.delete(webhook_id)`, PHP: `$sendora->webhooks->delete($webhookId)`) - GET /v1/webhooks/{id}/deliveries: [List the deliveries of a webhook](https://sendora.se/docs/api/get-v1-webhooks-id-deliveries) (TypeScript: `sendora.webhooks.deliveries(webhookId, query)`, Python: `sendora.webhooks.deliveries(webhook_id, status=..., limit=..., after=...)`, PHP: `$sendora->webhooks->deliveries($webhookId, status: $status, limit: $limit, after: $after)`) - POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay: [Replay a delivery](https://sendora.se/docs/api/post-v1-webhooks-id-deliveries-deliveryid-replay) (TypeScript: `sendora.webhooks.replay(webhookId, deliveryId)`, Python: `sendora.webhooks.replay(webhook_id, delivery_id)`, PHP: `$sendora->webhooks->replay($webhookId, $deliveryId)`) - POST /v1/webhooks/{id}/secrets: [Create a signing secret](https://sendora.se/docs/api/post-v1-webhooks-id-secrets) (TypeScript: `sendora.webhooks.createSecret(webhookId)`, Python: `sendora.webhooks.create_secret(webhook_id)`, PHP: `$sendora->webhooks->createSecret($webhookId)`) - DELETE /v1/webhooks/{id}/secrets/{secretId}: [Delete a signing secret](https://sendora.se/docs/api/delete-v1-webhooks-id-secrets-secretid) (TypeScript: `sendora.webhooks.deleteSecret(webhookId, secretId)`, Python: `sendora.webhooks.delete_secret(webhook_id, secret_id)`, PHP: `$sendora->webhooks->deleteSecret($webhookId, $secretId)`) - GET /v1/tokens: [List the keys](https://sendora.se/docs/api/get-v1-tokens) (TypeScript: `sendora.tokens.list()`, Python: `sendora.tokens.list()`, PHP: `$sendora->tokens->list()`) - POST /v1/tokens: [Create a key](https://sendora.se/docs/api/post-v1-tokens) (TypeScript: `sendora.tokens.create({ name })`, Python: `sendora.tokens.create(name=...)`, PHP: `$sendora->tokens->create(name: $name)`) - GET /v1/tokens/{id}: [Read a key](https://sendora.se/docs/api/get-v1-tokens-id) (TypeScript: `sendora.tokens.get(tokenId)`, Python: `sendora.tokens.get(token_id)`, PHP: `$sendora->tokens->get($tokenId)`) - DELETE /v1/tokens/{id}: [Revoke a key](https://sendora.se/docs/api/delete-v1-tokens-id) (TypeScript: `sendora.tokens.revoke(tokenId)`, Python: `sendora.tokens.revoke(token_id)`, PHP: `$sendora->tokens->revoke($tokenId)`) - [Webhook events](https://sendora.se/docs/api/webhook-events): what a webhook receives for each event, with its fields and an example.