@sendora/mcp 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"documents":[{"uri":"sendora://docs/quickstart","title":"Quickstart","description":"Send your first message and read it in the log.","text":"# Quickstart\n\nSend your first message and read it in the log.\n\nThree steps take you from an approved account to a delivered message.\n\n## Before you start\n\n- 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](sendora://docs/errors#when-an-account-cannot-send).\n- The domain you send from is verified, as [Domains](sendora://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.\n- To try this without sending real mail, use a [test server](sendora://docs/test-servers) and its key. A test server works before your account is approved and before payment.\n\n## 1. Get a server key\n\nSign 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](sendora://docs/keys) explains the two kinds of key.\n\nOver HTTP:\n\nSend the key as a bearer token on every request:\n\n```bash\nAuthorization: Bearer sk_…\n```\n\nWith the TypeScript SDK:\n\nInstall the SDK. It has no dependencies, is ESM, and runs on Node 20.19 or newer, Bun, Deno and Cloudflare Workers:\n\n```sh\nnpm install @sendora/sdk\n```\n\nKeep 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.\n\nWith the Python SDK:\n\nInstall the SDK. It runs on Python 3.11 or newer and has one dependency, httpx2:\n\n```sh\npip install sendora\n```\n\nPin the major version you test with, such as `sendora~=2.0` in your requirements. Only a new major version may break your code.\n\nKeep 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.\n\nWith the PHP SDK:\n\nInstall the SDK. It runs on PHP 8.2 or newer, with ext-curl and ext-json and no other package:\n\n```sh\ncomposer require sendora/sendora-php\n```\n\nComposer 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.\n\nKeep 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.\n\n## 2. Send a message\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your account is ready\",\n \"text\": \"Hi Anna, your account at Example is ready. Sign in at https://example.se/login.\",\n \"tag\": \"welcome\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { messageId } = await sendora.email.send({\n from: {\n email: 'no-reply@example.se',\n name: 'Example AB',\n },\n to: ['anna@example.com'],\n subject: 'Your account is ready',\n text: 'Hi Anna, your account at Example is ready. Sign in at https://example.se/login.',\n tag: 'welcome',\n});\n\nconsole.log(messageId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\"anna@example.com\"],\n subject=\"Your account is ready\",\n text=(\n \"Hi Anna, your account at Example is ready. \"\n \"Sign in at https://example.se/login.\"\n ),\n tag=\"welcome\",\n)\n\nprint(accepted.message_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'no-reply@example.se',\n 'name' => 'Example AB',\n ],\n to: ['anna@example.com'],\n subject: 'Your account is ready',\n text: 'Hi Anna, your account at Example is ready. '\n . 'Sign in at https://example.se/login.',\n tag: 'welcome',\n);\n\necho $accepted->messageId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false\n}\n```\n\nThe `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`.\n\nThe 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.\n\nIf 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](sendora://docs/sending#idempotency) explains keys and batches.\n\n## 3. Read the message\n\nThe message log keeps every message with its recipients and its timeline, for the time on [Limits](sendora://docs/limits#retention). Read one by its id:\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/messages/{messageId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst message = await sendora.messages.get(\n '5f432ffd-c005-499b-aa3a-5b8a088ea20e',\n);\n\nfor (const recipient of message.recipients) {\n console.log(recipient.address, recipient.status);\n}\nfor (const event of message.events) {\n console.log(\n event.at,\n event.type,\n event.code,\n event.details,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nmessage = sendora.messages.get(\n \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\"\n)\n\nfor recipient in message.recipients:\n print(recipient.address, recipient.status)\nfor event in message.events:\n print(event.at, event.type, event.code, event.details)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$message = $sendora->messages->get(\n '5f432ffd-c005-499b-aa3a-5b8a088ea20e',\n);\n\nforeach ($message->recipients as $recipient) {\n printf(\n \"%s %s\\n\",\n $recipient->address,\n $recipient->status,\n );\n}\nforeach ($message->events as $event) {\n printf(\n \"%s %s %s %s\\n\",\n $event->at->format('c'),\n $event->type,\n $event->code,\n $event->details,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your account is ready\",\n \"tag\": \"welcome\",\n \"metadata\": {},\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": false,\n \"opens\": 0,\n \"clicks\": 0,\n \"firstOpenAt\": null,\n \"firstHumanOpenAt\": null\n }\n ],\n \"tracking\": {\n \"recordingOpens\": false,\n \"recordingClicks\": false,\n \"skipped\": null\n },\n \"attachments\": [],\n \"events\": [\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Reception\",\n \"at\": \"2026-09-15T12:00:00.000Z\",\n \"code\": 250,\n \"details\": null,\n \"classification\": null,\n \"tracking\": null\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Delivery\",\n \"at\": \"2026-09-15T12:00:02.000Z\",\n \"code\": 250,\n \"details\": \"2.0.0 OK\",\n \"classification\": null,\n \"tracking\": null\n }\n ]\n}\n```\n\nThe 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.\n\nThe server's **Messages** tab in the dashboard shows the same log, searchable by recipient, tag, status and time.\n\n## What next\n\n- Register a [webhook](sendora://docs/webhooks) to get `delivered`, `bounced`, `deferred` and `spam_complaint` events as they happen.\n- Send from a system that only speaks SMTP, as [SMTP submission](sendora://docs/smtp) shows.\n- Look up any route, with its fields and error codes, in the [API reference](sendora://index)."},{"uri":"sendora://docs/sending","title":"Sending","description":"Single sends, batches, attachments, headers and idempotency.","text":"# Sending\n\nSingle sends, batches, attachments, headers and idempotency.\n\nSend messages one at a time or in a batch. Both take the same message and return the same result for each message. The [reference](sendora://reference/post-v1-email) lists every field, and [Limits](sendora://docs/limits#messages) gives every size and count.\n\n## The message\n\n- `from` must be an address on a [verified sending domain](sendora://docs/domains) of your account.\n- `to`, `cc` and `bcc` take plain addresses or objects with `email` and a display `name`. The recipients of all three count together.\n- `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.\n- `streamId` sets the [stream](sendora://docs/streams) the message goes on. Without it, the message goes on the server's default transactional stream.\n\n## Attachments, headers, tags and metadata\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n {\n \"email\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n }\n ],\n \"cc\": [\n \"ekonomi@example.se\"\n ],\n \"subject\": \"Invoice 2026-0912\",\n \"text\": \"Hi Anna, invoice 2026-0912 is attached. It is due on 2026-10-15.\",\n \"html\": \"<p>Hi Anna,</p><p>Invoice <strong>2026-0912</strong> is attached. It is due on 2026-10-15.</p>\",\n \"attachments\": [\n {\n \"name\": \"invoice-2026-0912.pdf\",\n \"content\": \"JVBERi0xLjcKJcOkw7zDtsOfCg==\",\n \"contentType\": \"application/pdf\"\n }\n ],\n \"headers\": {\n \"Reply-To\": \"ekonomi@example.se\",\n \"X-Invoice-Id\": \"2026-0912\"\n },\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\",\n \"customerId\": \"4711\"\n }\n}'\n```\n\nTypeScript:\n\n```ts\nimport { readFile } from 'node:fs/promises';\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst invoice = await readFile(\n new URL('./invoice-2026-0912.pdf', import.meta.url),\n);\n\nconst { messageId } = await sendora.email.send({\n from: {\n email: 'billing@example.se',\n name: 'Example AB',\n },\n to: [\n { email: 'anna@example.com', name: 'Anna Andersson' },\n ],\n cc: ['ekonomi@example.se'],\n subject: 'Invoice 2026-0912',\n text: 'Hi Anna, invoice 2026-0912 is attached. It is due on 2026-10-15.',\n html: '<p>Hi Anna,</p><p>Invoice <strong>2026-0912</strong> is attached. It is due on 2026-10-15.</p>',\n attachments: [\n {\n name: 'invoice-2026-0912.pdf',\n content: invoice,\n contentType: 'application/pdf',\n },\n ],\n headers: {\n 'Reply-To': 'ekonomi@example.se',\n 'X-Invoice-Id': '2026-0912',\n },\n tag: 'invoice',\n metadata: {\n invoiceId: '2026-0912',\n customerId: '4711',\n },\n});\n\nconsole.log(messageId);\n```\n\nPython:\n\n```python\nimport os\nfrom pathlib import Path\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\ninvoice = (\n Path(__file__)\n .with_name(\"invoice-2026-0912.pdf\")\n .read_bytes()\n)\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\n {\n \"email\": \"anna@example.com\",\n \"name\": \"Anna Andersson\",\n }\n ],\n cc=[\"ekonomi@example.se\"],\n subject=\"Invoice 2026-0912\",\n text=(\n \"Hi Anna, invoice 2026-0912 is attached. \"\n \"It is due on 2026-10-15.\"\n ),\n html=(\n \"<p>Hi Anna,</p>\"\n \"<p>Invoice <strong>2026-0912</strong> \"\n \"is attached. It is due on 2026-10-15.</p>\"\n ),\n attachments=[\n {\n \"name\": \"invoice-2026-0912.pdf\",\n \"content\": invoice,\n \"content_type\": \"application/pdf\",\n }\n ],\n headers={\n \"Reply-To\": \"ekonomi@example.se\",\n \"X-Invoice-Id\": \"2026-0912\",\n },\n tag=\"invoice\",\n metadata={\n \"invoiceId\": \"2026-0912\",\n \"customerId\": \"4711\",\n },\n)\n\nprint(accepted.message_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Attachment;\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'billing@example.se',\n 'name' => 'Example AB',\n ],\n to: [\n [\n 'email' => 'anna@example.com',\n 'name' => 'Anna Andersson',\n ],\n ],\n cc: ['ekonomi@example.se'],\n subject: 'Invoice 2026-0912',\n text: 'Hi Anna, invoice 2026-0912 is attached. '\n . 'It is due on 2026-10-15.',\n html: '<p>Hi Anna,</p>'\n . '<p>Invoice <strong>2026-0912</strong> '\n . 'is attached. It is due on 2026-10-15.</p>',\n attachments: [\n Attachment::fromFile(\n __DIR__ . '/invoice-2026-0912.pdf',\n contentType: 'application/pdf',\n ),\n ],\n headers: [\n 'Reply-To' => 'ekonomi@example.se',\n 'X-Invoice-Id' => '2026-0912',\n ],\n tag: 'invoice',\n metadata: [\n 'invoiceId' => '2026-0912',\n 'customerId' => '4711',\n ],\n);\n\necho $accepted->messageId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"4e1c3f2a-9b6d-4e5c-9a0f-3d4e5f6a7b82\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false\n}\n```\n\n- `attachments`: send the content base64-encoded, without line breaks. `contentType` defaults to `application/octet-stream`. Set `contentId` to use the attachment as an inline image, with `<img src=\"cid:…\">` in the HTML. Sendora deletes the content once its mail server has the message, and keeps the name, type and size.\n- `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](sendora://reference/post-v1-email) lists every one.\n- `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.\n- `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.\n\nWith the TypeScript SDK:\n\nThe SDK takes attachment content as bytes, a `Uint8Array` or a Node `Buffer`, and encodes it. It passes a base64 string through as it is.\n\nWith the Python SDK:\n\nThe SDK takes attachment content as `bytes` and encodes it. It passes a base64 string through as it is. The content id is `content_id`.\n\nWith the PHP SDK:\n\n`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`.\n\n## Idempotency\n\nA 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](sendora://docs/limits#requests) gives the key's length and how long Sendora remembers it.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Idempotency-Key: order-4711-confirmation\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"order@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Order 4711 confirmed\",\n \"text\": \"Hi Anna, we have received order 4711 and will ship it tomorrow.\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { messageId } = await sendora.email.send(\n {\n from: {\n email: 'order@example.se',\n name: 'Example AB',\n },\n to: ['anna@example.com'],\n subject: 'Order 4711 confirmed',\n text: 'Hi Anna, we have received order 4711 and will ship it tomorrow.',\n },\n { idempotencyKey: 'order-4711-confirmation' },\n);\n\nconsole.log(messageId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"order@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\"anna@example.com\"],\n subject=\"Order 4711 confirmed\",\n text=(\n \"Hi Anna, we have received order 4711 \"\n \"and will ship it tomorrow.\"\n ),\n idempotency_key=\"order-4711-confirmation\",\n)\n\nprint(accepted.message_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'order@example.se',\n 'name' => 'Example AB',\n ],\n to: ['anna@example.com'],\n subject: 'Order 4711 confirmed',\n text: 'Hi Anna, we have received order 4711 '\n . 'and will ship it tomorrow.',\n idempotencyKey: 'order-4711-confirmation',\n);\n\necho $accepted->messageId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f2d4a3b-0c7e-4f6d-8b1a-4e5f6a7b8c93\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false\n}\n```\n\nOver HTTP:\n\nSend the key in the `Idempotency-Key` header. A replayed answer has the header `Idempotent-Replayed: true`.\n\nWith the TypeScript SDK:\n\nThe 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.\n\nWith the Python SDK:\n\nThe 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.\n\nWith the PHP SDK:\n\nThe 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.\n\nKeys are per server. The same key with a different message fails with `idempotency_key_mismatch`, since a changed request is a new request.\n\n## Batches\n\nA batch sends a list of messages in one request, up to the number on [Limits](sendora://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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email/batch \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Idempotency-Key: invoices-2026-09-15-run-1\" \\\n -H \"Content-Type: application/json\" \\\n -d '[\n {\n \"from\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Invoice 2026-0912\",\n \"text\": \"Hi Anna, invoice 2026-0912 is due on 2026-10-15.\",\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n }\n },\n {\n \"from\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"bo@example.com\"\n ],\n \"subject\": \"Invoice 2026-0913\",\n \"text\": \"Hi Bo, invoice 2026-0913 is due on 2026-10-15.\",\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0913\"\n }\n }\n]'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { results } = await sendora.email.sendBatch(\n [\n {\n from: {\n email: 'billing@example.se',\n name: 'Example AB',\n },\n to: ['anna@example.com'],\n subject: 'Invoice 2026-0912',\n text: 'Hi Anna, invoice 2026-0912 is due on 2026-10-15.',\n tag: 'invoice',\n metadata: { invoiceId: '2026-0912' },\n },\n {\n from: {\n email: 'billing@example.se',\n name: 'Example AB',\n },\n to: ['bo@example.com'],\n subject: 'Invoice 2026-0913',\n text: 'Hi Bo, invoice 2026-0913 is due on 2026-10-15.',\n tag: 'invoice',\n metadata: { invoiceId: '2026-0913' },\n },\n ],\n { idempotencyKey: 'invoices-2026-09-15-run-1' },\n);\n\nfor (const result of results) {\n if (result.status === 'accepted') {\n console.log(\n result.index,\n result.messageId,\n result.replayed ? 'sent earlier' : 'sent',\n );\n } else {\n console.log(\n result.index,\n result.error,\n result.message,\n );\n }\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nbatch = sendora.email.send_batch(\n [\n {\n \"from_\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\",\n },\n \"to\": [\"anna@example.com\"],\n \"subject\": \"Invoice 2026-0912\",\n \"text\": (\n \"Hi Anna, invoice 2026-0912 \"\n \"is due on 2026-10-15.\"\n ),\n \"tag\": \"invoice\",\n \"metadata\": {\"invoiceId\": \"2026-0912\"},\n },\n {\n \"from_\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\",\n },\n \"to\": [\"bo@example.com\"],\n \"subject\": \"Invoice 2026-0913\",\n \"text\": (\n \"Hi Bo, invoice 2026-0913 \"\n \"is due on 2026-10-15.\"\n ),\n \"tag\": \"invoice\",\n \"metadata\": {\"invoiceId\": \"2026-0913\"},\n },\n ],\n idempotency_key=\"invoices-2026-09-15-run-1\",\n)\n\nfor result in batch.results:\n if result.status == \"accepted\":\n print(\n result.index,\n result.message_id,\n \"sent earlier\" if result.replayed else \"sent\",\n )\n else:\n print(result.index, result.code, result.message)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Types\\AcceptedBatchItem;\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$batch = $sendora->email->sendBatch(\n [\n [\n 'from' => [\n 'email' => 'billing@example.se',\n 'name' => 'Example AB',\n ],\n 'to' => ['anna@example.com'],\n 'subject' => 'Invoice 2026-0912',\n 'text' => 'Hi Anna, invoice 2026-0912 '\n . 'is due on 2026-10-15.',\n 'tag' => 'invoice',\n 'metadata' => ['invoiceId' => '2026-0912'],\n ],\n [\n 'from' => [\n 'email' => 'billing@example.se',\n 'name' => 'Example AB',\n ],\n 'to' => ['bo@example.com'],\n 'subject' => 'Invoice 2026-0913',\n 'text' => 'Hi Bo, invoice 2026-0913 '\n . 'is due on 2026-10-15.',\n 'tag' => 'invoice',\n 'metadata' => ['invoiceId' => '2026-0913'],\n ],\n ],\n idempotencyKey: 'invoices-2026-09-15-run-1',\n);\n\nforeach ($batch->results as $result) {\n if ($result instanceof AcceptedBatchItem) {\n printf(\n \"%d %s %s\\n\",\n $result->index,\n $result->messageId,\n $result->replayed ? 'sent earlier' : 'sent',\n );\n } else {\n printf(\n \"%d %s %s\\n\",\n $result->index,\n $result->errorCode,\n $result->message,\n );\n }\n}\n```\n\nResponse:\n\n```json\n{\n \"results\": [\n {\n \"index\": 0,\n \"messageId\": \"2c9a1d0e-7f4b-4c3a-9e8d-1b2c3d4e5f60\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"replayed\": false\n },\n {\n \"index\": 1,\n \"messageId\": \"3d0b2e1f-8a5c-4d4b-8f9e-2c3d4e5f6a71\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"replayed\": false\n }\n ]\n}\n```\n\nSendora 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.\n\n- 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`.\n- 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.\n- 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.\n\n## Send from Laravel\n\nThe 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:\n\n```sh\ncomposer require sendora/sendora-laravel\n```\n\nThen set the server key and the mailer in `.env`:\n\n```bash\nSENDORA_API_TOKEN=sk_…\nMAIL_MAILER=sendora\nMAIL_FROM_ADDRESS=no-reply@example.se\n```\n\nEvery 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.\n\n- A mailable's attachments, Cc, Bcc and Reply-To go through as Laravel sets them.\n- 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.\n- `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.\n\nA failed send throws Symfony's `TransportException`, with the SDK's exception as its previous one.\n\n## What happens next\n\nSendora 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](sendora://docs/messages) shows. To be told instead of polling, register a [webhook](sendora://docs/webhooks)."},{"uri":"sendora://docs/streams","title":"Streams","description":"Keep kinds of mail apart, each with its own suppression list.","text":"# Streams\n\nKeep kinds of mail apart, each with its own suppression list.\n\nEvery 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.\n\nEvery server starts with a default transactional stream. A message that names no stream goes there, so you can send without creating one.\n\n## Three kinds\n\n- `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.\n- `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](sendora://docs/broadcasts#rules-of-a-broadcast-stream), the unsubscribe link among them.\n- `inbound`: mail the server receives, as [Inbound](sendora://docs/inbound) explains. It sends nothing, and it has no suppression list.\n\nAny account can create transactional and broadcast streams. An inbound stream needs an approved account.\n\nAdd 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.\n\n## Create a stream\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/streams \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"kind\": \"transactional\",\n \"name\": \"Notices\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst stream = await sendora.streams.create({\n kind: 'transactional',\n name: 'Notices',\n});\n\nconsole.log(stream.streamId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nstream = sendora.streams.create(\n kind=\"transactional\", name=\"Notices\"\n)\n\nprint(stream.stream_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$stream = $sendora->streams->create(\n kind: 'transactional',\n name: 'Notices',\n);\n\necho $stream->streamId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\nThe 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](sendora://docs/suppressions) is empty, and you can limit a [webhook](sendora://docs/webhooks) to the stream.\n\n## Send on a stream\n\nSet `streamId` on the message. Without it, the message goes on the default stream. Over [SMTP](sendora://docs/smtp), set the header `X-Sendora-Stream` to the id instead.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your case has a new message\",\n \"text\": \"Hi Anna, there is a new message in your case. Sign in to read it.\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { messageId } = await sendora.email.send({\n from: {\n email: 'no-reply@example.se',\n name: 'Example AB',\n },\n streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n to: ['anna@example.com'],\n subject: 'Your case has a new message',\n text: 'Hi Anna, there is a new message in your case. Sign in to read it.',\n});\n\nconsole.log(messageId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\",\n },\n stream_id=\"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n to=[\"anna@example.com\"],\n subject=\"Your case has a new message\",\n text=(\n \"Hi Anna, there is a new message in your case. \"\n \"Sign in to read it.\"\n ),\n)\n\nprint(accepted.message_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'no-reply@example.se',\n 'name' => 'Example AB',\n ],\n streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n to: ['anna@example.com'],\n subject: 'Your case has a new message',\n text: 'Hi Anna, there is a new message in your case. '\n . 'Sign in to read it.',\n);\n\necho $accepted->messageId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"9a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-16T12:00:00.000Z\",\n \"test\": false\n}\n```\n\nA send fails when the stream cannot take it:\n\n| Code | When |\n| --------------------------------- | ------------------------------------------------------------------------------------------------------------ |\n| `stream_not_found` | The stream is not one of this server's. |\n| `stream_archived` | The stream is archived. |\n| `stream_not_sendable` | The stream is an inbound stream. A batch item or an SMTP submission that names it fails the same way. |\n| `stream_paused` | Sendora paused the broadcast stream after too many complaints. It shows `pausedAt` until support resumes it. |\n| `unsubscribe_placeholder_missing` | The stream is a broadcast stream, and a part of the message lacks `{{ unsubscribe_url }}`. |\n| `list_unsubscribe_reserved` | The stream is a broadcast stream, and the message sets its own `List-Unsubscribe` header. |\n\nThe last three follow from the [rules of a broadcast stream](sendora://docs/broadcasts#rules-of-a-broadcast-stream).\n\n## List, rename, archive\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/streams \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { streams } = await sendora.streams.list();\n\nfor (const stream of streams) {\n console.log(\n stream.name,\n stream.kind,\n stream.isDefault ? 'default' : '',\n stream.archivedAt ?? '',\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nlisted = sendora.streams.list()\n\nfor stream in listed.streams:\n print(\n stream.name,\n stream.kind,\n \"default\" if stream.is_default else \"\",\n stream.archived_at or \"\",\n )\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$listed = $sendora->streams->list();\n\nforeach ($listed->streams as $stream) {\n printf(\n \"%s %s %s %s\\n\",\n $stream->name,\n $stream->kind,\n $stream->isDefault ? 'default' : '',\n $stream->archivedAt?->format('c'),\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"streams\": [\n {\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"kind\": \"transactional\",\n \"name\": \"transactional\",\n \"isDefault\": true,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n },\n {\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n }\n ]\n}\n```\n\nEvery message in the [log](sendora://docs/messages) carries its `streamId`, and the search filters by it.\n\n- [Update a stream](sendora://reference/patch-v1-streams-id) to rename it. Its id stays the same. The same call sets an inbound stream's `contentRetentionDays`.\n- [Archive a stream](sendora://reference/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.\n- Archiving the default stream fails with `default_stream`.\n\nIn 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.\n\nWith the TypeScript SDK:\n\n`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.\n\nWith the Python SDK:\n\n`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.\n\nWith the PHP SDK:\n\n`$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."},{"uri":"sendora://docs/broadcasts","title":"Broadcasts","description":"One message to many recipients, each with their own unsubscribe link.","text":"# Broadcasts\n\nOne message to many recipients, each with their own unsubscribe link.\n\nA 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.\n\nA broadcast goes on a broadcast stream. Create one with `kind: \"broadcast\"` before your first broadcast, as [Streams](sendora://docs/streams#create-a-stream) shows.\n\n## Send a broadcast\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Idempotency-Key: newsletter-2026-10\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"from\": {\n \"email\": \"news@example.se\",\n \"name\": \"Example AB\"\n },\n \"subject\": \"News from Example in October\",\n \"text\": \"Hi!\\n\\nHere is what happened in October.\\n\\nNo more of these? {{ unsubscribe_url }}\",\n \"html\": \"<p>Hi!</p><p>Here is what happened in October.</p><p><a href=\\\"{{ unsubscribe_url }}\\\">Unsubscribe</a></p>\",\n \"tag\": \"newsletter-2026-10\",\n \"messages\": [\n {\n \"to\": [\n \"anna@example.com\"\n ],\n \"metadata\": {\n \"customer\": \"1042\"\n }\n },\n {\n \"to\": [\n {\n \"email\": \"bo@example.com\",\n \"name\": \"Bo Berg\"\n }\n ]\n }\n ]\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { broadcastId, total } =\n await sendora.broadcasts.send(\n {\n streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',\n from: {\n email: 'news@example.se',\n name: 'Example AB',\n },\n subject: 'News from Example in October',\n text: 'Hi!\\n\\nHere is what happened in October.\\n\\nNo more of these? {{ unsubscribe_url }}',\n html: '<p>Hi!</p><p>Here is what happened in October.</p><p><a href=\"{{ unsubscribe_url }}\">Unsubscribe</a></p>',\n tag: 'newsletter-2026-10',\n messages: [\n {\n to: ['anna@example.com'],\n metadata: { customer: '1042' },\n },\n {\n to: [\n { email: 'bo@example.com', name: 'Bo Berg' },\n ],\n },\n ],\n },\n { idempotencyKey: 'newsletter-2026-10' },\n );\n\nconsole.log(broadcastId, total);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.broadcasts.send(\n stream_id=\"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n from_={\n \"email\": \"news@example.se\",\n \"name\": \"Example AB\",\n },\n subject=\"News from Example in October\",\n text=(\n \"Hi!\\n\\n\"\n \"Here is what happened in October.\\n\\n\"\n \"No more of these? {{ unsubscribe_url }}\"\n ),\n html=(\n \"<p>Hi!</p>\"\n \"<p>Here is what happened in October.</p>\"\n '<p><a href=\"{{ unsubscribe_url }}\">'\n \"Unsubscribe</a></p>\"\n ),\n tag=\"newsletter-2026-10\",\n messages=[\n {\n \"to\": [\"anna@example.com\"],\n \"metadata\": {\"customer\": \"1042\"},\n },\n {\n \"to\": [\n {\n \"email\": \"bo@example.com\",\n \"name\": \"Bo Berg\",\n }\n ]\n },\n ],\n idempotency_key=\"newsletter-2026-10\",\n)\n\nprint(accepted.broadcast_id, accepted.total)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->broadcasts->send(\n streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',\n from: [\n 'email' => 'news@example.se',\n 'name' => 'Example AB',\n ],\n subject: 'News from Example in October',\n text: \"Hi!\\n\\n\"\n . \"Here is what happened in October.\\n\\n\"\n . 'No more of these? {{ unsubscribe_url }}',\n html: '<p>Hi!</p>'\n . '<p>Here is what happened in October.</p>'\n . '<p><a href=\"{{ unsubscribe_url }}\">'\n . 'Unsubscribe</a></p>',\n tag: 'newsletter-2026-10',\n messages: [\n [\n 'to' => ['anna@example.com'],\n 'metadata' => ['customer' => '1042'],\n ],\n [\n 'to' => [\n [\n 'email' => 'bo@example.com',\n 'name' => 'Bo Berg',\n ],\n ],\n ],\n ],\n idempotencyKey: 'newsletter-2026-10',\n);\n\necho $accepted->broadcastId, ' ', $accepted->total, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"status\": \"accepted\",\n \"total\": 2,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"test\": false\n}\n```\n\nGive 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.\n\n[Limits](sendora://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.\n\nThe `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.\n\nWith the TypeScript SDK:\n\nPass 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.\n\nWith the Python SDK:\n\nPass 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.\n\nWith the PHP SDK:\n\nPass 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`.\n\n## Rules of a broadcast stream\n\nThese rules apply to every message on a broadcast stream, whether it goes in a broadcast or alone.\n\n- Send only to people who asked for the mail, as the [acceptable use rules](https://sendora.se/acceptable-use) say.\n- 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`.\n- 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`.\n- A recipient who unsubscribes goes on the stream's [suppression list](sendora://docs/suppressions), and your [webhook](sendora://docs/webhooks) gets the event `unsubscribed`.\n\nIf complaints pass the rate on [Limits](sendora://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.\n\n## What is dropped and what is refused\n\n- 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`.\n- 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.\n- A broadcast on a transactional or an inbound stream fails with `stream_not_broadcast`, and on an archived one with `stream_archived`.\n\n## Personalise\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Idempotency-Key: welcome-2026-10\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"from\": {\n \"email\": \"news@example.se\",\n \"name\": \"Example AB\"\n },\n \"subject\": \"{{ first_name }}, your October news\",\n \"text\": \"Hi {{ first_name }}!\\n\\nYou have {{ points }} points to spend before the end of the year.\\n\\nNo more of these? {{ unsubscribe_url }}\",\n \"messages\": [\n {\n \"to\": [\n \"anna@example.com\"\n ],\n \"substitutions\": {\n \"first_name\": \"Anna\",\n \"points\": \"120\"\n }\n },\n {\n \"to\": [\n \"bo@example.com\"\n ],\n \"substitutions\": {\n \"first_name\": \"Bo\",\n \"points\": \"35\"\n }\n }\n ]\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { broadcastId } = await sendora.broadcasts.send(\n {\n streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',\n from: {\n email: 'news@example.se',\n name: 'Example AB',\n },\n subject: '{{ first_name }}, your October news',\n text: 'Hi {{ first_name }}!\\n\\nYou have {{ points }} points to spend before the end of the year.\\n\\nNo more of these? {{ unsubscribe_url }}',\n messages: [\n {\n to: ['anna@example.com'],\n substitutions: {\n first_name: 'Anna',\n points: '120',\n },\n },\n {\n to: ['bo@example.com'],\n substitutions: { first_name: 'Bo', points: '35' },\n },\n ],\n },\n { idempotencyKey: 'welcome-2026-10' },\n);\n\nconsole.log(broadcastId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.broadcasts.send(\n stream_id=\"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n from_={\n \"email\": \"news@example.se\",\n \"name\": \"Example AB\",\n },\n subject=\"{{ first_name }}, your October news\",\n text=(\n \"Hi {{ first_name }}!\\n\\n\"\n \"You have {{ points }} points to spend \"\n \"before the end of the year.\\n\\n\"\n \"No more of these? {{ unsubscribe_url }}\"\n ),\n messages=[\n {\n \"to\": [\"anna@example.com\"],\n \"substitutions\": {\n \"first_name\": \"Anna\",\n \"points\": \"120\",\n },\n },\n {\n \"to\": [\"bo@example.com\"],\n \"substitutions\": {\n \"first_name\": \"Bo\",\n \"points\": \"35\",\n },\n },\n ],\n idempotency_key=\"welcome-2026-10\",\n)\n\nprint(accepted.broadcast_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->broadcasts->send(\n streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',\n from: [\n 'email' => 'news@example.se',\n 'name' => 'Example AB',\n ],\n subject: '{{ first_name }}, your October news',\n text: \"Hi {{ first_name }}!\\n\\n\"\n . 'You have {{ points }} points to spend '\n . \"before the end of the year.\\n\\n\"\n . 'No more of these? {{ unsubscribe_url }}',\n messages: [\n [\n 'to' => ['anna@example.com'],\n 'substitutions' => [\n 'first_name' => 'Anna',\n 'points' => '120',\n ],\n ],\n [\n 'to' => ['bo@example.com'],\n 'substitutions' => [\n 'first_name' => 'Bo',\n 'points' => '35',\n ],\n ],\n ],\n idempotencyKey: 'welcome-2026-10',\n);\n\necho $accepted->broadcastId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"broadcastId\": \"9b7e5d3c-1a2f-4e6b-8c0d-2f4a6b8c0d1e\",\n \"status\": \"accepted\",\n \"total\": 2,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:10:00.000Z\",\n \"test\": false\n}\n```\n\nWrite `{{ 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](sendora://docs/limits#broadcasts) gives how many values a message may have, and how long each may be.\n\nSendora 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.\n\nNothing else is rendered: there are no conditions, loops or filters.\n\n## Follow a broadcast\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/broadcasts/{broadcastId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst broadcast = await sendora.broadcasts.get(\n '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',\n);\n\nconsole.log(\n broadcast.status,\n broadcast.released,\n broadcast.total,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nbroadcast = sendora.broadcasts.get(\n \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\"\n)\n\nprint(\n broadcast.status, broadcast.released, broadcast.total\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$broadcast = $sendora->broadcasts->get(\n '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',\n);\n\nprintf(\n \"%s %d %d\\n\",\n $broadcast->status,\n $broadcast->released,\n $broadcast->total,\n);\n```\n\nResponse:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"status\": \"processing\",\n \"from\": \"news@example.se\",\n \"subject\": \"News from Example in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 0,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": null,\n \"opened\": 0,\n \"openedByPeople\": 0,\n \"clicked\": 0,\n \"clickedByPeople\": 0,\n \"links\": []\n}\n```\n\n- `total`: the messages stored.\n- `released`: the messages handed to the mail server so far. Sendora releases them at the pace on [Limits](sendora://docs/limits#broadcasts). Divide the messages by that pace for the minutes a broadcast takes.\n- `failed`: the messages that never reached the mail server, cancelled ones included. A bounce is not counted here. It shows on its message.\n- `status`: `accepted`, then `processing`, then `completed` once nothing is left to send. A cancelled broadcast is `cancelled`. `completedAt` is set when it ends.\n- `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.\n- `openedByPeople` and `clickedByPeople`: those of them likely to be people.\n- `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](sendora://docs/limits#messages) says, so a recipient past those can be missing from a link.\n\nTracking is not open to accounts yet, so until it is, these counts are 0 and `links` is empty. Only this read has them, not the list or a cancel. [Messages and events](sendora://docs/messages#opens-and-clicks) says how the labels work.\n\nEach message of a broadcast is a message in the [log](sendora://docs/messages), with its own timeline and the `broadcastId`. The search filters by `broadcastId`, and every [webhook](sendora://docs/webhooks) event about such a message includes it. Follow deliveries, bounces, complaints and unsubscribes there, as for any other message.\n\n[List the broadcasts](sendora://reference/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.\n\n## Cancel a broadcast\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts/{broadcastId}/cancel \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst cancelled = await sendora.broadcasts.cancel(\n '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',\n);\n\nconsole.log(cancelled.status, cancelled.failed);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\ncancelled = sendora.broadcasts.cancel(\n \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\"\n)\n\nprint(cancelled.status, cancelled.failed)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$cancelled = $sendora->broadcasts->cancel(\n '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',\n);\n\necho $cancelled->status, ' ', $cancelled->failed, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"status\": \"cancelled\",\n \"from\": \"news@example.se\",\n \"subject\": \"News from Example in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 1,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": \"2026-10-01T08:05:00.000Z\"\n}\n```\n\nA 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`."},{"uri":"sendora://docs/domains","title":"Domains","description":"Verify a sending domain with two DNS records.","text":"# Domains\n\nVerify a sending domain with two DNS records.\n\nYour 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.\n\n## Before you start\n\nDomains 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](sendora://docs/keys) explains.\n\n## Add the domain\n\nThe 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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/domains \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"domain\": \"mail.example.se\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst domain = await account.domains.create({\n domain: 'mail.example.se',\n});\n\nconsole.log(\n `${domain.returnPath.type} ${domain.returnPath.host} -> ${domain.returnPath.value}`,\n);\nconsole.log(\n `${domain.dkim.type} ${domain.dkim.host} -> ${domain.dkim.value}`,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\ndomain = account.domains.create(domain=\"mail.example.se\")\n\nfor record in (domain.return_path, domain.dkim):\n print(record.type, record.host, \"->\", record.value)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$domain = $account->domains->create(\n domain: 'mail.example.se',\n);\n\n$records = [$domain->returnPath, $domain->dkim];\nforeach ($records as $record) {\n printf(\n \"%s %s -> %s\\n\",\n $record->type,\n $record->host,\n $record->value,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"mail.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.mail.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"sendora-a1b2c3d4._domainkey.mail.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\nAdd both records at your DNS provider:\n\n- `returnPath`: a CNAME from `sendora-bounces.<your domain>` to `bounces.sendora.se`. Bounce reports and feedback-loop reports for your mail arrive there and become events on your messages.\n- `dkim`: a TXT record under `<selector>._domainkey.<your domain>` 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.\n\nThe 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`.\n\nAdding a domain the account already has fails with `domain_exists`, which gives the id of the existing one.\n\n## Check the records\n\nSendora 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:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/domains/{domainId}/verify \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst domain = await account.domains.verify(\n '0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01',\n);\n\nconsole.log(\n domain.verified,\n domain.check.returnPath,\n domain.check.dkim,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\ndomain = account.domains.verify(\n \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\"\n)\n\nprint(\n domain.verified,\n domain.check.return_path,\n domain.check.dkim,\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$domain = $account->domains->verify(\n '0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01',\n);\n\nprintf(\n \"%s %s %s\\n\",\n $domain->verified ? 'verified' : 'not verified',\n $domain->check->returnPath,\n $domain->check->dkim,\n);\n```\n\nResponse:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"mail.example.se\",\n \"verified\": true,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": \"2026-09-15T12:10:00.000Z\",\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.mail.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-15T12:10:00.000Z\"\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"sendora-a1b2c3d4._domainkey.mail.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-15T12:10:00.000Z\"\n },\n \"check\": {\n \"returnPath\": \"ok\",\n \"dkim\": \"ok\"\n }\n}\n```\n\n`check` says what each lookup found:\n\n| `check` | Meaning |\n| ----------- | ------------------------------------------------------------------------------------- |\n| `ok` | The record is there with the right value. |\n| `missing` | There is no record. |\n| `mismatch` | A record is there with another value. |\n| `dns_error` | The resolver got no trustworthy answer, for example because DNSSEC validation failed. |\n\nThe domain is `verified` once both records have been seen.\n\n## When a record goes missing\n\nA verified domain rides out DNS trouble rather than stopping at the first failed lookup:\n\n- A `dns_error` changes nothing. The resolver could not answer, which says nothing about your records, so the domain stays as it was.\n- 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.\n- Any check that finds both records ends the warning.\n- 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.\n\nA DNS move is the usual cause. Publish both records at the new provider before you switch, and the domain never enters its warning.\n\n## SPF, DKIM and DMARC\n\n- SPF is satisfied by the return-path domain, `sendora-bounces.<your domain>`, whose records Sendora keeps. It aligns with your domain under DMARC's relaxed mode.\n- DKIM signs with your domain, through the key above, so it aligns strictly.\n- DMARC is your own record, `_dmarc.<your domain>`. 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.\n\n## List and remove domains\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/domains \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst { domains } = await account.domains.list();\n\nfor (const domain of domains) {\n console.log(\n domain.domain,\n domain.verified ? 'verified' : 'waiting for DNS',\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nlisted = account.domains.list()\n\nfor domain in listed.domains:\n if domain.verified:\n print(domain.domain, \"verified\")\n else:\n print(domain.domain, \"waiting for DNS\")\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$listed = $account->domains->list();\n\nforeach ($listed->domains as $domain) {\n if ($domain->verified) {\n echo $domain->domain, \" verified\\n\";\n } else {\n echo $domain->domain, \" waiting for DNS\\n\";\n }\n}\n```\n\nResponse:\n\n```json\n{\n \"domains\": [\n {\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"mail.example.se\",\n \"verified\": true,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": \"2026-09-15T12:10:00.000Z\",\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.mail.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-15T12:10:00.000Z\"\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"sendora-a1b2c3d4._domainkey.mail.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-15T12:10:00.000Z\"\n }\n }\n ]\n}\n```\n\n[Removing a domain](sendora://reference/delete-v1-domains-id) refuses mail from it at once and discards its key. Adding it again creates a new key and new records.\n\n## Tracking domains\n\nA 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. Tracking is not open to accounts yet, so a host carries no mail until it is.\n\nAdd 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](sendora://docs/limits#account) says how many an account may have.\n\n### Add a tracking domain\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/tracking-domains \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"host\": \"links.example.se\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst domain = await account.trackingDomains.create({\n host: 'links.example.se',\n});\n\nconsole.log(domain.cname.host, domain.cname.value);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\ndomain = account.tracking_domains.create(\n host=\"links.example.se\"\n)\n\nprint(domain.cname.host, domain.cname.value)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$domain = $account->trackingDomains->create(\n host: 'links.example.se',\n);\n\nprintf(\n \"%s %s\\n\",\n $domain->cname->host,\n $domain->cname->value,\n);\n```\n\nResponse:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": null\n },\n \"caa\": {\n \"check\": null,\n \"record\": null\n }\n}\n```\n\nAdd 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](sendora://docs/limits#account) says.\n\nA claim can fail with these codes:\n\n| Code | Status | Meaning |\n| --------------------------------- | ------ | --------------------------------------------------------------------------------------------- |\n| `domain_reserved` | 400 | The name is Sendora's own, or holds a domain's return path. |\n| `tracking_domain_exists` | 409 | The account has that host, or a host on that sending domain. `trackingDomainId` gives its id. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |\n| `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. Choose a subdomain of it. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |\n\nIf 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.\n\n### Check the records\n\nSendora 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:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/tracking-domains/{trackingDomainId}/verify \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst domain = await account.trackingDomains.verify(\n '3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09',\n);\n\nconsole.log(\n domain.state,\n domain.cname.check,\n domain.caa.check,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\ndomain = account.tracking_domains.verify(\n \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\"\n)\n\nprint(\n domain.state,\n domain.cname.check,\n domain.caa.check,\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$domain = $account->trackingDomains->verify(\n '3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09',\n);\n\nprintf(\n \"%s %s %s\\n\",\n $domain->state,\n $domain->cname->check ?? 'not checked',\n $domain->caa->check ?? 'not checked',\n);\n```\n\nResponse:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"waiting_for_certificate\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": \"2026-11-05T12:10:00.000Z\",\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": null,\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": \"ok\"\n },\n \"caa\": {\n \"check\": \"ok\",\n \"record\": null\n }\n}\n```\n\n`cname.check` says what the lookup of the CNAME found:\n\n| `check` | Meaning |\n| ----------- | ----------------------------------------------------------------------------------------------------------- |\n| `ok` | The chain of CNAME records ends at `track.sendora.se`, and the host has the same addresses. |\n| `missing` | The name answers nothing. |\n| `mismatch` | The name points elsewhere, through another CNAME or an A record. An A record fails even if it is Sendora's. |\n| `proxied` | A proxy answers in front of the name. Switch it off for this record. |\n| `dns_error` | The resolver got no trustworthy answer. |\n\n`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:\n\n| `check` | Meaning |\n| ----------------- | --------------------------------------------------------- |\n| `ok` | There are no such records, or they allow it. |\n| `no_lets_encrypt` | The records do not allow Let's Encrypt. |\n| `other_account` | The records allow only another account at Let's Encrypt. |\n| `no_http_01` | The records do not allow the validation method `http-01`. |\n| `dns_error` | The resolver got no trustworthy answer. |\n\n`state` says where the host stands:\n\n| `state` | Meaning |\n| ------------------------- | -------------------------------------------------------------------------------------------------------- |\n| `pending` | The records have not been right yet. `expiresAt` says when the host goes. |\n| `waiting_for_certificate` | The records are right, and the certificate is not working yet. |\n| `ready` | New mail from the sending domain uses the host. |\n| `failing` | A check found the records wrong. A host that was ready stays in use until 24 hours after `failingSince`. |\n| `stopped` | Those 24 hours have passed, and new mail no longer uses the host. |\n| `sending_domain_lost` | The sending domain is deleted or has lost its verification, so new mail does not use the host. |\n\n### When a tracking domain's records go wrong\n\n- A `dns_error` changes nothing. The resolver could not answer, which says nothing about your records.\n- A check that finds the records wrong after they have been right starts a 24-hour warning, and `failingSince` says when it began.\n- If the records are still wrong an hour later, your account's administrators are mailed. A new record can look missing for that long.\n- Any check that finds the records right ends the warning.\n\n### Remove a tracking domain\n\n[Removing a tracking domain](sendora://reference/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."},{"uri":"sendora://docs/keys","title":"Keys","description":"The two kinds of API key, and how to rotate one.","text":"# Keys\n\nThe two kinds of API key, and how to rotate one.\n\nEvery request carries an API key as a bearer token. There are two kinds, and an operation takes only one of them.\n\n## Two kinds of key\n\n| | Server key | Account key |\n| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| Starts with | `sk_`, or `sk_test_` on a [test server](sendora://docs/test-servers) | `ak_` |\n| Belongs to | One server | The account |\n| 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. |\n| Never | Manages the account's servers or domains | Sends or reads mail |\n| 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. |\n\nYour 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.\n\nWith the TypeScript SDK:\n\nThe 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.\n\nWith the Python SDK:\n\nThe 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.\n\nWith the PHP SDK:\n\nThe 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.\n\n## A server comes with its first key\n\nA 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:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/servers \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Invoicing\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst server = await account.servers.create({\n name: 'Invoicing',\n});\n\nconsole.log(server.serverId);\nconsole.log(server.token.token);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nserver = account.servers.create(name=\"Invoicing\")\n\nprint(server.server_id)\nprint(server.token.token)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$server = $account->servers->create(name: 'Invoicing');\n\necho $server->serverId, \"\\n\";\necho $server->token->token, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"token\": {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n }\n}\n```\n\nStore `token.token` in your secret store. It is never shown again.\n\n## Create and list keys\n\nA 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.\n\nCreate a key:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/servers/{serverId}/tokens \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Invoicing system\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst key = await account.servers.tokens.create(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n { name: 'Invoicing system' },\n);\n\nconsole.log(key.token);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nkey = account.servers.tokens.create(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n name=\"Invoicing system\",\n)\n\nprint(key.token)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$key = $account->servers->tokens->create(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n name: 'Invoicing system',\n);\n\necho $key->token, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"tokenId\": \"9f2d7e6c-5b40-4e8b-8a1c-3b0c9e2f6d4a\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_c3d4e5f6g7\",\n \"createdAt\": \"2026-09-20T12:05:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x\"\n}\n```\n\nList a server's keys:\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/servers/{serverId}/tokens \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst { tokens } = await account.servers.tokens.list(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n\nfor (const key of tokens) {\n console.log(\n key.name,\n key.prefix,\n key.revokedAt ? 'revoked' : 'live',\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nlisted = account.servers.tokens.list(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\"\n)\n\nfor key in listed.tokens:\n print(\n key.name,\n key.prefix,\n \"revoked\" if key.revoked_at else \"live\",\n )\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$listed = $account->servers->tokens->list(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n\nforeach ($listed->tokens as $key) {\n printf(\n \"%s %s %s\\n\",\n $key->name,\n $key->prefix,\n $key->revokedAt === null ? 'live' : 'revoked',\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"tokens\": [\n {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null\n },\n {\n \"tokenId\": \"9f2d7e6c-5b40-4e8b-8a1c-3b0c9e2f6d4a\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_c3d4e5f6g7\",\n \"createdAt\": \"2026-09-20T12:05:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\nThese samples use the account key. A server key manages its own server's keys the same way:\n\n| Key | Manages the keys of | Path |\n| ----------- | ------------------------- | ------------------------------- |\n| Server key | Its own server | `/v1/tokens` |\n| Account key | Any server of the account | `/v1/servers/{serverId}/tokens` |\n\nWith the TypeScript SDK:\n\nWith a server key, `sendora.tokens.create({ name })`, `sendora.tokens.list()` and `sendora.tokens.revoke(tokenId)` manage the server's own keys.\n\nWith the Python SDK:\n\nWith a server key, `sendora.tokens.create(name=...)`, `sendora.tokens.list()` and `sendora.tokens.revoke(token_id)` manage the server's own keys.\n\nWith the PHP SDK:\n\nWith a server key, `$sendora->tokens->create(name: ...)`, `$sendora->tokens->list()` and `$sendora->tokens->revoke($tokenId)` manage the server's own keys.\n\n## Rotation is create, switch, revoke\n\n1. Create the new key.\n2. Move your systems to it; both keys work meanwhile.\n3. Revoke the old one. It stops working at once.\n\ncURL:\n\n```bash\ncurl -X DELETE https://api.sendora.se/v1/servers/{serverId}/tokens/{tokenId} \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nawait account.servers.tokens.revoke(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n '3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40',\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\naccount.servers.tokens.revoke(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$account->servers->tokens->revoke(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n '3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40',\n);\n```\n\nThe 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.\n\nIf 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."},{"uri":"sendora://docs/test-servers","title":"Test servers","description":"Send from staging and CI without delivering anything, and act out bounces and complaints.","text":"# Test servers\n\nSend from staging and CI without delivering anything, and act out bounces and complaints.\n\nA 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.\n\nA 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.\n\n## Create a test server\n\nIn 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\"`:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/servers \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"CI\",\n \"mode\": \"test\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst server = await account.servers.create({\n name: 'CI',\n mode: 'test',\n});\n\nconsole.log(server.token.token);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nserver = account.servers.create(name=\"CI\", mode=\"test\")\n\nprint(server.token.token)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$server = $account->servers->create(\n name: 'CI',\n mode: 'test',\n);\n\necho $server->token->token, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"serverId\": \"e4a7c2b9-3d1f-4b6e-9a8c-2f5d7b1e0c63\",\n \"name\": \"CI\",\n \"mode\": \"test\",\n \"createdAt\": \"2026-09-23T09:00:00.000Z\",\n \"token\": {\n \"tokenId\": \"c8e1f3a5-7b9d-4c2e-8f60-1a3b5c7d9e2f\",\n \"name\": \"default\",\n \"prefix\": \"sk_test_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-23T09:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n }\n}\n```\n\nA 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.\n\nA server's mode is fixed when you create it. An update that sets `mode` fails with `400 mode_immutable`.\n\n## Send with it\n\nSend 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`:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"hardbounce+signup-test@simulator.sendora.se\"\n ],\n \"subject\": \"Confirm your address\",\n \"text\": \"Confirm at https://example.se/confirm/abc123.\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { messageId, test } = await sendora.email.send({\n from: {\n email: 'no-reply@example.se',\n name: 'Example AB',\n },\n to: ['hardbounce+signup-test@simulator.sendora.se'],\n subject: 'Confirm your address',\n text: 'Confirm at https://example.se/confirm/abc123.',\n});\n\nconsole.log(messageId, test);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\"hardbounce+signup-test@simulator.sendora.se\"],\n subject=\"Confirm your address\",\n text=\"Confirm at https://example.se/confirm/abc123.\",\n)\n\nprint(accepted.message_id, accepted.test)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'no-reply@example.se',\n 'name' => 'Example AB',\n ],\n to: ['hardbounce+signup-test@simulator.sendora.se'],\n subject: 'Confirm your address',\n text: 'Confirm at https://example.se/confirm/abc123.',\n);\n\nprintf(\n \"%s %s\\n\",\n $accepted->messageId,\n $accepted->test ? 'test' : 'live',\n);\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"0b6f2d8e-4a1c-4e7b-9d3f-5c8a2e6b1f40\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-23T09:01:00.000Z\",\n \"test\": true\n}\n```\n\n[SMTP submission](sendora://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.\n\n## The simulator's addresses\n\nA test server reports every recipient as delivered, without sending anything, except at `simulator.sendora.se`. There, the part before the `@` decides what happens:\n\n| Address | What happens |\n| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| `hardbounce@simulator.sendora.se` | A hard bounce: the `bounced` webhook with `hard: true`, and the address is suppressed on that [stream](sendora://docs/streams) |\n| `softbounce@simulator.sendora.se` | A soft bounce, a full mailbox: the `bounced` webhook with `hard: false`, and no suppression |\n| `deferred@simulator.sendora.se` | The `deferred` webhook, then delivered a minute later |\n| `complaint@simulator.sendora.se` | Delivered, then a spam complaint: the `spam_complaint` webhook, and the address is suppressed |\n| any other address | Delivered |\n\nCase 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.\n\nA 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`:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"hardbounce+signup-test@simulator.sendora.se\"\n ],\n \"subject\": \"Confirm your address\",\n \"text\": \"Confirm at https://example.se/confirm/abc123.\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora, SendoraError } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\ntry {\n await sendora.email.send({\n from: {\n email: 'no-reply@example.se',\n name: 'Example AB',\n },\n to: ['hardbounce+signup-test@simulator.sendora.se'],\n subject: 'Confirm your address',\n text: 'Confirm at https://example.se/confirm/abc123.',\n });\n} catch (error) {\n if (\n error instanceof SendoraError &&\n error.code === 'test_address_on_live_server'\n ) {\n console.log(error.addresses);\n } else {\n throw error;\n }\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora, SendoraError\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\ntry:\n sendora.email.send(\n from_={\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\n \"hardbounce+signup-test@simulator.sendora.se\"\n ],\n subject=\"Confirm your address\",\n text=(\n \"Confirm at \"\n \"https://example.se/confirm/abc123.\"\n ),\n )\nexcept SendoraError as error:\n if error.code == \"test_address_on_live_server\":\n print(error.addresses)\n else:\n raise\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Exception\\SendoraException;\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\ntry {\n $sendora->email->send(\n from: [\n 'email' => 'no-reply@example.se',\n 'name' => 'Example AB',\n ],\n to: [\n 'hardbounce+signup-test@simulator.sendora.se',\n ],\n subject: 'Confirm your address',\n text: 'Confirm at '\n . 'https://example.se/confirm/abc123.',\n );\n} catch (SendoraException $error) {\n $code = 'test_address_on_live_server';\n if ($error->errorCode !== $code) {\n throw $error;\n }\n echo implode(', ', $error->addresses), \"\\n\";\n}\n```\n\nResponse:\n\n```json\n{\n \"error\": \"test_address_on_live_server\",\n \"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.\",\n \"addresses\": [\n \"hardbounce+signup-test@simulator.sendora.se\"\n ]\n}\n```\n\n## Limits and cost\n\nTest 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](sendora://docs/limits#test-servers). A refusal under them has the scope `test`. Write to support if your CI needs more.\n\nA 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`.\n\nA test server keeps messages for less time than a live server, as [Limits](sendora://docs/limits#retention) gives. The content of attachments is deleted as soon as the simulator has the message.\n\n## Going live\n\nA 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:\n\nOver HTTP:\n\nA test key starts with `sk_test_`, and a live server's key with `sk_` alone. Check for `sk_test_` when your application starts.\n\nWith the TypeScript SDK:\n\n`isTestKey` tells the two kinds of key apart:\n\n```ts\nimport { isTestKey, Sendora } from '@sendora/sdk';\n\nconst token = process.env.SENDORA_API_TOKEN ?? '';\nif (process.env.NODE_ENV === 'production' && isTestKey(token)) {\n throw new Error('SENDORA_API_TOKEN is a test key; nothing would be delivered');\n}\nconst sendora = new Sendora({ token });\n```\n\nWith the Python SDK:\n\n`is_test_key` tells the two kinds of key apart:\n\n```python\nimport os\n\nfrom sendora import Sendora, is_test_key\n\ntoken = os.environ[\"SENDORA_API_TOKEN\"]\nproduction = os.environ.get(\"APP_ENV\") == \"production\"\nif production and is_test_key(token):\n message = (\n \"SENDORA_API_TOKEN is a test key; \"\n \"nothing would be delivered\"\n )\n raise RuntimeError(message)\nsendora = Sendora(token)\n```\n\nWith the PHP SDK:\n\n`Keys::isTestKey()` tells the two kinds of key apart:\n\n```php\n<?php\n\nuse Sendora\\Keys;\nuse Sendora\\Sendora;\n\n$token = (string) getenv('SENDORA_API_TOKEN');\n$production = getenv('APP_ENV') === 'production';\nif ($production && Keys::isTestKey($token)) {\n throw new RuntimeException(\n 'SENDORA_API_TOKEN is a test key; '\n . 'nothing would be delivered',\n );\n}\n$sendora = new Sendora($token);\n```\n\nTo ask the API instead, read the key's server. The answer gives the server's `mode` and the sending domains a From address may use. It also lists each limit a send meets, with how much this minute and this month have used:\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/server \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { mode, limits } = await sendora.server.get();\nif (\n process.env.NODE_ENV === 'production' &&\n mode === 'test'\n) {\n throw new Error('This key belongs to a test server');\n}\n\nconsole.log(limits);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nserver = sendora.server.get()\nif (\n os.environ.get(\"APP_ENV\") == \"production\"\n and server.mode == \"test\"\n):\n message = \"This key belongs to a test server\"\n raise RuntimeError(message)\n\nprint(server.limits)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$server = $sendora->server->get();\nif (\n getenv('APP_ENV') === 'production'\n && $server->mode === 'test'\n) {\n throw new RuntimeException(\n 'This key belongs to a test server',\n );\n}\n\nforeach ($server->limits as $limit) {\n printf(\n \"%s %s %d of %d\\n\",\n $limit->scope,\n $limit->period,\n $limit->used,\n $limit->limit,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"serverId\": \"e4a7c2b9-3d1f-4b6e-9a8c-2f5d7b1e0c63\",\n \"name\": \"CI\",\n \"mode\": \"test\",\n \"createdAt\": \"2026-09-23T09:00:00.000Z\",\n \"limits\": [\n {\n \"scope\": \"test\",\n \"period\": \"minute\",\n \"limit\": 300,\n \"used\": 1,\n \"resetsAt\": \"2026-09-23T09:02:00.000Z\"\n },\n {\n \"scope\": \"test\",\n \"period\": \"month\",\n \"limit\": 10000,\n \"used\": 1,\n \"resetsAt\": \"2026-10-01T00:00:00.000Z\"\n }\n ],\n \"inboundCap\": null,\n \"sendingDomains\": [\n {\n \"domain\": \"example.se\",\n \"failingSince\": null\n }\n ]\n}\n```"},{"uri":"sendora://docs/messages","title":"Messages and events","description":"Statuses, timelines and searching the log.","text":"# Messages and events\n\nStatuses, timelines and searching the log.\n\nEvery 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.\n\n## Statuses\n\nA 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.\n\nEach 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](sendora://docs/webhooks) event:\n\n| What happened | Status | Timeline event | Webhook event |\n| -------------------------------------------------- | ----------- | ------------------ | ------------------------------- |\n| Sendora's mail server took the message | `queued` | `Reception` | none |\n| The receiver accepted it | `delivered` | `Delivery` | `delivered` |\n| The receiver asked Sendora to try later | `deferred` | `TransientFailure` | `deferred` |\n| The receiver refused it | `bounced` | `Bounce` | `bounced` |\n| A bounce report arrived later by mail | `bounced` | `OOB` | `bounced` |\n| The receiver was still deferring after three days | `expired` | `Expiration` | `bounced`, with `expired: true` |\n| The recipient reported it as spam | unchanged | `Feedback` | `spam_complaint` |\n| The recipient unsubscribed from a broadcast stream | unchanged | none | `unsubscribed` |\n| A tracked copy's pixel loaded | unchanged | `Open` | none |\n| A tracked link was followed | unchanged | `Click` | none |\n\nA 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`.\n\nA 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.\n\nRead a message with its timeline by its id:\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/messages/{messageId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst message = await sendora.messages.get(\n '5f432ffd-c005-499b-aa3a-5b8a088ea20e',\n);\n\nfor (const recipient of message.recipients) {\n console.log(recipient.address, recipient.status);\n}\nfor (const event of message.events) {\n console.log(\n event.at,\n event.type,\n event.code,\n event.details,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nmessage = sendora.messages.get(\n \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\"\n)\n\nfor recipient in message.recipients:\n print(recipient.address, recipient.status)\nfor event in message.events:\n print(event.at, event.type, event.code, event.details)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$message = $sendora->messages->get(\n '5f432ffd-c005-499b-aa3a-5b8a088ea20e',\n);\n\nforeach ($message->recipients as $recipient) {\n printf(\n \"%s %s\\n\",\n $recipient->address,\n $recipient->status,\n );\n}\nforeach ($message->events as $event) {\n printf(\n \"%s %s %s %s\\n\",\n $event->at->format('c'),\n $event->type,\n $event->code,\n $event->details,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your account is ready\",\n \"tag\": \"welcome\",\n \"metadata\": {},\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": false,\n \"opens\": 0,\n \"clicks\": 0,\n \"firstOpenAt\": null,\n \"firstHumanOpenAt\": null\n }\n ],\n \"tracking\": {\n \"recordingOpens\": false,\n \"recordingClicks\": false,\n \"skipped\": null\n },\n \"attachments\": [],\n \"events\": [\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Reception\",\n \"at\": \"2026-09-15T12:00:00.000Z\",\n \"code\": 250,\n \"details\": null,\n \"classification\": null,\n \"tracking\": null\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Delivery\",\n \"at\": \"2026-09-15T12:00:02.000Z\",\n \"code\": 250,\n \"details\": \"2.0.0 OK\",\n \"classification\": null,\n \"tracking\": null\n }\n ]\n}\n```\n\n## Opens and clicks\n\nThe log shows the opens and clicks of mail that is tracked. Tracking is not open to accounts yet, so until it is, `tracked` is `false` and the counts are 0.\n\nEach recipient has these fields, in a message you read by its id and in a search:\n\n| Field | What it says |\n| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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 |\n| `opens`, `clicks` | How many were recorded, machines' included |\n| `firstOpenAt` | The first open, or the first click that was likely a person's; `null` before that |\n| `firstHumanOpenAt` | The same, counting only what was likely a person; `null` before that |\n\nA message you read by its id also has `tracking`:\n\n| Field | What it says |\n| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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 |\n| `recordingClicks` | The same for clicks on its links |\n| `skipped` | Why tracking stayed out of at least one copy, or `null`. `no_tracking_domain`: the from domain had no working [tracking domain](sendora://docs/domains#tracking-domains). `gmail_clipping`: see below |\n\nTracked 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.\n\nAn `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:\n\n| Field | What it says |\n| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `automated` | Whether a machine rather than a person did it: `likely`, `unlikely`, or `unknown` where nobody can tell |\n| `agent` | The proxy or machine, where Sendora knows it: `apple_privacy`, `gmail_prefetch`, `gmail_proxy`, `yahoo_proxy`, `outlook_prefetch`, `proton_proxy`, `scanner` or `crawler` |\n| `reasons` | Why it was labelled a machine's: `privacy_proxy`, `prefetch_signature`, `delivery_timing`, `link_burst`, `scanner_network`, `bot_user_agent` or `many_networks` |\n| `client` | The mail program, where Sendora knows it: `apple_mail`, `gmail`, `outlook`, `yahoo`, `thunderbird`, `proton`, `browser` or `other` |\n| `device` | The kind of device, where Sendora knows it: `desktop`, `mobile` or `tablet` |\n| `first` | The recipient's first event of this kind |\n| `firstHuman` | The recipient's first event of this kind that was likely a person's |\n| `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 |\n\nA label is a judgement. Apple Mail fetches every image whether or not anyone reads the mail, so its 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`.\n\nThe timeline may not hold every open and click, while `opens` and `clicks` count them all. [Limits](sendora://docs/limits#messages) gives how many a recipient keeps and how many a read returns.\n\n## Search the log\n\nEvery filter is optional:\n\n| Filter | Matches |\n| ------------- | ------------------------------------------------------------------------------------ |\n| `recipient` | Messages to this address, or with this part of one, in `to`, `cc` or `bcc` |\n| `streamId` | Messages on this [stream](sendora://docs/streams) |\n| `broadcastId` | Messages of this [broadcast](sendora://docs/broadcasts) |\n| `tag` | Messages with this tag |\n| `status` | Messages with at least one recipient in this status, or in any of a list of statuses |\n| `from` | Messages submitted at or after this time |\n| `to` | Messages submitted before this time |\n\nA whole address in `recipient` matches exactly. Three characters or more of one match anywhere in an address, such as `kommun.se`.\n\n`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](sendora://docs/limits#page-sizes). Pass `next` as `after` for the next page.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/messages/search \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"tag\": \"welcome\",\n \"from\": \"2026-09-01T00:00:00Z\",\n \"limit\": 20\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst page = await sendora.messages.search({\n tag: 'welcome',\n from: '2026-09-01T00:00:00Z',\n limit: 20,\n});\n\nfor (const message of page.messages) {\n console.log(\n message.submittedAt,\n message.subject,\n message.recipients[0]?.status,\n );\n}\nconsole.log(\n page.next === null\n ? 'last page'\n : `next page after ${page.next}`,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.messages.search(\n tag=\"welcome\", from_=\"2026-09-01T00:00:00Z\", limit=20\n)\n\nfor message in page.messages:\n print(\n message.submitted_at,\n message.subject,\n message.recipients[0].status,\n )\nif page.next is None:\n print(\"last page\")\nelse:\n print(f\"next page after {page.next}\")\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->messages->search(\n tag: 'welcome',\n from: '2026-09-01T00:00:00Z',\n limit: 20,\n);\n\nforeach ($page->messages as $message) {\n printf(\n \"%s %s %s\\n\",\n $message->submittedAt->format('c'),\n $message->subject,\n $message->recipients[0]->status ?? '',\n );\n}\nif ($page->next === null) {\n echo \"last page\\n\";\n} else {\n echo \"next page after {$page->next}\\n\";\n}\n```\n\nResponse:\n\n```json\n{\n \"messages\": [\n {\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your account is ready\",\n \"tag\": \"welcome\",\n \"metadata\": {},\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": false,\n \"opens\": 0,\n \"clicks\": 0,\n \"firstOpenAt\": null,\n \"firstHumanOpenAt\": null\n }\n ]\n }\n ],\n \"next\": null\n}\n```\n\nWith the TypeScript SDK:\n\n`messages.searchAll` takes the same filters and walks every page for you, for `for await`. Its `from` and `to` also take a `Date`.\n\nWith the Python SDK:\n\n`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`.\n\nWith the PHP SDK:\n\n`$sendora->messages->searchAll()` takes the same filters and walks every page for you, as a generator. Its `from` and `to` also take a `DateTimeInterface`.\n\n## How long a message is kept\n\nSendora 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](sendora://docs/limits#retention) gives each period, and a [test server](sendora://docs/test-servers) keeps less.\n\nWhen a recipient asks you to erase their data, use an [erasure request](sendora://docs/suppressions#erasure-requests)."},{"uri":"sendora://docs/inbound","title":"Inbound","description":"Receive mail on an inbound stream or on your own domain, and read it.","text":"# Inbound\n\nReceive mail on an inbound stream or on your own domain, and read it.\n\nAn inbound [stream](sendora://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](sendora://docs/webhooks).\n\nSendora 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.\n\n## Before you start\n\n- An approved account. Until it is approved, creating an inbound stream fails.\n- A live server and its key. A [test server](sendora://docs/test-servers) has no inbound stream.\n\n## Receive your first message\n\n1. Create an inbound stream.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/streams \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"kind\": \"inbound\",\n \"name\": \"Support\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst stream = await sendora.streams.create({\n kind: 'inbound',\n name: 'Support',\n});\n\nconsole.log(stream.inboundAddress);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nstream = sendora.streams.create(\n kind=\"inbound\", name=\"Support\"\n)\n\nprint(stream.inbound_address)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$stream = $sendora->streams->create(\n kind: 'inbound',\n name: 'Support',\n);\n\necho $stream->inboundAddress, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"kind\": \"inbound\",\n \"name\": \"Support\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-19T07:55:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\",\n \"contentRetentionDays\": null\n}\n```\n\n2. Note the stream's `inboundAddress` from the response. It is the stream's id without its dashes, at `inbound.sendora.se`.\n3. [Register a webhook](sendora://docs/webhooks#register) that takes the `inbound` event. A webhook registered without `events` takes every event, `inbound` included.\n4. Send a message to the address from any mail account.\n5. Your webhook receives the `inbound` event. You can also find the message by [searching received mail](#search-received-mail).\n\nSendora accepts a message up to the size on [Limits](sendora://docs/limits#inbound-mail).\n\nPast the account's [cap on received mail](sendora://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](sendora://docs/limits#sending-rates-and-the-monthly-cap) says.\n\nA 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.\n\n## Route with a plus address\n\nAnything 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.\n\n## Receive on your own domain\n\nA 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`.\n\nThe 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.\n\n1. Claim the domain for the stream, with the stream's id.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/domains \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { mx, txt } = await sendora.inboundDomains.create({\n streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',\n domain: 'post.example.se',\n});\n\nconsole.log(`${mx.type} ${mx.host} -> ${mx.value}`);\nconsole.log(`${txt.type} ${txt.host} -> ${txt.value}`);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\ndomain = sendora.inbound_domains.create(\n stream_id=\"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n domain=\"post.example.se\",\n)\n\nfor record in (domain.mx, domain.txt):\n print(record.type, record.host, \"->\", record.value)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$domain = $sendora->inboundDomains->create(\n streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',\n domain: 'post.example.se',\n);\n\nforeach ([$domain->mx, $domain->txt] as $record) {\n printf(\n \"%s %s -> %s\\n\",\n $record->type,\n $record->host,\n $record->value,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n2. 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.\n3. Wait for Sendora's check, which runs every few minutes until the domain is verified, or ask for one:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/domains/{inboundDomainId}/verify \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst checked = await sendora.inboundDomains.verify(\n '6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60',\n);\nconst { verified, check } = checked;\n\nconsole.log(verified, check.mx, check.txt);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nchecked = sendora.inbound_domains.verify(\n \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\"\n)\n\nprint(\n checked.verified, checked.check.mx, checked.check.txt\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$checked = $sendora->inboundDomains->verify(\n '6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60',\n);\n\nprintf(\n \"%s %s %s\\n\",\n $checked->verified ? 'verified' : 'not verified',\n $checked->check->mx,\n $checked->check->txt,\n);\n```\n\nResponse:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": true,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": \"2026-09-19T09:10:00.000Z\",\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-19T09:10:00.000Z\"\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-19T09:10:00.000Z\"\n },\n \"check\": {\n \"mx\": \"ok\",\n \"txt\": \"ok\"\n }\n}\n```\n\n`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.\n\n- A stream has one domain.\n- A domain of Sendora's own fails with `domain_reserved`.\n- The first account to verify a domain keeps it. A claim to a domain that another account has verified fails with `inbound_domain_exists`.\n- Removing the domain refuses mail to it at once, and frees the domain for another claim.\n\n### If the records disappear\n\nSendora 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:\n\n- The domain keeps receiving through two such checks.\n- 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.\n- After the grace period on [Limits](sendora://docs/limits#inbound-mail), mail to the domain is refused until a check finds both records again.\n\nA sending domain follows another rule, which [Domains](sendora://docs/domains#when-a-record-goes-missing) explains.\n\n## The inbound event\n\nA 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](sendora://docs/webhooks#register) decides what else comes:\n\n- `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.\n- `reference` adds nothing. Read the rest through the API.\n\nWith `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](sendora://reference/webhook-events#event-inbound) lists every field.\n\n## Search received mail\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/search \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"limit\": 20\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst page = await sendora.inbound.search({\n streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',\n limit: 20,\n});\n\nfor (const message of page.messages) {\n console.log(\n message.receivedAt,\n message.from?.address,\n message.subject,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.inbound.search(\n stream_id=\"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n limit=20,\n)\n\nfor message in page.messages:\n print(\n message.received_at,\n message.from_.address if message.from_ else None,\n message.subject,\n )\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->inbound->search(\n streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',\n limit: 20,\n);\n\nforeach ($page->messages as $message) {\n printf(\n \"%s %s %s\\n\",\n $message->receivedAt->format('c'),\n $message->from?->address,\n $message->subject,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"messages\": [\n {\n \"inboundMessageId\": \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"receivedAt\": \"2026-09-19T08:00:02.000Z\",\n \"envelopeRecipient\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 412,\n \"attachmentCount\": 0,\n \"hasText\": true,\n \"hasHtml\": false,\n \"parseIssue\": null,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-19T08:00:03.000Z\"\n },\n \"contentAvailable\": true,\n \"contentExpiresAt\": \"2026-10-19T08:00:02.000Z\",\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"subject\": \"A question about my order\",\n \"date\": \"2026-09-19T08:00:00.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\nEvery 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`.\n\n## Read a message\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/inbound/4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31 \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst message = await sendora.inbound.get(\n '4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31',\n);\n\nconsole.log(message.from?.address, message.subject);\nconsole.log(message.authentication.dmarc);\nconsole.log(message.text);\nfor (const attachment of message.attachments) {\n console.log(attachment.name, attachment.size);\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nmessage = sendora.inbound.get(\n \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\"\n)\n\nprint(\n message.from_.address if message.from_ else None,\n message.subject,\n)\nprint(message.authentication.dmarc)\nprint(message.text)\nfor attachment in message.attachments:\n print(attachment.name, attachment.size)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$message = $sendora->inbound->get(\n '4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31',\n);\n\nprintf(\n \"%s %s\\n\",\n $message->from?->address,\n $message->subject,\n);\necho $message->authentication->dmarc, \"\\n\";\necho $message->text, \"\\n\";\nforeach ($message->attachments as $attachment) {\n echo $attachment->name, ' ', $attachment->size, \"\\n\";\n}\n```\n\nResponse:\n\n```json\n{\n \"inboundMessageId\": \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"receivedAt\": \"2026-09-19T08:00:02.000Z\",\n \"envelopeRecipient\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 412,\n \"attachmentCount\": 0,\n \"hasText\": true,\n \"hasHtml\": false,\n \"parseIssue\": null,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-19T08:00:03.000Z\"\n },\n \"contentAvailable\": true,\n \"contentExpiresAt\": \"2026-10-19T08:00:02.000Z\",\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"subject\": \"A question about my order\",\n \"date\": \"2026-09-19T08:00:00.000Z\",\n \"envelope\": {\n \"sender\": \"anna@example.com\"\n },\n \"replyTo\": [],\n \"to\": [\n {\n \"address\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\",\n \"name\": null\n }\n ],\n \"toCount\": 1,\n \"cc\": [],\n \"ccCount\": 0,\n \"messageIdHeader\": \"<question-1@example.com>\",\n \"inReplyTo\": null,\n \"references\": [],\n \"headers\": [\n {\n \"name\": \"From\",\n \"value\": \"Anna Andersson <anna@example.com>\"\n },\n {\n \"name\": \"To\",\n \"value\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\"\n },\n {\n \"name\": \"Subject\",\n \"value\": \"A question about my order\"\n },\n {\n \"name\": \"Message-ID\",\n \"value\": \"<question-1@example.com>\"\n },\n {\n \"name\": \"Date\",\n \"value\": \"Fri, 19 Sep 2026 10:00:00 +0200\"\n }\n ],\n \"text\": \"Hi!\\n\",\n \"html\": null,\n \"attachments\": []\n}\n```\n\n- `to` and `cc` hold as many entries as [Limits](sendora://docs/limits#inbound-mail) allows. `toCount` and `ccCount` give the whole number.\n- `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.\n- `dmarcPolicy` is set only when DMARC failed. `checkedAt` says when the checks ran.\n- `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.\n- `contentExpiresAt` says when the content goes. After that, `contentAvailable` is false and the content fields are null.\n\n## Download the raw message and the attachments\n\n`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`.\n\nOver HTTP:\n\n```\ncurl https://api.sendora.se/v1/inbound/4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31/raw \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -o message.eml\n```\n\nWith the TypeScript SDK:\n\n`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.\n\nWith the Python SDK:\n\n`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.\n\nWith the PHP SDK:\n\n`$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.\n\n## What Sendora keeps\n\nSendora 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](sendora://reference/patch-v1-streams-id), within the range on [Limits](sendora://docs/limits#inbound-mail). Null means the default that Limits gives. After that, only the reference remains, for the time on [Limits](sendora://docs/limits#retention).\n\nTo erase a person from received mail, use an [erasure request](sendora://docs/suppressions#erasure-requests). It covers their sent and received mail alike.\n\nThe 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."},{"uri":"sendora://docs/webhooks","title":"Webhooks","description":"Events pushed to your endpoint, how to verify them, and retries and replays.","text":"# Webhooks\n\nEvents pushed to your endpoint, how to verify them, and retries and replays.\n\nA 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.\n\n## Rules for your endpoint\n\n- Verify the signature against the raw body before you parse it. The steps are below.\n- 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](sendora://docs/limits#webhooks).\n- Expect the same delivery more than once, and key your handling on its `id`.\n- 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`.\n\n## Register\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/webhooks \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"deferred\",\n \"spam_complaint\"\n ]\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { webhookId, secret } =\n await sendora.webhooks.create({\n url: 'https://example.se/hooks/sendora',\n events: [\n 'delivered',\n 'bounced',\n 'deferred',\n 'spam_complaint',\n ],\n });\n\nconsole.log(webhookId);\nconsole.log(secret); // shown this once: store it beside the token\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nwebhook = sendora.webhooks.create(\n url=\"https://example.se/hooks/sendora\",\n events=[\n \"delivered\",\n \"bounced\",\n \"deferred\",\n \"spam_complaint\",\n ],\n)\n\nprint(webhook.webhook_id)\n# Shown this once: store it beside the token.\nprint(webhook.secret)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$webhook = $sendora->webhooks->create(\n url: 'https://example.se/hooks/sendora',\n events: [\n 'delivered',\n 'bounced',\n 'deferred',\n 'spam_complaint',\n ],\n);\n\necho $webhook->webhookId, \"\\n\";\n// Shown this once: store it beside the token.\necho $webhook->secret, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"webhookId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"deferred\",\n \"spam_complaint\"\n ],\n \"streamId\": null,\n \"enabled\": true,\n \"inboundContent\": \"full\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"secrets\": [\n {\n \"secretId\": \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ],\n \"secret\": \"whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a\"\n}\n```\n\nThe response includes `secret` once: the webhook's first signing secret. Store it with your other secrets.\n\n- 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.\n- A server has one webhook per URL. Registering a URL again fails with `webhook_exists`, which includes the existing webhook's id.\n- Leave out `events` to receive every event. 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.\n- With `streamId`, the webhook receives the message events of that one [stream](sendora://docs/streams). Cap events belong to no stream, so they reach the webhook either way.\n- `inboundContent` decides how much of a received message the `inbound` event carries, as [Inbound](sendora://docs/inbound#the-inbound-event) explains.\n\nYou 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.\n\n## Events\n\nEach 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](sendora://reference/webhook-events) lists every field of every event, with an example of each. Each event's name below links to its fields.\n\n| Event | Sent when |\n| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [`delivered`](sendora://reference/webhook-events#event-delivered) | The receiving server accepted the message for the recipient. |\n| [`bounced`](sendora://reference/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](sendora://docs/suppressions). |\n| [`deferred`](sendora://reference/webhook-events#event-deferred) | The receiving server asked Sendora to try later. Sendora keeps trying, so several may follow. |\n| [`spam_complaint`](sendora://reference/webhook-events#event-spam_complaint) | The recipient reported the message as spam. The recipient is suppressed. |\n| [`unsubscribed`](sendora://reference/webhook-events#event-unsubscribed) | The recipient unsubscribed from a [broadcast stream](sendora://docs/broadcasts) and is suppressed on it. The event comes a minute after the click, since the recipient can undo it for that long. |\n| [`cap_warning`](sendora://reference/webhook-events#event-cap_warning) | The server or the account has used most of its monthly cap, at the share on [Limits](sendora://docs/limits#sending-rates-and-the-monthly-cap). |\n| [`cap_reached`](sendora://reference/webhook-events#event-cap_reached) | A send was refused because the monthly cap is used up. |\n| [`inbound`](sendora://reference/webhook-events#event-inbound) | A message arrived on an [inbound stream](sendora://docs/inbound). |\n| [`inbound_cap_warning`](sendora://reference/webhook-events#event-inbound_cap_warning) | The account has received most of its monthly cap on received mail, at the share on [Limits](sendora://docs/limits#the-cap-on-received-mail). |\n| [`inbound_cap_reached`](sendora://reference/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. |\n\nOnly Sendora support can lift a spam complaint or an unsubscribe, as [Suppressions](sendora://docs/suppressions#lift-a-suppression) explains. When you get `unsubscribed`, take the recipient off your own list too.\n\nA message event has `test` set to true when the message was on a [test server](sendora://docs/test-servers). Its events were simulated, and nothing was sent.\n\n## Verify the signature\n\nEvery request carries `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`. To verify it:\n\n1. Read `t` and every `v1` from the header. There is one `v1` for each live secret of the webhook, the newest first.\n2. Compute an HMAC-SHA256 of `<t>.<raw body>` in hex. Its key is your whole secret, `whsec_` included.\n3. Compare it with each `v1` in constant time. One match is enough.\n4. Refuse the request if `t` is more than five minutes from your clock, so a captured request cannot be replayed.\n\nOnly 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.\n\nThe sample shows the steps in shell, and the one call that does all four in each SDK:\n\nThe recipe, in shell:\n\n```bash\n# Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]\n# One v1 per live secret of the webhook, the newest first; each is\n# HMAC-SHA256 with that secret over \"<t>.<raw body>\".\nexpected=$(printf '%s.%s' \"$T\" \"$RAW_BODY\" | openssl dgst -sha256 -hmac \"$SENDORA_WEBHOOK_SECRET\" | sed 's/^.* //')\n\n# Compare in constant time in your language, refuse a t more than five minutes from your clock, then parse the body.\n# One matching v1 is enough; $V1S holds every v1 of the header, one per line.\nprintf '%s\\n' \"$V1S\" | grep -qx \"$expected\" && echo \"signed by Sendora\"\n```\n\nTypeScript:\n\n```ts\nimport { verifyWebhook } from '@sendora/sdk';\n\n/*\n * A route handler for any framework that hands you a Fetch Request:\n * Next.js, Hono, SvelteKit, Cloudflare Workers. With Express or Fastify,\n * keep the raw body and pass it and the header the same way.\n */\nexport async function POST(\n request: Request,\n): Promise<Response> {\n const event = await verifyWebhook({\n secret: process.env.SENDORA_WEBHOOK_SECRET,\n signature: request.headers.get('sendora-signature'),\n body: await request.text(),\n });\n\n if (event.event === 'bounced' && event.details.hard) {\n console.log(\n `${event.recipient} is undeliverable: ${event.details.classification ?? 'unknown'}`,\n );\n }\n\n return new Response(null, { status: 204 });\n}\n```\n\nPython:\n\n```python\nimport os\nfrom collections.abc import Mapping\n\nfrom sendora import (\n BouncedEvent,\n WebhookVerificationError,\n verify_webhook,\n)\n\n# A handler for any framework: give it the raw body\n# exactly as it arrived and the request's headers.\ndef receive(\n body: bytes, headers: Mapping[str, str]\n) -> int:\n # Header names ignore case, and a plain dict does not.\n lowered = {\n name.lower(): value\n for name, value in headers.items()\n }\n try:\n event = verify_webhook(\n body,\n signature=lowered.get(\"sendora-signature\"),\n secret=os.environ[\"SENDORA_WEBHOOK_SECRET\"],\n )\n except WebhookVerificationError:\n return 400\n\n if (\n isinstance(event, BouncedEvent)\n and event.details.hard\n ):\n print(\n f\"{event.recipient} is undeliverable: \"\n f\"{event.details.classification or 'unknown'}\"\n )\n\n return 204\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Events\\BouncedEvent;\nuse Sendora\\Exception\\WebhookVerificationException;\nuse Sendora\\WebhookVerifier;\n\n// A receiver in plain PHP. In Symfony or Laravel, pass\n// $request->getContent() and the header the same way.\n$header = $_SERVER['HTTP_SENDORA_SIGNATURE'] ?? null;\n\ntry {\n $event = WebhookVerifier::verify(\n (string) file_get_contents('php://input'),\n signature: is_string($header) ? $header : null,\n secret: (string) getenv('SENDORA_WEBHOOK_SECRET'),\n );\n} catch (WebhookVerificationException) {\n http_response_code(400);\n exit;\n}\n\nif (\n $event instanceof BouncedEvent\n && $event->details->hard\n) {\n error_log(sprintf(\n '%s is undeliverable: %s',\n $event->recipient,\n $event->details->classification ?? 'unknown',\n ));\n}\n\nhttp_response_code(204);\n```\n\nWith the TypeScript SDK:\n\n`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.\n\nWith the Python SDK:\n\n`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.\n\nThe 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](sendora://docs/limits#inbound-mail) shows.\n\nPass 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.\n\nWith the PHP SDK:\n\n`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.\n\nThe 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](sendora://docs/limits#inbound-mail) shows, so raise it for the webhook's route.\n\nIn 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.\n\n## Receive in Laravel\n\nThe 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.\n\n1. Register the webhook with the address of the package's route, such as `https://example.se/sendora/webhook`.\n2. 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.\n3. Listen for the events you want, with a listener in `app/Listeners` or with `Event::listen()`.\n\nThe 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](sendora://docs/limits#webhooks) says.\n\n| Event | Laravel event in `Sendora\\Laravel\\Events` |\n| --------------------- | ----------------------------------------- |\n| `delivered` | `MessageDelivered` |\n| `bounced` | `MessageBounced` |\n| `deferred` | `MessageDeferred` |\n| `spam_complaint` | `SpamComplaintReceived` |\n| `unsubscribed` | `RecipientUnsubscribed` |\n| `cap_warning` | `CapWarning` |\n| `cap_reached` | `CapReached` |\n| `inbound` | `InboundEmailReceived` |\n| `inbound_cap_warning` | `InboundCapWarning` |\n| `inbound_cap_reached` | `InboundCapReached` |\n| any other | `UnknownWebhookReceived` |\n\nEach 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.\n\n## Roll the secret\n\nA webhook can have two live secrets at once, so you can replace one without a gap:\n\n1. Create a new secret. From then on, every delivery is signed with both.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/webhooks/{webhookId}/secrets \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst rolled = await sendora.webhooks.createSecret(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n\nconsole.log(rolled.secretId, rolled.secret);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nrolled = sendora.webhooks.create_secret(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\"\n)\n\nprint(rolled.secret_id, rolled.secret)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$rolled = $sendora->webhooks->createSecret(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n\necho $rolled->secretId, ' ', $rolled->secret, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"secretId\": \"5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"secret\": \"whsec_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f\"\n}\n```\n\n2. Move your receiver to the new secret. Deliveries verify with either one meanwhile.\n3. Delete the old secret by its id, which the webhook's `secrets` lists.\n\ncURL:\n\n```bash\ncurl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId}/secrets/{secretId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nawait sendora.webhooks.deleteSecret(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n '4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d',\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nsendora.webhooks.delete_secret(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$sendora->webhooks->deleteSecret(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n '4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d',\n);\n```\n\nA 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.\n\n## Retries, dead letters and replay\n\nSendora retries a failed attempt with growing delays, for about a day, on the schedule on [Limits](sendora://docs/limits#webhooks). After the last retry, the delivery is `dead`. It stays listed until you replay it.\n\ncURL:\n\n```bash\ncurl 'https://api.sendora.se/v1/webhooks/{webhookId}/deliveries?status=dead&limit=50' \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { deliveries } = await sendora.webhooks.deliveries(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n {\n status: 'dead',\n limit: 50,\n },\n);\n\nfor (const delivery of deliveries) {\n console.log(\n delivery.deliveryId,\n delivery.event,\n delivery.attempts,\n delivery.lastError,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.webhooks.deliveries(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n status=\"dead\",\n limit=50,\n)\n\nfor delivery in page.deliveries:\n print(\n delivery.delivery_id,\n delivery.event,\n delivery.attempts,\n delivery.last_error,\n )\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->webhooks->deliveries(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n status: 'dead',\n limit: 50,\n);\n\nforeach ($page->deliveries as $delivery) {\n printf(\n \"%s %s %d %s\\n\",\n $delivery->deliveryId,\n $delivery->event,\n $delivery->attempts,\n $delivery->lastError,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"deliveries\": [\n {\n \"deliveryId\": \"9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c60\",\n \"event\": \"bounced\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"inboundMessageId\": null,\n \"status\": \"dead\",\n \"attempts\": 9,\n \"nextAttemptAt\": null,\n \"lastStatusCode\": 503,\n \"lastError\": \"http_503\",\n \"deliveredAt\": null,\n \"createdAt\": \"2026-09-14T12:00:02.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\nA replay queues a delivery again, whatever its state, with the same `id` and event. Its `attempt` keeps counting. The [reference](sendora://reference/post-v1-webhooks-id-deliveries-deliveryid-replay) has the call.\n\n## Remove\n\ncURL:\n\n```bash\ncurl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nawait sendora.webhooks.delete(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nsendora.webhooks.delete(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\"\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$sendora->webhooks->delete(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n```\n\nDeliveries still pending for the webhook are dropped with it."},{"uri":"sendora://docs/suppressions","title":"Suppressions","description":"Addresses a stream no longer sends to, why they are there, and how to lift one.","text":"# Suppressions\n\nAddresses a stream no longer sends to, why they are there, and how to lift one.\n\nEach [stream](sendora://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](sendora://docs/broadcasts) instead drops suppressed addresses and sends to the rest.\n\nThe calls on this page take `streamId`, and without it they mean the server's default transactional stream.\n\n## How an address gets there\n\n| Reason | Added when | Who can lift it |\n| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- |\n| `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 |\n| `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 |\n| `unsubscribe` | The recipient used the unsubscribe link of a message on a [broadcast stream](sendora://docs/broadcasts), or their mail client's one-click button. The `unsubscribed` [webhook](sendora://docs/webhooks) tells you. | Sendora support only |\n| `manual` | You added the address in the dashboard. The API cannot add one. | You |\n\nAfter 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.\n\n## Read the list\n\ncURL:\n\n```bash\ncurl 'https://api.sendora.se/v1/suppressions?limit=100' \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { suppressions } = await sendora.suppressions.list({\n limit: 100,\n});\n\nfor (const entry of suppressions) {\n console.log(\n entry.address,\n entry.reason,\n entry.createdAt,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.suppressions.list(limit=100)\n\nfor entry in page.suppressions:\n print(entry.address, entry.reason, entry.created_at)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->suppressions->list(limit: 100);\n\nforeach ($page->suppressions as $entry) {\n printf(\n \"%s %s %s\\n\",\n $entry->address,\n $entry->reason,\n $entry->createdAt->format('c'),\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"suppressions\": [\n {\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"address\": \"bounced@example.com\",\n \"reason\": \"hard_bounce\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"createdAt\": \"2026-09-14T12:00:05.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\nThe list is newest first, in the page sizes on [Limits](sendora://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`.\n\nEach 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.\n\n## Lift a suppression\n\nLift 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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/suppressions/delete \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"address\": \"bounced@example.com\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nawait sendora.suppressions.delete({\n address: 'bounced@example.com',\n});\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nsendora.suppressions.delete(address=\"bounced@example.com\")\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$sendora->suppressions->delete(\n address: 'bounced@example.com',\n);\n```\n\nLifting 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.\n\n## Erasure requests\n\nWhen 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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/erasures \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"address\": \"anna@example.com\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst erasure = await account.erasures.create({\n address: 'anna@example.com',\n});\n\nconsole.log(erasure.erasureId, erasure.status);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nerasure = account.erasures.create(\n address=\"anna@example.com\"\n)\nprint(erasure.erasure_id, erasure.status)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$erasure = $account->erasures->create(\n address: 'anna@example.com',\n);\n\necho $erasure->erasureId, ' ', $erasure->status, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"erasureId\": \"6f1d2c3b-4a5e-4f60-8b7a-9c8d7e6f5a41\",\n \"status\": \"pending\",\n \"createdAt\": \"2026-10-04T12:00:00.000Z\",\n \"completedAt\": null,\n \"pseudonym\": null,\n \"messages\": null,\n \"receivedMessages\": null\n}\n```\n\nThe erasure returns `pending`, and Sendora carries it out within a minute. [Read the erasure](sendora://reference/get-v1-erasures-id) to see it `done`, with the pseudonym and how many messages lost their content.\n\nOn 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."},{"uri":"sendora://docs/smtp","title":"SMTP submission","description":"Send from a system that only speaks SMTP, with the same key and rules.","text":"# SMTP submission\n\nSend from a system that only speaks SMTP, with the same key and rules.\n\nUse 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.\n\nTo move a system that signs in to a Microsoft 365 mailbox with a password, follow [Move from Microsoft 365](sendora://docs/microsoft-365).\n\n## Connect\n\n| Setting | Value |\n| ---------------------- | ---------------------------------------------------------------------------------- |\n| Host | `smtp.sendora.se` |\n| Port | 465 with TLS from the start, or 587 with STARTTLS |\n| User name and password | A server key, `sk_…` or a [test server](sendora://docs/test-servers)'s `sk_test_…`, as both |\n| Authentication | `PLAIN` or `LOGIN`, once TLS is on |\n\nThe `From` address must be on one of the server's verified domains. The [message limits](sendora://docs/limits#messages) apply as they do over the API, with `To`, `Cc` and `Bcc` counted together.\n\nTypeScript:\n\n```ts\nimport nodemailer from 'nodemailer';\n\n/** Sends one message over SMTP with a server's API token as both user name and password. */\nexport async function sendOverSmtp(\n token: string,\n): Promise<string> {\n const transport = nodemailer.createTransport({\n host: 'smtp.sendora.se',\n port: 587,\n secure: false,\n requireTLS: true,\n auth: { user: token, pass: token },\n });\n const info = await transport.sendMail({\n from: '\"Example AB\" <no-reply@example.se>',\n to: 'anna@example.com',\n subject: 'Your account is ready',\n text: 'Hi Anna, your account at Example is ready.',\n headers: {\n 'X-Sendora-Tag': 'welcome',\n 'X-Sendora-Metadata-Customer': '4711',\n },\n });\n return info.response; // \"250 2.0.0 OK: queued as <messageId>\"\n}\n```\n\nPython:\n\n```python\nimport smtplib\nimport ssl\nfrom email.message import EmailMessage\n\n# Sends one message over SMTP with a server's API\n# token as both user name and password.\ndef send_over_smtp(token: str) -> None:\n message = EmailMessage()\n message[\"From\"] = \"Example AB <no-reply@example.se>\"\n message[\"To\"] = \"anna@example.com\"\n message[\"Subject\"] = \"Your account is ready\"\n message[\"X-Sendora-Tag\"] = \"welcome\"\n message[\"X-Sendora-Metadata-Customer\"] = \"4711\"\n message.set_content(\n \"Hi Anna, your account at Example is ready.\"\n )\n\n # Without a context of its own, starttls() would\n # accept any certificate, and without a timeout\n # smtplib waits forever on a stalled connection.\n context = ssl.create_default_context()\n with smtplib.SMTP(\n \"smtp.sendora.se\", 587, timeout=30\n ) as smtp:\n smtp.starttls(context=context)\n smtp.login(token, token)\n smtp.send_message(message)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Symfony\\Component\\Mailer\\Header\\MetadataHeader;\nuse Symfony\\Component\\Mailer\\Header\\TagHeader;\nuse Symfony\\Component\\Mailer\\MailerInterface;\nuse Symfony\\Component\\Mailer\\Transport;\nuse Symfony\\Component\\Mailer\\Transport\\TransportInterface;\nuse Symfony\\Component\\Mime\\Email;\n\n// Sendora's SMTP submission, with a server's API token\n// as both user name and password. smtps:// starts TLS\n// before the token goes over.\nfunction sendoraSmtp(string $token): TransportInterface\n{\n return Transport::fromDsn(\n \"smtps://{$token}:{$token}@smtp.sendora.se:465\",\n );\n}\n\n// Give it new Mailer(sendoraSmtp($token)), or the\n// MailerInterface that Symfony's framework injects.\nfunction sendWelcome(MailerInterface $mailer): void\n{\n $email = (new Email())\n ->from('Example AB <no-reply@example.se>')\n ->to('anna@example.com')\n ->subject('Your account is ready')\n ->text(\n 'Hi Anna, your account at Example is ready.',\n );\n $email->getHeaders()\n ->add(new TagHeader('welcome'))\n ->add(new MetadataHeader('Customer', '4711'));\n\n $mailer->send($email);\n}\n```\n\nSendora 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.\n\n## Send from Django\n\nDjango 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.\n\n```python\nimport os\n\n# In settings.py, for Django's SMTP backend, its\n# default: the server key as user name and password.\nEMAIL_HOST = \"smtp.sendora.se\"\nEMAIL_PORT = 587\nEMAIL_USE_TLS = True\nEMAIL_HOST_USER = os.environ[\"SENDORA_API_TOKEN\"]\nEMAIL_HOST_PASSWORD = os.environ[\"SENDORA_API_TOKEN\"]\nEMAIL_TIMEOUT = 30\nDEFAULT_FROM_EMAIL = \"Example AB <no-reply@example.se>\"\nSERVER_EMAIL = DEFAULT_FROM_EMAIL\n```\n\nDjango 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.\n\n## Send from Laravel or Symfony\n\nLaravel 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.\n\nIn Laravel, set the `smtp` mailer in `.env`:\n\n```bash\nMAIL_MAILER=smtp\nMAIL_SCHEME=smtps\nMAIL_HOST=smtp.sendora.se\nMAIL_PORT=465\nMAIL_USERNAME=\"${SENDORA_API_TOKEN}\"\nMAIL_PASSWORD=\"${SENDORA_API_TOKEN}\"\nMAIL_FROM_ADDRESS=no-reply@example.se\n```\n\nA 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.\n\nTo send through the API instead, with each message's id in hand, use the Laravel package, as [Send from Laravel](sendora://docs/sending#send-from-laravel) shows.\n\nIn Symfony, set `MAILER_DSN` in `.env`:\n\n```bash\nMAILER_DSN=smtps://${SENDORA_API_TOKEN}:${SENDORA_API_TOKEN}@smtp.sendora.se:465\n```\n\nAdd the tag with Symfony's `TagHeader` and each metadata entry with its `MetadataHeader`.\n\n## Set the stream, tag and metadata\n\n| Header | Sets |\n| -------------------------- | ------------------------------------------------------------------------------------- |\n| `X-Sendora-Stream` | The [stream](sendora://docs/streams) the message goes on, by its id |\n| `X-Sendora-Tag` | The message's tag |\n| `X-Sendora-Metadata-<key>` | One metadata entry: `X-Sendora-Metadata-Customer: 4711` sets `{ \"customer\": \"4711\" }` |\n\nWithout `X-Sendora-Stream`, the message goes on the server's default transactional stream.\n\nSendora also reads `X-Tag` and `X-Metadata-<key>`, which Laravel's and Symfony's mailers write for a message's tags and metadata. `X-Sendora-Tag` and `X-Sendora-Metadata-<key>` 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.\n\nAttachments 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 tag and metadata headers, 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.\n\n## SMTP replies\n\nSendora 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.\n\n| Reply | When |\n| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `250 2.0.0 OK: queued as <messageId>` | Accepted. The id is the one the log and the webhooks use. |\n| `530 5.7.0` | The client sent a message without authenticating. |\n| `535 5.7.8` | The key is wrong, revoked or an [account key](sendora://docs/keys), or the user name and password differ. |\n| `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](sendora://docs/errors#when-an-account-cannot-send) describes. It also covers a stream in `X-Sendora-Stream` that is unknown, archived, paused or inbound. |\n| `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. |\n| `550 5.6.0` | The API would refuse the message as invalid, or it breaks the [rules of a broadcast stream](sendora://docs/broadcasts#rules-of-a-broadcast-stream). The text gives the reasons. |\n| `552 5.3.4` | The message is over the [size limit](sendora://docs/limits#messages). |\n| `452 4.5.3` | The message has more recipients than [a message may have](sendora://docs/limits#messages). Split them over several messages. |\n| `451 4.7.0` | The per-minute limit or the monthly cap is reached. The text says when to try again. |\n| `451 4.3.2` | Sending is disabled for everyone for the moment. |\n| `451 4.3.0` | A temporary failure on Sendora's side. |"},{"uri":"sendora://docs/errors","title":"Errors","description":"The error body, every error code, and what stops an account from sending.","text":"# Errors\n\nThe error body, every error code, and what stops an account from sending.\n\nEvery 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:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [{ \"path\": \"to.0\", \"message\": \"Invalid email address\" }]\n}\n```\n\nSome errors add fields, such as the suppressed recipients or when a limit resets. Each operation's page in the [reference](sendora://index) lists them.\n\nTwo 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](sendora://docs/limits#requests-per-second) explains. A body over the size limit meets the proxy first, so it gets the plain-text 413.\n\n## Retry or fix\n\n- `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.\n- `sending_disabled` (503): sending is disabled for everyone for the moment. Retry after the seconds in the `Retry-After` header.\n- `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](sendora://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.\n- A 5xx status: retry with a growing delay, under the same idempotency key so the message cannot go twice.\n- 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.\n\nOn `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](sendora://docs/webhooks) can warn you before the cap is used up.\n\nWith the TypeScript SDK:\n\nThe 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.\n\nThe 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.\n\nWith the Python SDK:\n\nThe 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.\n\nThe 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.\n\nWith the PHP SDK:\n\nThe 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.\n\nThe 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.\n\n## When an account cannot send\n\n- `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](sendora://docs/test-servers) works meanwhile.\n- `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](sendora://docs/test-servers) works before that.\n- `account_paused` (403): Sendora has paused the account. Support tells you why.\n\nOver SMTP, the same refusals arrive as SMTP replies, which [SMTP submission](sendora://docs/smtp#smtp-replies) lists.\n\n## Error codes\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled already; nothing is left to cancel. |\n| `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |\n| `default_stream` | 409 | The default stream of a server cannot be archived. |\n| `domain_exists` | 409 | The account already has that domain. |\n| `domain_reserved` | 400 | The name is Sendora’s own, or a return-path host; no account can receive on it. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |\n| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |\n| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |\n| `inbound_domain_exists` | 409 | The stream already has a domain, or another account holds the name verified. |\n| `inbound_stream_exists` | 409 | A server has one inbound stream at a time; archive it to make another. |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted; create another first. |\n| `last_token` | 409 | This is the only live key of the server; create another before revoking it. |\n| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |\n| `mode_immutable` | 400 | A server’s mode is fixed when it is created; create a new server for the other mode. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `not_found` | 404 | No such broadcast of this server. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `rate_limited` | 429 | More was sent within a minute than the limit allows. |\n| `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. |\n| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `secret_limit` | 409 | The webhook already holds two live secrets: one in use and one to roll to. Delete one before creating another. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; try again once they have left. |\n| `server_limit` | 409 | The account has as many servers as it may; Sendora raises the cap on request. |\n| `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. |\n| `spam_complaint_locked` | 403 | The recipient reported a message as spam; only Sendora support lifts that, at the recipient’s own request. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_exists` | 409 | The server already has a stream with this name, archived or not. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; the streamId names a transactional one. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |\n| `substitution_missing` | 422 | The subject, text or HTML names a {{ key }} that a message does not give. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `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. |\n| `tracking_domain_exists` | 409 | The account already has that host, or a host on that sending domain. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may have. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. A tracking domain is a subdomain of one. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of this account. |\n| `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `unsubscribe_locked` | 403 | The recipient unsubscribed themselves; only Sendora support lifts that, at the recipient’s own request. |\n| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL. |\n| `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. |"},{"uri":"sendora://docs/limits","title":"Limits","description":"Every limit an account meets, and its number.","text":"# Limits\n\nEvery limit an account meets, and its number.\n\nEvery limit an account meets, with its number. When a send meets one, [Errors](sendora://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.\n\n## Sending rates and the monthly cap\n\nEmails are counted one per recipient, across `to`, `cc` and `bcc`.\n\nAn 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.\n\n| Limit | Value |\n| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |\n| Per minute, per account | 300 by default |\n| 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 |\n| Highest monthly cap | 10 times the cap approval set, unless Sendora sets another |\n| Warning | A `cap_warning` webhook at 80 percent of the cap, once a month |\n| After a cancellation | The 10,000 emails that the monthly fee includes, sent and received together |\n\n- 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.\n- 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.\n- 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](sendora://docs/limits#the-cap-on-received-mail). No `inbound_cap_reached` webhook goes.\n- An account approved without an application starts at the monthly cap of the smallest volume.\n- Sendora can give a server a per-minute limit and a monthly cap of its own, only ever tighter than the account's.\n- A broadcast counts in full against the monthly cap as soon as Sendora accepts it. The per-minute limit does not apply to broadcasts.\n\n## Test servers\n\n| Limit | Value |\n| ------------------------------------------ | ----------------- |\n| Per minute, all the account's test servers | 300 by default |\n| Per month, all the account's test servers | 10,000 by default |\n\nTest 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`.\n\n## Account\n\n| Object | Limit |\n| ------------------------------- | ------------------------------------------ |\n| Servers | 10 by default, more on request |\n| Keys | 2 live per server, and 2 live account keys |\n| Webhook secrets | 2 live per webhook |\n| Inbound streams | One live per server |\n| Tracking domains | 10 per account, one per sending domain |\n| A tracking domain's first check | Its records must be right within 7 days |\n| Names of servers, streams, keys | Up to 100 characters |\n\nSending domains, webhooks and transactional and broadcast streams have no limit of their own.\n\n## Requests\n\n| Limit | Value |\n| ----------------- | ------------------------------------------------------- |\n| Request body | 10 MB, and 64 MB for `POST /v1/broadcasts` |\n| `Idempotency-Key` | Up to 255 printable characters, remembered for 24 hours |\n| Batch | Up to 100 messages |\n\n## Messages\n\n| Limit | Value |\n| --------------------- | --------------------------------------------------------------- |\n| Recipients | 50, across `to`, `cc` and `bcc` |\n| Size | 10 MB as built, with attachments counted after base64 encoding |\n| Subject | 998 characters |\n| Attachments | 20 |\n| Custom headers | 20 |\n| Metadata | 20 entries, keys of up to 40 characters, values of up to 500 |\n| Tag | Up to 100 characters |\n| Opens and clicks kept | 100 of each per recipient; `opens` and `clicks` go on counting |\n| Opens and clicks read | 500 per message; each recipient's first and earliest come first |\n\n## Broadcasts\n\n| Limit | Value |\n| ------------- | ------------------------------------------------------------------------------------------------------------------------ |\n| Messages | 50,000 per broadcast |\n| Size | 50 MB for the whole broadcast as built, and 10 MB for each message in it |\n| Substitutions | 20 per message, each up to 500 characters |\n| Pace | 600 messages a minute per broadcast stream, unless Sendora sets another |\n| 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 |\n\n## SMTP submission\n\n| Limit | Value |\n| ----------- | ---------------------------------------- |\n| Connections | 200 at once on each listener |\n| Idle | A connection is closed after 60 seconds |\n| Message | 10 MB and 50 recipients, as over the API |\n\n## Inbound mail\n\n| Limit | Value |\n| ------------ | ------------------------------------------------------------------------------------------- |\n| Size | 35 MB per message |\n| Rate | 300 recipients a minute per account, unless Sendora sets another |\n| Recipients | 50 per delivery from a sending server |\n| Connections | 300 a minute from one sending server |\n| Content kept | The stream's `contentRetentionDays`, 1 to 30 days, 30 by default |\n| Addresses | `to` and `cc` hold up to 100 entries each, with the whole number in `toCount` and `ccCount` |\n| Domain grace | Mail to a verified domain whose records went missing is deferred for 72 hours, then refused |\n\nIf 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:\n\n| Parser limit | Value |\n| --------------- | ------------------------------------------- |\n| Header block | 256 KB and 1,000 lines |\n| Parts | 500 MIME parts, nested up to 50 deep |\n| Text and HTML | 1 MB of text and 2 MB of HTML once decoded |\n| Decoded content | 4 times the size of the message as received |\n| Time | 20 seconds to parse |\n| Attachments | The first 100 are kept |\n| Header values | The first 2,000 characters are kept |\n| Address lists | The first 1,000 addresses are kept |\n\nIf 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.\n\n## The cap on received mail\n\n| Limit | Value |\n| ----------- | ---------------------------------------------------------------------------------------------------- |\n| Monthly cap | The account's monthly cap on sending, until an administrator moves it, up to the highest monthly cap |\n| Warning | An `inbound_cap_warning` webhook at 80 percent of the cap, once a month |\n\n- Received mail counts toward this cap alone. It never counts toward the monthly cap on sending, and sent mail never counts toward this one.\n- The cap counts the same month as sending does.\n- An administrator moves the cap under [Account › Usage](https://app.sendora.se/account/usage). Until then, it follows the monthly cap on sending.\n- 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.\n\n## Retention\n\n| What | Kept for |\n| -------------------------------------------------------------------------- | ------------------------------------------------------------- |\n| A message's text and HTML | 30 days |\n| Its attachments' content | Until Sendora's mail server has the message, or it has failed |\n| The message, its recipients and events, and a received message's reference | 13 months |\n| On a test server | 7 days for the text and HTML, 30 days for the rest |\n\n## Webhooks\n\n| Limit | Value |\n| ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |\n| Timeout | 10 seconds for an answer; a redirect is not followed and counts as a failure |\n| 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 |\n\n## Page sizes\n\n| Lists | Page size |\n| --------------------------------------- | --------------------------- |\n| Messages, received messages, broadcasts | Up to 100, 50 by default |\n| Suppressions, webhook deliveries | Up to 1,000, 100 by default |\n\n## Sign-in\n\n| Limit | Per 15 minutes |\n| ------------- | ----------------------------- |\n| Sign-in tries | 10 per address, 30 per client |\n| Mailed links | 3 per address, 10 per client |\n| Invitations | 3 per address, 20 per client |\n| SSO sign-ins | 30 per client |\n\n## SSO and SCIM\n\n| Limit | Value |\n| --------------- | ----------------------------- |\n| SSO connections | 5 per account |\n| SSO domains | 20 per account |\n| Sign-in tests | 20 per account per 15 minutes |\n| Links to IT | 5 per account per 15 minutes |\n| SCIM tokens | 2 live per account |\n| SCIM requests | 1,200 per token in 1 minute |\n| SCIM lists | Up to 100 users a page |\n\n## Requests per second\n\nBefore 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`."},{"uri":"sendora://docs/mcp","title":"MCP server","description":"Connect claude.ai, ChatGPT, Claude Code, Cursor, VS Code, Codex or Claude Desktop to Sendora's docs and tools.","text":"# MCP server\n\nConnect claude.ai, ChatGPT, Claude Code, Cursor, VS Code, Codex or Claude Desktop to Sendora's docs and tools.\n\nSendora'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.\n\nIt runs two ways, with the same tools:\n\n- Locally, as `npx -y @sendora/mcp`, over stdio. The documentation is built into the package, so it answers offline.\n- 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.\n\n## Before you start\n\n- The local server needs Node 20.19 or newer. The hosted server and the Claude Desktop bundle need nothing installed.\n- 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.\n- 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.\n\n## Connect with OAuth\n\nWith 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.\n\nUse 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).\n\nOpen your app's steps:\n\n### claude.ai and Claude Desktop\n\nOn 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.\n\n1. Open **Customize › Connectors** and select **Add custom connector**.\n2. Name it Sendora and enter `https://mcp.sendora.se/mcp` as the server URL. Leave the other settings as they are, and select **Add**.\n3. Select **Connect** beside Sendora. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n4. In a chat, select **+**, then **Connectors**, and turn Sendora on.\n\nThe connector also works in Claude's mobile apps.\n\n### ChatGPT\n\n1. In ChatGPT on the web, open **Plugins**, select **Add**, then **Add custom MCP server**.\n2. Name it Sendora. Under **Connection**, enter `https://mcp.sendora.se/mcp`.\n3. Choose OAuth as the authentication, with no client ID or secret. Select **I understand and want to continue**, then **Create as a plugin**.\n4. When ChatGPT asks, select **Continue to Sendora**. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n5. In a chat, type `@` and choose Sendora.\n\n### Claude Code\n\n1. 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.\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.sendora.se/mcp\",\n \"timeout\": 90000\n }\n }\n}\n```\n\n2. In Claude Code, run `/mcp`, select sendora and authenticate. Claude Code opens your browser.\n3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n\n### VS Code\n\n1. Add Sendora to `.vscode/mcp.json` in your project, or to the file that **MCP: Open User Configuration** opens:\n\n```json\n{\n \"servers\": {\n \"sendora\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.sendora.se/mcp\"\n }\n }\n}\n```\n\n2. 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.\n3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n\n### Cursor\n\n1. Add Sendora to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in your project:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"url\": \"https://mcp.sendora.se/mcp\"\n }\n }\n}\n```\n\n2. Open **Customize** in Cursor's sidebar, and connect Sendora's MCP server there. Cursor opens your browser.\n3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n\n### Codex\n\n1. Add Sendora to `~/.codex/config.toml`. The timeout lets `send_email` wait its full minute.\n\n```toml\n[mcp_servers.sendora]\nurl = \"https://mcp.sendora.se/mcp\"\ntool_timeout_sec = 90\n```\n\n2. Run `codex mcp login sendora`. Codex opens your browser.\n3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n\n### Approve the app\n\nSendora'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.\n\nNothing is ticked when the page opens. Tick at least one server or the account:\n\n- The page lists each server you may open yourself, marked live or test. Tick those the app may reach.\n- 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.\n- 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.\n\nThen approve, and your browser returns to the app. If you cancel instead, the app is told that you refused.\n\nOn 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.\n\nThe 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.\n\n### End a connection\n\nYour 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.\n\nA 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.\n\nAn 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.\n\n### If the app cannot connect\n\n| What you see | Why | What to do |\n| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |\n| 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 |\n| 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 |\n\n## Choose a key\n\nThe MCP server lists only the tools your key allows, so the agent cannot call any other:\n\n- 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](sendora://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 and complaints.\n- 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.\n- 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.\n- With no key, the agent reads the documentation alone.\n\n| What the tool does | No key | Test key | Live key | Account key |\n| --- | --- | --- | --- | --- |\n| Reads the documentation | yes | yes | yes | yes |\n| Reads | no | yes | yes | yes |\n| Changes | no | yes | with changes on | with changes on |\n| Shows a new key or signing secret | no | yes | no | no |\n| Cannot be undone | no | yes | no | with changes on |\n\n**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](sendora://docs/keys) says where each key is made.\n\n## Install\n\nSet 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.\n\nOpen your client's setup:\n\n### Claude Code\n\nAdd 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:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@sendora/mcp\"],\n \"env\": { \"SENDORA_API_TOKEN\": \"${SENDORA_API_TOKEN}\" }\n }\n }\n}\n```\n\nOr hosted, with a timeout that lets `send_email` wait up to a minute to learn whether the message was delivered:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.sendora.se/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${SENDORA_API_TOKEN}\" },\n \"timeout\": 90000\n }\n }\n}\n```\n\nDon'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:\n\n```bash\nclaude mcp add --transport http sendora-docs https://mcp.sendora.se/docs\n```\n\n### Cursor\n\n[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`.\n\nBy 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:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@sendora/mcp\"],\n \"env\": { \"SENDORA_API_TOKEN\": \"${env:SENDORA_API_TOKEN}\" }\n }\n }\n}\n```\n\nOr hosted:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"url\": \"https://mcp.sendora.se/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${env:SENDORA_API_TOKEN}\" }\n }\n }\n}\n```\n\n### VS Code\n\n[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.\n\nBy 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:\n\n```json\n{\n \"inputs\": [\n {\n \"type\": \"promptString\",\n \"id\": \"sendora-key\",\n \"description\": \"Sendora key\",\n \"password\": true\n }\n ],\n \"servers\": {\n \"sendora\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@sendora/mcp\"],\n \"env\": { \"SENDORA_API_TOKEN\": \"${input:sendora-key}\" }\n }\n }\n}\n```\n\nOr hosted:\n\n```json\n{\n \"inputs\": [\n {\n \"type\": \"promptString\",\n \"id\": \"sendora-key\",\n \"description\": \"Sendora key\",\n \"password\": true\n }\n ],\n \"servers\": {\n \"sendora\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.sendora.se/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${input:sendora-key}\" }\n }\n }\n}\n```\n\n### Codex\n\nEdit `~/.codex/config.toml`. Don't use `codex mcp add --env`, which writes the key's value into the file.\n\nCodex 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:\n\n```toml\n[mcp_servers.sendora]\ncommand = \"npx\"\nargs = [\"-y\", \"@sendora/mcp\"]\nenv_vars = [\"SENDORA_API_TOKEN\"]\ntool_timeout_sec = 90\n```\n\nOr hosted:\n\n```toml\n[mcp_servers.sendora]\nurl = \"https://mcp.sendora.se/mcp\"\nbearer_token_env_var = \"SENDORA_API_TOKEN\"\ntool_timeout_sec = 90\n```\n\n### Claude Desktop\n\n[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.\n\n1. Paste your key into **Sendora key**, which Claude Desktop keeps in your operating system's secure storage. Leave it empty for the documentation alone.\n2. Tick **Allow changes** only when you want changes on.\n\nEach 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.\n\n## Check the connection\n\nAsk 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`.\n\n| What you see | Why | What to do |\n| ----------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| 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 |\n| 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 |\n\nThen give the agent a task:\n\n- With a test server's key, ask: \"Send a test mail to any address you like, and tell me whether it was delivered.\"\n- With a live server's key, ask: \"Why did the last message to `anna@example.com` bounce?\"\n- With no key, ask: \"Add a password-reset mail to this app with `@sendora/sdk`.\"\n\n## Turn on changes\n\nA live server's key and the account key list only the reading tools until you turn on changes for the connection:\n\n- Locally, add `--allow-writes` after `@sendora/mcp` in the arguments, or set `SENDORA_ALLOW_WRITES=true` in the server's environment.\n- Hosted, send the header `Sendora-Allow-Writes: true` beside the key.\n- In Claude Desktop, tick **Allow changes**.\n- Through OAuth, tick the box for changes on live servers when you approve the app. The account stays read only.\n\nAny value but `true` leaves changes off. A test server's key needs no switch.\n\nWith 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.\n\nThe 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.\n\n## Received mail\n\nA 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:\n\n- `search_inbound` returns only two of the addresses the sender chose: the From address and the address the mail was sent to.\n- `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.\n\nThis 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.\n\n- Read what the agent proposes before you approve it.\n- Keep changes off unless the task needs them.\n- Give an agent that reads received mail no more tools than the task needs.\n- If you think a key reached someone it should not have, revoke it in the dashboard.\n\n## Tools\n\nThe 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.\n\n| Tool | What it does | Keys |\n| --- | --- | --- |\n| `search_docs` | Search the Sendora docs | any, or none |\n| `read_doc` | Read a Sendora document | any, or none |\n| `get_server` | Read the server this key belongs to | test, live |\n| `send_email` | Send one email | test, live with changes on |\n| `search_messages` | Search the message log | test, live |\n| `get_message` | Read one message and its timeline | test, live |\n| `list_suppressions` | List a stream's suppression list | test, live |\n| `remove_suppression` | Lift a suppression | test, live with changes on |\n| `create_stream` | Add a stream | test, live with changes on |\n| `archive_stream` | Archive a stream | test |\n| `send_broadcast` | Send a test broadcast | test |\n| `get_broadcast` | Read a broadcast | test, live |\n| `cancel_broadcast` | Cancel a broadcast | test |\n| `list_webhooks` | List the server's webhooks | test, live |\n| `list_webhook_deliveries` | List a webhook's deliveries | test, live |\n| `replay_webhook_delivery` | Replay a webhook delivery | test, live with changes on |\n| `create_webhook` | Add a webhook | test |\n| `delete_webhook` | Delete a webhook | test |\n| `list_keys` | List the server's keys | test, live |\n| `create_key` | Create a key | test |\n| `revoke_key` | Revoke a key | test |\n| `search_inbound` | Search received mail | live |\n| `read_inbound_message` | Read a received message | live |\n| `list_inbound_domains` | List the domains for receiving | live |\n| `check_inbound_domain` | Check a domain for receiving | live |\n| `add_inbound_domain` | Add a domain for receiving | live with changes on |\n| `list_servers` | List the account's servers | account |\n| `list_domains` | List the sending domains | account |\n| `check_domain` | Check a sending domain | account |\n| `add_domain` | Add a sending domain | account with changes on |\n| `list_tracking_domains` | List the tracking domains | account |\n| `check_tracking_domain` | Check a tracking domain | account |\n| `add_tracking_domain` | Add a tracking domain | account with changes on |\n| `delete_test_server` | Delete a test server | account with changes on |\n\n## Screens\n\nIn 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:\n\n- `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.\n- `add_domain` and `check_domain` show the domain's two DNS records, whether each checks out, and a button that checks them again.\n\nEvery other client gets the same answer as text. A screen loads nothing from anywhere else.\n\n## Documents and prompts\n\nThe documentation is built into the MCP server at each release, from the same sources as these pages:\n\n- the guides, as `sendora://docs/…`\n- the API reference, as `sendora://reference/…`\n- the error codes, as `sendora://errors`\n- the TypeScript SDK's README and rules for agents, as `sendora://sdk/readme` and `sendora://sdk/skill`\n- the Python SDK's, as `sendora://sdk/python/readme` and `sendora://sdk/python/skill`\n- the PHP SDK's, as `sendora://sdk/php/readme` and `sendora://sdk/php/skill`\n- the Laravel package's README, as `sendora://sdk/laravel/readme`\n\n`sendora://index` lists them all. `search_docs` searches every document but the rules, which an agent reads whole. `read_doc` reads one document whole.\n\nThe server has three prompts, which a client offers as commands:\n\n- `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.\n- `why_not_delivered`: read a message's timeline, the suppression list and the error codes, and explain what happened.\n- `verify_webhooks`: implement signature verification for the webhook endpoint and test it.\n\n## The official server\n\nSendora'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."},{"uri":"sendora://docs/changelog","title":"Changelog","description":"What each release of the SDKs changed, and what to change in your code when you upgrade.","text":"# Changelog\n\nWhat each release of the SDKs changed, and what to change in your code when you upgrade.\n\nWhat 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.\n\n## TypeScript SDK\n\nThe package is `@sendora/sdk` on npm.\n\n### 5.2.0\n\nEach 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](sendora://docs/messages#opens-and-clicks). A `switch` over `MessageEventType` needs the two new values. No call changes.\n\n### 5.1.0\n\n`account.trackingDomains` adds, lists, reads, checks and removes [tracking domains](sendora://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.\n\n### 5.0.2\n\nThe 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.\n\n### 5.0.1\n\nThe 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.\n\n### 5.0.0\n\nThe 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:\n\n- `SendoraErrorCode` and a batch item's `error` no longer include `unpaid_limit_reached`.\n- `SendoraError`, its `toJSON()` and a batch item's result no longer have `unpaidOre`, `limitOre` or `priceOre`.\n\nA 402 from a send, a batch or a broadcast is now always `payment_required`, which is not `retryable`. No call changes.\n\n### Upgrade from 4.x\n\n1. Install the new version with `npm install @sendora/sdk@5`.\n2. Remove each comparison with `unpaid_limit_reached` and each read of `unpaidOre`, `limitOre` or `priceOre`. TypeScript reports every one.\n\n### 4.4.1\n\nA 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.\n\n### 4.4.0\n\n`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.\n\n### 4.3.0\n\nA 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.\n\n### 4.2.0\n\nThe 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](sendora://docs/limits#the-cap-on-received-mail) says what the cap does.\n\n### 4.1.1\n\nThe 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.\n\n### 4.1.0\n\nThree 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.\n\n### 4.0.0\n\nTwo changes break code written for 3.x:\n\n- Values that said tenant now say account. The [error codes](sendora://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.\n- `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](sendora://docs/webhooks#verify-the-signature) shows the verifier.\n\n### Upgrade from 3.x\n\n1. Install the new version with `npm install @sendora/sdk@4`.\n2. Where you compare `error.code` with `tenant_paused` or `tenant_not_active`, compare it with `account_paused` or `account_not_active` instead.\n3. Where you compare a `scope` with `tenant`, compare it with `account`.\n4. If your code checks that a `switch` over `event.event` handles every event, add a case for `unknown`.\n\n## Python SDK\n\nThe package is `sendora` on PyPI.\n\n### 2.2.0\n\nEach `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](sendora://docs/messages#opens-and-clicks). No call changes.\n\n### 2.1.0\n\n`account.tracking_domains` adds, lists, reads, checks and removes [tracking domains](sendora://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.\n\n### 2.0.2\n\nThe 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.\n\n### 2.0.1\n\nThe 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.\n\n### 2.0.0\n\nThe 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:\n\n- `SendoraErrorCode` and a batch item's `code` no longer include `unpaid_limit_reached`.\n- `SendoraError` and `RefusedBatchItem` no longer have `unpaid_ore`, `limit_ore` or `price_ore`, so reading one raises `AttributeError`.\n\nA 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.\n\n### Upgrade from 1.x\n\n1. Install the new version with `pip install \"sendora~=2.0\"`.\n2. Remove each comparison with `unpaid_limit_reached` and each read of `unpaid_ore`, `limit_ore` or `price_ore`.\n\n### 1.4.1\n\nA 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.\n\n### 1.4.0\n\n`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.\n\n### 1.3.0\n\nA 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.\n\n### 1.2.0\n\nThe 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](sendora://docs/limits#the-cap-on-received-mail) says what the cap does.\n\n### 1.1.1\n\nThe 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.\n\n### 1.1.0\n\nThree 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.\n\n### 1.0.0\n\nThe 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.\n\n### Upgrade from 0.x\n\n1. Install the new version with `pip install \"sendora~=1.0\"`.\n2. 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`.\n3. If you wrote for a version before 0.9.0, compare a `scope` with `account` where you compared it with `tenant`.\n\n## PHP SDK\n\nThe package is `sendora/sendora-php` on Packagist.\n\n### 1.2.0\n\nEach `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](sendora://docs/messages#opens-and-clicks). No call changes.\n\n### 1.1.0\n\n`$account->trackingDomains` adds, lists, reads, checks and removes [tracking domains](sendora://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.\n\n### 1.0.3\n\nThe 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.\n\n### 1.0.2\n\nThe 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.\n\n### 1.0.1\n\nThe 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.\n\n### 1.0.0\n\nThe 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.\n\n### Upgrade from 0.x\n\n1. Install the new version with `composer require sendora/sendora-php:^1.0`.\n2. 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.\n\n## Laravel package\n\nThe package is `sendora/sendora-laravel` on Packagist, and it installs the PHP SDK.\n\n### 1.1.0\n\nThe container holds `TrackingDomains`, the account client's resource for [tracking domains](sendora://docs/domains#tracking-domains). The package requires `sendora/sendora-php` 1.1.0 or a later 1.x release.\n\n### 1.0.0\n\nThe 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.\n\nTo 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."},{"uri":"sendora://docs/sso","title":"SSO and SCIM","description":"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.","text":"# SSO and SCIM\n\nSign 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.\n\nYour 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.\n\nThis 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.\n\n## Before you start\n\n- 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.\n- You need administrator rights in your identity provider.\n- You need to publish a DNS record at your organisation's email domain.\n\n## How the setup goes\n\n1. Prove that the account controls your email domain, with one DNS record.\n2. 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.\n3. Test the sign-in. The test signs nobody in to Sendora.\n4. An administrator switches the connection on. The sign-in screen then shows a button under the address field for each address at your domain.\n5. If you want, require SSO, and connect SCIM.\n\nAn account can hold several connections, for example one per provider, up to the count in [Limits](sendora://docs/limits#sso-and-scim). Each covers the domains you attach to it.\n\n## Prove your domain\n\nAdd the domain under [Account › Sign-in](https://app.sendora.se/account/sso). Sendora shows one TXT record, such as:\n\n```\n_sendora-sso.example.com TXT \"sendora-sso=8f14e45fceea167a5a36dedd4bea2543\"\n```\n\nPublish 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.\n\n- A domain covers its exact name only. `mail.example.com` is a domain of its own.\n- Only one Sendora account can verify a domain.\n- A verified sending domain does not count. Sending as a domain is not the same as signing its people in.\n\n## Connect by SAML\n\nEach connection has its own values for your provider. The connection's page shows each one to copy.\n\n| Value | Is |\n| ------------------------------------ | --------------------------------------------------------------- |\n| Entity ID | `https://app.sendora.se/dashboard/sso/saml/<connection id>` |\n| ACS URL (Assertion Consumer Service) | `https://app.sendora.se/dashboard/sso/saml/<connection id>/acs` |\n| Metadata URL | The entity ID, which also serves Sendora's metadata |\n| Sign-on URL | `https://app.sendora.se/login?sso_domain=<your domain>` |\n\nSendora 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.\n\nGive Sendora your provider's metadata, as a URL or as a file.\n\n- 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.\n- 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.\n\nYour 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.\n\n- 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.\n- It takes the name from `http://schemas.microsoft.com/identity/claims/displayname`, `displayName` or `urn:oid:2.16.840.1.113730.3.1.241`.\n- 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`.\n- Without any name, the sign-in still works, and the user has no name in Sendora.\n- 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.\n\n## Connect by OpenID Connect\n\nCreate a web app with the authorization code flow in your provider, with these values.\n\n| Value | Is |\n| ------------ | -------------------------------------------------------------------- |\n| Redirect URI | `https://app.sendora.se/dashboard/sso/oidc/<connection id>/callback` |\n| Scopes | `openid email profile` |\n\nThen give Sendora:\n\n- the issuer's URL, whose `/.well-known/openid-configuration` Sendora must reach from the internet;\n- the client ID and the client secret, sent as HTTP Basic (`client_secret_basic`) or in the form (`client_secret_post`);\n- the date the secret expires, if it does. Sendora mails the administrators 30 and 7 days before.\n\nSendora 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.\n\n- `sub` is who the person is to Sendora.\n- `email` is the address. Sendora refuses one whose `email_verified` is false.\n- The name is `name`, or else `given_name` and `family_name`. Without one, the sign-in still works.\n\n## Each provider's steps\n\nThe 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.\n\n### Microsoft Entra ID\n\n1. 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**.\n2. Name the app Sendora. Choose **Integrate any other application you don't find in the gallery (Non-gallery)**, then **Create**.\n3. 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**.\n4. 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.\n5. 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.\n6. Back under **Single sign-on**, copy **App Federation Metadata Url** from **SAML Certificates**, and give it to Sendora.\n\nThen test the sign-in in Sendora. Entra's own **Test** button works only once the connection is on.\n\n### Google Workspace\n\n1. 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**.\n2. On **Google Identity Provider details**, choose **Download Metadata** under **Option 1**, and keep the file for Sendora. Choose **Continue**.\n3. 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**.\n4. Under **Attribute mapping**, map **Basic Information > First Name** to `firstName` and **Last Name** to `lastName`. Choose **Finish**.\n5. 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**.\n6. Give Sendora the metadata file.\n\nThe 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.\n\n### Okta\n\n1. In the Okta Admin Console, go to **Applications and Resources** and **Applications**. Choose **Create App Integration**.\n2. Choose **SAML 2.0** as the **Sign-in method**, then **Next**. Name the app Sendora and choose **Next**.\n3. 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.\n4. Choose **EmailAddress** as the **Name ID format** and **Email** as the **Application username**. Leave **Show Advanced Settings** as it is, and choose **Next**.\n5. Under **Feedback**, tick **This is an internal app that we have created**, then choose **Finish**.\n6. 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`.\n7. Under **Assignments**, assign the people or groups who sign in to Sendora.\n8. Back on the **Sign On** tab, copy **Metadata URL** from **Metadata details**, and give it to Sendora.\n\n### AD FS\n\n1. In **AD FS Management**, choose **Relying Party Trusts**, then **Add Relying Party Trust** under **Actions**. Choose **Claims aware** and **Start**.\n2. 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.\n3. 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.\n4. 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**.\n5. Add a rule from the template **Transform an Incoming Claim** that turns **E-Mail Address** into **Name ID**, with the format **Email**.\n6. 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.\n7. Give Sendora your federation service's metadata, at `https://<your federation service>/FederationMetadata/2007-06/FederationMetadata.xml`. If Sendora cannot reach it from the internet, save it from a browser and give Sendora the file.\n\n### Keycloak\n\n1. In the Keycloak admin console, choose your realm. Go to **Clients** and choose **Create client**.\n2. Leave **Client type** as **OpenID Connect**, enter `sendora` as the **Client ID** and choose **Next**.\n3. Turn on **Client authentication**, leave **Standard flow** ticked and choose **Next**.\n4. 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**.\n5. Under the **Credentials** tab, copy **Client Secret**.\n6. 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.\n\nSendora 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.\n\n## Test and switch on\n\nTest 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.\n\n| A failed test says | Check |\n| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |\n| `provider_error` | Your provider stopped the sign-in. Check that the person may use the app. |\n| `discovery_failed` | Sendora could not read the issuer's configuration. Check the issuer's URL. |\n| `token_exchange_failed` | Your provider refused the client ID or the secret. |\n| `id_token_invalid` | The ID token failed Sendora's checks. Check the issuer's URL and the client ID. |\n| `userinfo_failed` | Sendora could not read the userinfo endpoint. Try again shortly. |\n| `email_missing` | Your provider sent no address. Let the app read the person's email. |\n| `email_unverified` | Your provider says the address is not verified. |\n| `response_invalid` | Sendora could not accept the response. Check that your provider uses Sendora's metadata. |\n| `signature_invalid` | The signature does not match the connection's certificate. Give the connection your provider's metadata again, and sign with SHA-256. |\n| `response_expired` | The response was not valid at this moment. Check your provider's clock. |\n| `audience_mismatch` | The response was meant for another service. Check Sendora's entity ID and ACS URL in your provider. |\n| `issuer_mismatch` | The response came from another provider. Give the connection your provider's metadata again. |\n\nAn administrator switches the connection on, once three things hold:\n\n- the connection has a verified domain;\n- a test has passed since its settings last changed;\n- Sendora has approved the account.\n\nThrough 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.\n\n## Sign in\n\nA person types their address on the sign-in screen. At a domain of a connection that is on, a button under the field starts the sign-in at your provider. Only an address at one of the connection's verified domains signs in through it.\n\nA 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.\n\nThe first time, Sendora finds the person's user:\n\n- A user whom SCIM created, or whom this connection created, is linked at once.\n- 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.\n- A person with no user gets one as a member with no servers, if the connection creates users. Otherwise an administrator invites them first.\n\nA sign-in through SSO lasts at most 12 hours. A person whose address is in another Sendora account cannot sign in here.\n\n## Require SSO\n\nAn administrator can require SSO on a connection that is on. It covers the people at its domains and those linked to it.\n\n- A password fails for everyone it covers.\n- A member signs in only through your provider. A request for a mailed link gets the SSO button instead.\n- 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.\n- Switching the requirement on ends these people's other sessions and their connected apps, such as an AI assistant.\n- People at other domains, keys, SMTP and webhooks are unaffected.\n\nIf no administrator can sign in, [Sendora's support](https://sendora.se/contact) can lift the requirement.\n\n## SCIM\n\nWith 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.\n\n### Connect your provider\n\nAn 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.\n\n| Setting | Value |\n| -------- | ------------------------------------------- |\n| Base URL | `https://api.sendora.se/scim/v2` |\n| Token | The SCIM token, `scim_…`, as a bearer token |\n\nEvery request carries `Authorization: Bearer scim_…`. Answers are `application/scim+json`, and errors use the error schema of RFC 7644.\n\nIn 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.\n\n1. In the enterprise app, open **Provisioning** and set the mode to **Automatic**.\n2. Enter the base URL as **Tenant URL** and the token as **Secret Token**. Choose **Test Connection**, then **Save**.\n3. Under **Mappings**, turn off **Provision Microsoft Entra ID Groups**, since Sendora keeps no groups. A group assigned to the app still provisions its members.\n4. 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`.\n5. Turn provisioning on. It provisions the users assigned to the app, directly or through a group.\n\nIn Okta, use the SAML integration from above, since Okta offers SCIM only on SAML integrations.\n\n1. Under the app's **General** tab, edit **App Settings**, choose **SCIM** as **Provisioning** and save.\n2. Under the **Provisioning** tab, edit **Integration**. Enter the base URL as **SCIM connector base URL** and `userName` as **Unique identifier field for users**.\n3. Under **Supported provisioning actions**, tick **Push New Users** and **Push Profile Updates**, and leave the rest unticked. Sendora keeps no groups.\n4. Choose **HTTP Header** as **Authentication Mode**, and enter the token after **Bearer**. Choose **Test Connector Configuration**, then **Save**.\n5. Under **To App**, choose **Edit**, enable **Create Users**, **Update User Attributes** and **Deactivate Users**, and save.\n6. 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.\n\nAD FS and Keycloak have no SCIM client of their own. Google Workspace may not offer SCIM for a custom SAML app.\n\n### Endpoints\n\n| Endpoint | Does |\n| ---------------------------------------------------------- | ---------------------------------------------------- |\n| `GET /ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | Describe what Sendora serves |\n| `GET /Users` | Lists or filters, with `startIndex` and `count` |\n| `POST /Users` | Creates a member with no servers |\n| `GET /Users/:id` | Reads one |\n| `PUT /Users/:id` | Replaces the attributes it carries |\n| `PATCH /Users/:id` | Changes one |\n| `DELETE /Users/:id` | Removes one |\n| `GET /Groups` | Returns an empty list, since Sendora keeps no groups |\n\nAny 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.\n\nSCIM 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`.\n\nA 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.\n\n### The user\n\n- `id` is the Sendora user's id.\n- `userName` and `externalId` are kept as your provider sent them.\n- 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`.\n- `name.formatted` sets the full name, or else the given and family names, or else `displayName`.\n- `active` is false while the user is deactivated.\n- `password`, `roles`, `groups` and `entitlements` are ignored.\n\n### What each request does\n\n- `POST` creates a member with no servers and mails nothing. An address that any Sendora account already holds fails with 409 `uniqueness`.\n- `PUT` replaces each attribute it carries. One it leaves out keeps its value, `active` included.\n- `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.\n- `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.\n- 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`.\n- `DELETE` removes the user. For the account's last administrator it fails with 409, and they stay deactivated instead.\n- Deactivating the last active administrator still happens, since a leaver must lose access. Sendora's support then helps the account name a new one.\n\n## When certificates and secrets expire\n\nSendora 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.\n\nWith 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).\n\n[Limits](sendora://docs/limits#sso-and-scim) lists the counts and rates for SSO and SCIM."},{"uri":"sendora://docs/microsoft-365","title":"Move from Microsoft 365","description":"Move a system that sends through a Microsoft 365 mailbox with a password to Sendora's SMTP.","text":"# Move from Microsoft 365\n\nMove a system that sends through a Microsoft 365 mailbox with a password to Sendora's SMTP.\n\nMove 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.\n\n## What Microsoft changes\n\nMicrosoft 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:\n\n- Until the end of December 2026, nothing changes.\n- 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.\n- In organisations created after December 2026, it is unavailable by default, and OAuth is the way to sign in.\n- In the second half of 2027, Microsoft will announce the date it is removed for good.\n\nA 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.\n\n## Before you start\n\n- 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.\n- Access to the DNS of the domain your system sends from.\n- Access to the system's mail settings.\n\n## Move the system\n\n1. Add the domain your system sends from under [Domains](https://app.sendora.se/domains), and add the two DNS records it shows, as [Domains](sendora://docs/domains) explains. The records leave your MX and SPF records as they are, so Microsoft 365 keeps receiving the domain's mail.\n2. 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](sendora://docs/test-servers) takes mail as a live server does and delivers none of it.\n3. In the system's mail settings, replace Microsoft 365's values with Sendora's:\n\n| Setting | Microsoft 365 | Sendora |\n| ------------------- | ---------------------------------- | ------------------------------------------------- |\n| Server | `smtp.office365.com` | `smtp.sendora.se` |\n| Port and encryption | 587 with STARTTLS | 587 with STARTTLS, or 465 with TLS from the start |\n| User name | The mailbox's address | The server key |\n| Password | The mailbox's password | The server key |\n| From address | The mailbox, or one it may send as | Any address on a verified domain |\n\n4. 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).\n5. 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.\n6. 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.\n\n## What changes for the system\n\n- Replies go where they went before. They reach the From or `Reply-To` address, so a mailbox in Microsoft 365 still receives them.\n- 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](sendora://docs/limits#retention) gives.\n- 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](sendora://docs/suppressions).\n- Sendora's [limits](sendora://docs/limits) apply instead of Microsoft's.\n\n## If a message is refused\n\nSendora 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](sendora://docs/smtp#smtp-replies) lists every reply."},{"uri":"sendora://errors","title":"Error codes","description":"Every code the API answers, with its HTTP statuses and what it means.","text":"# Error codes\n\nEvery error answers a JSON body with an `error` code and a `message`; the Errors guide (sendora://docs/errors) says what each refusal carries.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled already; nothing is left to cancel. |\n| `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |\n| `default_stream` | 409 | The default stream of a server cannot be archived. |\n| `domain_exists` | 409 | The account already has that domain. |\n| `domain_reserved` | 400 | The name is Sendora’s own, or a return-path host; no account can receive on it. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |\n| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |\n| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |\n| `inbound_domain_exists` | 409 | The stream already has a domain, or another account holds the name verified. |\n| `inbound_stream_exists` | 409 | A server has one inbound stream at a time; archive it to make another. |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted; create another first. |\n| `last_token` | 409 | This is the only live key of the server; create another before revoking it. |\n| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |\n| `mode_immutable` | 400 | A server’s mode is fixed when it is created; create a new server for the other mode. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `not_found` | 404 | No such broadcast of this server. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `rate_limited` | 429 | More was sent within a minute than the limit allows. |\n| `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. |\n| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `secret_limit` | 409 | The webhook already holds two live secrets: one in use and one to roll to. Delete one before creating another. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; try again once they have left. |\n| `server_limit` | 409 | The account has as many servers as it may; Sendora raises the cap on request. |\n| `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. |\n| `spam_complaint_locked` | 403 | The recipient reported a message as spam; only Sendora support lifts that, at the recipient’s own request. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_exists` | 409 | The server already has a stream with this name, archived or not. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; the streamId names a transactional one. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |\n| `substitution_missing` | 422 | The subject, text or HTML names a {{ key }} that a message does not give. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `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. |\n| `tracking_domain_exists` | 409 | The account already has that host, or a host on that sending domain. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may have. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. A tracking domain is a subdomain of one. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of this account. |\n| `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `unsubscribe_locked` | 403 | The recipient unsubscribed themselves; only Sendora support lifts that, at the recipient’s own request. |\n| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL. |\n| `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. |"},{"uri":"sendora://sdk/readme","title":"The TypeScript SDK","description":"@sendora/sdk's README: every call, error code and webhook event.","text":"# @sendora/sdk\n\nThe official TypeScript SDK for [Sendora](https://sendora.se), transactional\nemail delivery. Zero dependencies. Runs on Node 20.19 or newer and on any runtime with `fetch` and WebCrypto: Bun, Deno, Cloudflare\nWorkers. ESM only; CommonJS projects on Node 20.19 or 22.12 and newer load it\nwith `require`.\n\n```sh\nnpm install @sendora/sdk\n```\n\nTwo clients, two kinds of key. `Sendora` takes a server key (`sk_…`) and\ndoes everything inside one server: sending, broadcasts, the log,\nreceived mail, streams, suppressions, tokens, webhooks and the server's\nown limits. `SendoraAccount` takes an account key\n(`ak_…`) and manages the account: its servers, their keys, its sending and tracking\ndomains. Both keys are secrets: keep them on the server and never ship\nthem to a browser. Either client throws at construction when its key is\nmissing, so build it at startup.\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });\n\nconst { messageId } = await sendora.email.send({\n from: { email: 'no-reply@example.se', name: 'Example AB' },\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n attachments: [{ name: 'faktura.pdf', content: pdfBytes, contentType: 'application/pdf' }],\n tag: 'invoice',\n metadata: { invoiceId: '2026-0912' },\n});\n```\n\n`from` must be on a verified sending domain. `to`, `cc` and `bcc` take\nplain addresses or objects with a display name, at most 50 in all; at least\none of `text` and `html` is required. `streamId` names the stream of the\nserver the message goes on; without it the default transactional stream. Attachments take bytes (`Uint8Array`,\nso a Node `Buffer` too) or a base64 string. Every method takes what the\nroute takes and answers what the route answers; every call accepts an\n`options.signal` to cancel it.\n\n## Sending\n\n| Call | Answers |\n| --------------------------------------------- | -------------------------------------------------------------------------- |\n| `sendora.email.send(message, options?)` | `{ messageId, status: 'accepted', submittedAt, test }` |\n| `sendora.email.sendBatch(messages, options?)` | `{ results }`, one per message in order, `accepted` with its id or `error` |\n\n`accepted` means the message is stored and on its way to the mail server;\ndelivery, deferral, bounce and complaint arrive later as events on the\nmessage and as webhooks.\n\n`options.idempotencyKey` names the request so a retry cannot send twice.\nThe SDK makes a random UUID when you give none; give your own when a retry\nmay come from another process. The API remembers a key for 24 hours and\nrefuses it with a different body (`idempotency_key_mismatch`).\n\nA batch never fails halfway. Each result is `status: 'accepted'` with the\nid, or `status: 'error'` with the same `error` code a single send would\nthrow; a retried batch answers the items sent before with `replayed: true`.\n\n```ts\nconst { results } = await sendora.email.sendBatch(messages, { idempotencyKey: 'invoices-2026-09' });\nfor (const result of results) {\n if (result.status === 'error') {\n console.log(result.index, result.error, result.message);\n }\n}\n```\n\n## Messages\n\n| Call | Answers |\n| ------------------------------------------------------------ | --------------------------------------------------------------------------------------- |\n| `sendora.messages.get(messageId)` | The message with its recipients, `tracking`, its attachments described and its timeline |\n| `sendora.messages.search(filters?)` | `{ messages, next }`, one page newest first |\n| `sendora.messages.searchAll(filters?)` | Every match, page by page, for `for await` |\n| `sendora.inbound.get(inboundMessageId)` | A received message: envelope, parties, headers, text, HTML, attachments described |\n| `sendora.inbound.search(filters?)` | `{ messages, next }` of received mail, newest first |\n| `sendora.inbound.searchAll(filters?)` | Every received match, page by page, for `for await` |\n| `sendora.inbound.raw(inboundMessageId)` | The raw message as bytes, `message/rfc822` |\n| `sendora.inbound.attachment(inboundMessageId, attachmentId)` | An attachment's bytes |\n\nA stream can also receive on a domain of yours: every address on it,\nwhatever the local part, lands on the stream. Claim it with the stream\nid, add the two records the answer gives, an MX that names Sendora and a\nTXT record that proves the claim, and mail is accepted once both are\nseen. A stream holds one domain, and the first account to verify a name\nholds it.\n\n| Call | Answers |\n| ----------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `sendora.inboundDomains.create({ streamId, domain })` | The domain with `mx` and `txt` to add |\n| `sendora.inboundDomains.list()` | `{ domains }` of the server |\n| `sendora.inboundDomains.get(inboundDomainId)` | One domain with the state of its records |\n| `sendora.inboundDomains.verify(inboundDomainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `sendora.inboundDomains.delete(inboundDomainId)` | Nothing; mail to it is refused from then on |\n\nFilters: `recipient`, a whole address or three characters or more of one,\n`streamId`, `tag`, `status` (`queued`, `delivered`, `deferred`, `bounced`,\n`expired`, or a list of them), `from` and `to` as `Date` or ISO 8601,\n`limit` (1 to 100), `after`. Bodies are never returned; the log keeps messages for 13\nmonths.\n\nWhere mail is tracked, each recipient has `tracked`, `opens`, `clicks`,\n`firstOpenAt` and `firstHumanOpenAt`. `messages.get` adds `tracking`, and its\ntimeline holds `Open` and `Click` events with their labels. Tracking is not\nopen to accounts yet.\n\n```ts\nconst message = await sendora.messages.get(messageId);\nconst delivered = message.recipients.every((recipient) => recipient.status === 'delivered');\n\nfor await (const bounced of sendora.messages.searchAll({\n status: 'bounced',\n from: new Date('2026-09-01'),\n})) {\n console.log(bounced.messageId, bounced.subject);\n}\n```\n\n## Streams\n\nEvery message goes on a stream of its server. A server starts with a\ndefault transactional stream, and the account adds more: transactional\nand broadcast ones, open to every account, and one inbound stream, which\nreceives mail at its `inboundAddress` instead of sending. Each sending stream has a\nsuppression list of its own.\n\n| Call | Answers |\n| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |\n| `sendora.streams.create({ kind, name })` | The stream; a taken name is `stream_exists`, a second inbound stream `inbound_stream_exists`, both with `existingId` |\n| `sendora.streams.list()` | `{ streams }`, archived ones included |\n| `sendora.streams.get(streamId)` | One stream |\n| `sendora.streams.update(streamId, { name, contentRetentionDays })` | The updated stream; the days apply to an inbound stream |\n| `sendora.streams.archive(streamId)` | The stream with `archivedAt`; it takes no new messages from then on |\n\n```ts\nconst stream = await sendora.streams.create({ kind: 'transactional', name: 'Aviseringar' });\nawait sendora.email.send({ ...message, streamId: stream.streamId });\n```\n\n## Broadcasts\n\nOne message to many on a broadcast stream, sent as a whole: the content\nonce and one entry per message, up to 50,000 and 50 MB per broadcast.\nEvery message is a message in the log with its own events and webhooks,\ncarrying the `broadcastId`, and every recipient gets their own\nunsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the\nstream's suppression list are dropped and counted in `suppressed`. The\nSDK sets an idempotency key, so a retry answers the same broadcast. A\nmessage's `substitutions`, up to 20 strings, replace `{{ key }}` in the\nsubject, the text and the HTML, escaped in the HTML; a key the content\nnames that a message lacks throws `substitution_missing`.\n\n| Call | Answers |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- |\n| `sendora.broadcasts.send(broadcast, options?)` | `{ broadcastId, total, suppressed, test }`; refused whole otherwise |\n| `sendora.broadcasts.get(broadcastId)` | The broadcast with `status`, `released`, `failed`, `opened`, `clicked` and `links` |\n| `sendora.broadcasts.list({ limit?, after? })` | `{ broadcasts, next }`, newest first |\n| `sendora.broadcasts.cancel(broadcastId)` | The cancelled broadcast; what had not left counts as failed |\n\n```ts\nconst { broadcastId } = await sendora.broadcasts.send({\n streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n from: { email: 'nyheter@example.se', name: 'Example AB' },\n subject: 'Nyheter i oktober',\n text: 'Hej! Här är månadens nyheter. Vill du inte ha fler? {{ unsubscribe_url }}',\n messages: ['anna@example.com', 'bo@example.com'].map((email) => ({ to: [email] })),\n});\nconst progress = await sendora.broadcasts.get(broadcastId);\n```\n\n## Suppressions\n\nAddresses the server no longer sends to: hard bounces, spam complaints,\nunsubscribes and entries added by hand. A send to one of them throws `recipient_suppressed`.\n\n| Call | Answers |\n| ---------------------------------------------------------- | ----------------------------------------- |\n| `sendora.suppressions.list({ streamId?, limit?, after? })` | `{ suppressions, next }`, newest first |\n| `sendora.suppressions.listAll({ streamId? })` | Every entry, for `for await` |\n| `sendora.suppressions.delete({ address, streamId? })` | Nothing; the address may be sent to again |\n\nEvery stream has a list of its own; without `streamId` the calls mean the\ndefault transactional stream. A spam complaint or a recipient's own\nunsubscribe cannot be lifted this way (`spam_complaint_locked`,\n`unsubscribe_locked`); only Sendora support can, at the recipient's own\nrequest. A refused send carries `streamId` beside `suppressed`.\n\n```ts\nawait sendora.suppressions.delete({ address: 'anna@example.com' });\n```\n\n## Tokens\n\nThe server's own keys, from its own key. Every key of a server has the\nsame rights; the value is shown once, when it is created; a server holds\nup to two live keys, and a third is `token_limit`. The account key\nmanages the same keys under `account.servers.tokens`.\n\n| Call | Answers |\n| --------------------------------- | ---------------------------------------------------- |\n| `sendora.tokens.create({ name })` | The token with its `token` value, once |\n| `sendora.tokens.list()` | `{ tokens }`, revoked ones included |\n| `sendora.tokens.get(tokenId)` | One token, without its value |\n| `sendora.tokens.revoke(tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\n## The server\n\nThe key's own server: its name and id, whether it is live or a test\nserver, every limit a send on it is held to with how much of it this\nminute and this month have used, and the account's sending domains a From\naddress may use.\n\n`inboundCap` is the account's monthly cap on received mail, apart from the\nlimits on sending, with its `limit`, `used` and `resetsAt`. Past it, mail\nto the account is refused for now, so senders retry. It is null on a test\nserver, which receives no mail.\n\n| Call | Answers |\n| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |\n| `sendora.server.get()` | The server with `mode`, `limits`, each with `used` and `resetsAt`, `inboundCap`, and `sendingDomains` to send from |\n\n```ts\nconst { mode, limits, sendingDomains } = await sendora.server.get();\nconst month = limits.find((limit) => limit.period === 'month');\nconsole.log(\n mode,\n month?.used,\n month?.limit,\n sendingDomains.map(({ domain }) => domain),\n);\n```\n\n## Webhooks\n\nAn https URL of yours that Sendora posts events to as they happen, signed\nwith a secret shown once at creation. A webhook holds up to two live\nsecrets, so a new one can be rolled in beside the old one: every delivery\nis then signed with each, the newest first, and the old secret is deleted\nonce your receiver has switched.\n\n| Call | Answers |\n| -------------------------------------------------------- | ------------------------------------------------ |\n| `sendora.webhooks.create({ url, events?, streamId? })` | The webhook with its `secret`, once |\n| `sendora.webhooks.list()` | `{ webhooks }` |\n| `sendora.webhooks.get(webhookId)` | One webhook |\n| `sendora.webhooks.delete(webhookId)` | Nothing; pending deliveries are dropped |\n| `sendora.webhooks.deliveries(webhookId, { status? })` | `{ deliveries, next }`, newest first |\n| `sendora.webhooks.deliveriesAll(webhookId, { status? })` | Every delivery, for `for await` |\n| `sendora.webhooks.replay(webhookId, deliveryId)` | The delivery, queued again |\n| `sendora.webhooks.createSecret(webhookId)` | A second secret with its `secret` value, once |\n| `sendora.webhooks.deleteSecret(webhookId, secretId)` | Nothing; the last live secret is refused |\n| `sendora.webhooks.receive(request, secret)` | The verified, typed event from a Fetch `Request` |\n\nEvents: `delivered`, `bounced`, `deferred`, `spam_complaint` and\n`unsubscribed` about a message, with `messageId`, `streamId`, `recipient`,\n`occurredAt`, `serverId`, your `tag` and `metadata`, and `details` from the\nreceiver, or for `unsubscribed` the `source` (`link` or `one_click`);\n`cap_warning` and `cap_reached` about usage, with `scope`, `cap`, `used`\nand the period; `inbound` about a message received on an inbound stream,\nwith `inboundMessageId`, `envelopeRecipient`, the sizes, the\n`authentication` verdicts and, for a webhook with `inboundContent:\n'full'`, the message itself: `from`, `to`, `subject`, `text`, `html`,\n`attachments` and the rest. `inbound_cap_warning` and\n`inbound_cap_reached` say that received mail nears or reaches the\naccount's monthly cap, with `cap`, `used` and the period. Every event\ncarries `id`, the delivery id, and `attempt`.\n\nA failed delivery is retried with growing delays for about a day, then it\nis `dead` until you replay it. Deliveries arrive at least once and not\nalways in order, so key your handling on `event.id`.\n\n### Receiving\n\nVerify the signature before you parse anything, answer 2xx quickly, and do\nthe work afterwards. With a framework that hands you a Fetch `Request`\n(Next.js route handlers, Hono, SvelteKit, Cloudflare Workers):\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });\n\nexport async function POST(request: Request): Promise<Response> {\n const event = await sendora.webhooks.receive(request, process.env.SENDORA_WEBHOOK_SECRET);\n if (event.event === 'bounced' && event.details.hard) {\n console.log(\n `${event.recipient} is undeliverable: ${event.details.classification ?? 'unknown'}`,\n );\n }\n return new Response(null, { status: 204 });\n}\n```\n\nWith Express or Fastify, keep the raw body (`express.raw({ type: '*/*' })`\nor the raw-body plugin) and call the verifier directly:\n\n```ts\nimport { verifyWebhook } from '@sendora/sdk';\n\nconst event = await verifyWebhook({\n secret: process.env.SENDORA_WEBHOOK_SECRET,\n signature: req.headers['sendora-signature'],\n body: req.body,\n});\nswitch (event.event) {\n case 'bounced':\n console.log(event.recipient, event.details.hard);\n break;\n case 'unknown':\n console.log('an event newer than this release:', event.name, event.data);\n break;\n}\n```\n\nAn event this release does not know, one newer than it, comes as an\n`UnknownEvent` rather than an error, so your endpoint does not fail it\nuntil it is dead. Its `event` is `unknown`, `name` holds the event's name\nand `data` the payload as it came. Upgrade to read it as its type. A\nsigned body that is no event at all throws `unexpected_response`.\n\nThe signature is `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`,\none `v1` per live secret of the webhook, each an HMAC-SHA256 with that\nsecret over `<t>.<raw body>`, compared in constant time through WebCrypto;\nthe one your secret produces is enough. To roll a secret, create the new\none with `webhooks.createSecret`, move your receiver to it while both\nsign every delivery, then delete the old one. A signature older than five minutes is refused\n(`stale_signature`; `toleranceSeconds` changes the limit), a wrong one\nthrows `invalid_signature`.\n\n## The account: servers, keys, domains and erasures\n\nAn account key (`ak_…`) manages the account and never sends. An\nadministrator creates one in the dashboard under Account › API keys; the\naccount holds up to two live ones. `SendoraAccount` takes the same\noptions as `Sendora`.\n\n```ts\nimport { Sendora, SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN });\n\nconst server = await account.servers.create({ name: 'Fakturering' });\nconst sendora = new Sendora({ token: server.token.token });\n```\n\nA server is created with its default transactional stream and its first\nkey, whose value is in the answer this once. A server holds up to two\nlive keys and never falls below one, so rotation is create the new key,\nswitch to it, revoke the old one.\n\n### Test servers\n\nA server created with `mode: 'test'` goes through everything a live one\ndoes, validation, the suppression list, the log and the webhooks, but\nnothing it accepts is delivered. Its keys start `sk_test_`. Addresses at\n`simulator.sendora.se` (`hardbounce@`, `softbounce@`, `deferred@`,\n`complaint@`, with any `+label`) produce those outcomes; any other\naddress is marked delivered. A server's mode is fixed when it is\ncreated, so going live means a new server and a new key.\n\n```ts\nimport { isTestKey, Sendora, SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN });\nconst ci = await account.servers.create({ name: 'CI', mode: 'test' });\n\nconst token = process.env.SENDORA_API_TOKEN ?? '';\nif (process.env.NODE_ENV === 'production' && isTestKey(token)) {\n throw new Error('SENDORA_API_TOKEN is a test key; nothing would be delivered');\n}\nconst sendora = new Sendora({ token });\n```\n\nTest mail is free and counts against its own monthly cap and per-minute\nlimit, shared by the account's test servers. A test server has no\ninbound stream (`stream_kind_not_allowed`), and a rename that names a\nmode is `mode_immutable`.\n\n| Call | Answers |\n| --------------------------------------------------- | ---------------------------------------------------------------------------------- |\n| `account.servers.create({ name, mode })` | The server with its first key as `token`, once; a taken name is `server_exists` |\n| `account.servers.list()` | `{ servers }`, oldest first |\n| `account.servers.get(serverId)` | One server |\n| `account.servers.update(serverId, { name })` | The renamed server |\n| `account.servers.delete(serverId)` | Nothing; `server_in_flight` while its messages are still being delivered |\n| `account.servers.tokens.create(serverId, { name })` | The key with its `token` value, once; a third live key is `token_limit` with `max` |\n| `account.servers.tokens.list(serverId)` | `{ tokens }`, revoked ones included |\n| `account.servers.tokens.get(serverId, tokenId)` | One key, without its value |\n| `account.servers.tokens.revoke(serverId, tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\n```ts\nconst { token } = await account.servers.tokens.create(serverId, { name: 'Fakturasystemet' });\n```\n\nSending domains belong to the account, and every server sends from them.\nA domain sends once both DNS records are seen. A lookup that fails at the\nresolver (`dns_error`) changes nothing, and a verified domain whose record\ngoes missing keeps sending for 24 hours while the account's administrators\nare told, with `failingSince` saying when that began.\n\n| Call | Answers |\n| ------------------------------------ | ---------------------------------------------------------------------------- |\n| `account.domains.create({ domain })` | The domain with `returnPath` (CNAME) and `dkim` (TXT) to add |\n| `account.domains.list()` | `{ domains }` |\n| `account.domains.get(domainId)` | One domain with the state of its records |\n| `account.domains.verify(domainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `account.domains.delete(domainId)` | Nothing; mail from it is refused from then on |\n\n```ts\nconst domain = await account.domains.create({ domain: 'example.se' });\nconsole.log(`${domain.returnPath.type} ${domain.returnPath.host} -> ${domain.returnPath.value}`);\nconsole.log(`${domain.dkim.type} ${domain.dkim.host} -> ${domain.dkim.value}`);\n```\n\nA tracking domain is the host that tracked links and the open pixel use: a\nsubdomain of a verified sending domain, with one CNAME record to add. CAA\nrecords above it must let Let's Encrypt issue its certificate. Tracking is\nnot open to accounts yet, so a host carries no mail until it is. A host\nwhose records are not right within 7 days goes.\n\n| Call | Answers |\n| -------------------------------------------------- | --------------------------------------------------------------------- |\n| `account.trackingDomains.create({ host })` | The tracking domain with `cname` to add |\n| `account.trackingDomains.list()` | `{ trackingDomains }` |\n| `account.trackingDomains.get(trackingDomainId)` | One tracking domain with what the last check found |\n| `account.trackingDomains.verify(trackingDomainId)` | The tracking domain as it stands: `state`, `cname.check`, `caa.check` |\n| `account.trackingDomains.delete(trackingDomainId)` | Nothing; new mail stops using it at once |\n\n```ts\nconst tracking = await account.trackingDomains.create({ host: 'links.example.se' });\nconsole.log(`${tracking.cname.type} ${tracking.cname.host} -> ${tracking.cname.value}`);\n```\n\nAn erasure removes one person's data from every server of the account,\nas a request under the right to erasure asks. The content of every\nmessage to or from the address, sent and received, is deleted, and the\naddress becomes a pseudonym in the message log. The erasure answers\n`pending`, and Sendora carries it out within a minute. Erasing an address\nagain changes nothing more, and later mail to or from it is sent and\nreceived as before.\n\n| Call | Answers |\n| -------------------------------------- | ----------------------------------------------------------------------------------------- |\n| `account.erasures.create({ address })` | The erasure, `pending` |\n| `account.erasures.get(erasureId)` | The erasure; once `done`, its `pseudonym`, `messages` and `receivedMessages`, for 30 days |\n\n```ts\nconst erasure = await account.erasures.create({ address: 'anna@example.com' });\nconst { status } = await account.erasures.get(erasure.erasureId);\n```\n\nA key of the wrong kind answers `wrong_token_kind`: a server key on the\naccount's routes, or an account key on a server's.\n\n## Errors\n\nEvery failed call throws `SendoraError`. A path id that is empty, `.` or\n`..` throws `TypeError` instead, before any request, since the URL would\nreach another route.\n\nNarrow on `code`:\n\n```ts\nimport { SendoraError } from '@sendora/sdk';\n\ntry {\n await sendora.email.send(message);\n} catch (error) {\n if (error instanceof SendoraError) {\n switch (error.code) {\n case 'recipient_suppressed':\n // error.suppressed lists the addresses and why\n break;\n case 'rate_limited':\n // error.retryAfter seconds; error.scope and error.limit say whose limit\n break;\n case 'invalid_request':\n // error.issues, one { path, message } per field\n break;\n }\n }\n throw error;\n}\n```\n\n| Code | Status | Meaning |\n| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------- |\n| `invalid_request` | 400 | A field is wrong; `issues` says which. |\n| `idempotency_key_required` | 400 | A batch was sent without a key. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `wrong_token_kind` | 403 | A server key where an account key is needed, or the reverse; `message` names the kind. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `account_paused` | 403 | Sendora has paused the account. |\n| `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |\n| `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |\n| `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |\n| `not_found` | 404 | No such id on this server, or no such suppressed address. |\n| `domain_exists` | 409 | The account already has the domain; `existingId` names it. |\n| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |\n| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existingId` names it. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself; choose a subdomain. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |\n| `tracking_host_taken` | 409 | Another account holds the host. |\n| `inbound_domain_exists` | 409 | The stream already has a domain (`existingId` names it), or another account holds the name verified. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL; `existingId` names it. |\n| `stream_exists` | 409 | The server already has a stream with that name; `existingId` names it. |\n| `inbound_stream_exists` | 409 | The server already has a live inbound stream; `existingId` names it. |\n| `default_stream` | 409 | The default stream cannot be archived. |\n| `last_token` | 409 | The only live key cannot be revoked. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted. |\n| `secret_limit` | 409 | The webhook already holds two live secrets; `max` says how many. |\n| `token_limit` | 409 | The server or the account already holds two live keys; `max` says how many. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_limit` | 409 | The account has as many servers as it may; `max` says how many. |\n| `server_reserved` | 409 | The server sends Sendora's own sign-in mail and cannot be renamed or removed. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; delete it later. |\n| `request_too_large` | 413 | The body exceeds 10 MB, or 64 MB for a broadcast. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain. |\n| `stream_not_found` | 422 | `streamId` names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora paused the stream after complaints; support resumes it. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no list. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; this one is transactional. |\n| `substitution_missing` | 422 | The content names a `{{ key }}` a message does not give; `index` and `keys` say which. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled; nothing is left to cancel. |\n| `unsubscribe_placeholder_missing` | 422 | A broadcast message lacks `{{ unsubscribe_url }}` in a part. |\n| `list_unsubscribe_reserved` | 422 | Sendora writes List-Unsubscribe on broadcast streams; leave it out. |\n| `recipient_suppressed` | 422 | Recipients on the suppression list; `suppressed` lists them. |\n| `test_address_on_live_server` | 422 | Addresses at simulator.sendora.se are for test servers; `addresses` lists them. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `mode_immutable` | 400 | A server's mode is fixed at creation; create a new server for the other mode. |\n| `idempotency_key_mismatch` | 422 | The key was used before with a different body. |\n| `content_expired` | 410 | The received message's content window has passed; the message itself stays. |\n| `content_unreadable` | 409 | The received message's stored content cannot be opened; Sendora has been told. |\n| `rate_limited` | 429 | Per-minute limit; `retryAfter`, `scope`, `limit`. From the edge, too many requests at once. |\n| `monthly_cap_reached` | 429 | The monthly cap is used up; `scope`, `cap`, `used`, `resetsAt`. |\n| `sending_disabled` | 503 | Sending is paused for everyone; `retryAfter`. |\n| `connection_failed` | none | The request never reached the API; `cause` holds the failure. |\n| `timeout` | none | No answer within `timeoutMs`. |\n| `unexpected_response` | any | An answer without a JSON error body, such as from a proxy, or a signed webhook body that is no event. |\n| `invalid_signature` | none | A webhook's signature is missing, malformed or wrong. |\n| `stale_signature` | none | A webhook's signature is older than the tolerance. |\n\n`error.retryable` is true when waiting and calling again could succeed.\nNeither the key nor a request body is ever part of an error.\n\n## Retries\n\nLost connections, timeouts, `rate_limited` (the edge's own included),\n`sending_disabled` and server errors are retried with jittered backoff,\ntwice by default, honouring `retryAfter` when it fits within five seconds\nof waiting in all. A longer `retryAfter` is thrown at once for you to\nschedule. A request that creates something (`streams.create`,\n`tokens.create`, `webhooks.create`, `webhooks.createSecret`,\n`webhooks.replay`, `inboundDomains.create`, `servers.create`,\n`servers.tokens.create`, `domains.create`, `trackingDomains.create`) is repeated\nonly after a `rate_limited` or `sending_disabled` answer, never after a\nlost connection or a server error, so nothing is created twice. Nothing\nelse is retried. `new Sendora({ maxRetries: 0 })` turns retries off;\n`timeoutMs` sets the limit per attempt.\n\n## For coding agents\n\nThe package ships `skills/sendora/SKILL.md`, a short guide in the agent\nskill format with the rules above; point your agent at it or at this\nREADME. Every exported symbol carries JSDoc with an example.\n\n## Docs\n\nGuides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).\n"},{"uri":"sendora://sdk/skill","title":"Rules for agents using the TypeScript SDK","description":"The skill file @sendora/sdk ships for coding agents.","text":"---\nname: sendora\ndescription: Send transactional email through Sendora with @sendora/sdk, read the message log, manage streams, suppressions, keys, webhooks, servers and sending domains, and receive signed webhooks. Use when a task mentions Sendora, @sendora/sdk, or sending email from a system hosted in Sweden.\n---\n\n# Sendora\n\n`@sendora/sdk` is the official TypeScript client for the Sendora API. It\nhas zero dependencies, runs on Node 20.19 or newer and on any runtime with\n`fetch` and WebCrypto, and is ESM only. The README in this package is the\ncomplete reference; every call, every error code and every event is in it.\n\n## Rules\n\n- Two kinds of key, two clients. A server key (`sk_…`) drives\n `new Sendora({ token })`: `email`, `broadcasts`, `messages`, `inbound`,\n `inboundDomains`, `server`, `streams`, `suppressions`, `tokens`,\n `webhooks`, everything inside one server. An account key (`ak_…`) drives `new SendoraAccount({ token })`:\n `servers`, `servers.tokens`, `domains` and `trackingDomains`, and it never sends. A key of\n the wrong kind throws `wrong_token_kind`.\n- Every key is a secret. Read it from the environment on the server and\n never send it to a browser. Either client throws when its key is\n missing, so construct it at startup.\n- A server is created with its first key: `account.servers.create({ name })`\n answers `token.token` once, and `new Sendora({ token })` sends with it. A\n server or the account holds at most two live keys, so rotate by creating\n the new key, switching to it, then revoking the old one; the last live\n key can never be revoked.\n- For tests and CI, use a test server: `account.servers.create({ name,\nmode: 'test' })`. It accepts, logs and fires webhooks like a live one\n but delivers nothing; its keys start `sk_test_`, and `isTestKey(token)`\n tells them apart at startup. Addresses at `simulator.sendora.se`\n (`hardbounce@`, `softbounce@`, `deferred@`, `complaint@`) produce those\n outcomes on a test server and are refused on a live one\n (`test_address_on_live_server`). A server's mode never changes.\n- Every method takes what the route takes and answers what the route\n answers. Do not build request bodies by hand or call `fetch` yourself.\n- Do not add idempotency keys or retry loops of your own. The SDK gives\n every send a key and repeats a call only when repeating is safe. Pass\n `idempotencyKey` only when a retry may come from another process.\n- Every failed call throws `SendoraError`. Catch it, narrow on\n `error.code`, and let anything else propagate. Never swallow it.\n- A batch never throws for one bad message: read `results[i].status`.\n- Delivery is asynchronous. `email.send` answers `accepted`; whether the\n receiver took the message is in `messages.get(messageId).recipients`\n and in the `delivered`, `bounced`, `deferred` and `spam_complaint`\n webhook events.\n- Verify a webhook before parsing it. With a Fetch `Request`, call\n `sendora.webhooks.receive(request, secret)`; with Express or Fastify,\n call `verifyWebhook` with the secret, the header and the raw body.\n Narrow on `event.event`, and handle `unknown`, an event newer than the\n release, whose payload is in `event.data`. Answer 2xx quickly and do the\n work afterwards. Deliveries arrive at least once, so key handling on\n `event.id`.\n- Addresses on the suppression list are refused with\n `recipient_suppressed`; lift a hard bounce with\n `suppressions.delete({ address })`; a spam complaint or an unsubscribe cannot\n be lifted by the customer. Lists are per stream: pass `streamId` when the send went on\n a stream other than the default.\n- `from` must be on a verified sending domain of the account;\n `account.domains.create` answers the two DNS records to add, and\n `account.domains.verify` checks them now. A server key cannot manage\n domains, but `server.get()` names those a From may use (`sendingDomains`). A verified domain whose record goes missing keeps sending for\n 24 hours (`failingSince`); a `dns_error` changes nothing.\n- A person who asks to be forgotten is erased with the account key:\n `account.erasures.create({ address })` answers a pending erasure, and\n `account.erasures.get(erasureId)` shows it `done` within a minute. It\n deletes their messages' content and puts a pseudonym in the log on every\n server; it blocks nothing.\n- A message goes on the server's default transactional stream unless it\n names another with `streamId`; `streams.create` adds one. Never make a\n stream per message or per customer; a stream is a kind of mail.\n- Mail to many goes as one broadcast: `broadcasts.send({ streamId, from,\nsubject, text, messages })` with one entry per recipient, on a\n broadcast stream, at most 50,000 entries; follow it with\n `broadcasts.get(broadcastId)` and stop it with `broadcasts.cancel`.\n Personalise with `{{ key }}` in the content and `substitutions` on each\n entry. Never loop over `email.send` for a list.\n- A message on a broadcast stream must carry `{{ unsubscribe_url }}` in\n its text and its HTML; Sendora puts each recipient's own link there and\n writes the List-Unsubscribe headers itself. An `unsubscribed` event\n tells your system to stop; the address is then on that stream's list.\n\n## The shape of a call\n\n```ts\nimport { Sendora, SendoraError } from '@sendora/sdk';\n\nconst sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });\n\ntry {\n const { messageId } = await sendora.email.send({\n from: { email: 'no-reply@example.se', name: 'Example AB' },\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n tag: 'invoice',\n });\n} catch (error) {\n if (error instanceof SendoraError && error.code === 'recipient_suppressed') {\n // error.suppressed lists the addresses and why\n }\n throw error;\n}\n```\n\n## Where to read more\n\n- The README of this package: every method, every error code, retries,\n webhooks and events.\n- The guides and the HTTP reference at https://sendora.se/docs.\n"},{"uri":"sendora://sdk/python/readme","title":"The Python SDK","description":"sendora's README: every call, error code and webhook event.","text":"# sendora\n\nThe Python client for [Sendora](https://sendora.se), transactional email\ndelivery. Python 3.11 or newer, one dependency (httpx2), typed throughout:\nevery request is a `TypedDict` your editor completes and every answer a\nfrozen dataclass.\n\n```sh\npip install sendora\n```\n\nTwo kinds of key, two clients. `Sendora` takes a server key (`sk_…`) and\ndoes everything inside one server: sending, broadcasts, the log, received\nmail, streams, suppressions, tokens, webhooks and the server's own limits.\n`SendoraAccount` takes an account key (`ak_…`) and manages the account:\nits servers, their keys, its sending and tracking domains. `AsyncSendora` and\n`AsyncSendoraAccount` are the same clients for asyncio. Both keys are\nsecrets: keep them on the server and never ship them to a browser.\n\n```python\nfrom pathlib import Path\n\nfrom sendora import Sendora\n\nsendora = Sendora() # the key from SENDORA_API_TOKEN\n\naccepted = sendora.email.send(\n from_={\"email\": \"no-reply@example.se\", \"name\": \"Example AB\"},\n to=[\"anna@example.com\"],\n subject=\"Din faktura för september\",\n text=\"Hej Anna, fakturan finns bifogad.\",\n attachments=[\n {\n \"name\": \"faktura.pdf\",\n \"content\": Path(\"faktura.pdf\").read_bytes(),\n \"content_type\": \"application/pdf\",\n }\n ],\n tag=\"invoice\",\n metadata={\"invoice_id\": \"2026-0912\"},\n)\nprint(accepted.message_id)\n```\n\nA client without a key raises `ValueError` at construction, so build it\nat startup; `Sendora(\"sk_…\")` takes the key directly and\n`SendoraAccount()` reads `SENDORA_ACCOUNT_TOKEN`. Make one client per\nprocess and share it: it is safe across threads and reuses its\nconnections. `with Sendora() as sendora:` closes them at the end, as\n`sendora.close()` does.\n\n`from_` must be on a verified sending domain; it ends in an underscore\nbecause `from` is a Python keyword. `to`, `cc` and `bcc` take plain\naddresses or `{\"email\", \"name\"}` dictionaries, at most 50 in all; at least\none of `text` and `html` is required. `stream_id` names the stream the\nmessage goes on; without it the default transactional stream. Attachments\ntake bytes or a base64 string. Every method takes what its route takes,\nwith snake_case names, and answers what the route answers; your own keys,\nin `metadata`, `headers` and `substitutions`, are sent as you wrote them.\n\n## Sending\n\n| Call | Answers |\n| ------------------------------------ | ---------------------------------------------------------------------- |\n| `sendora.email.send(**message)` | `AcceptedEmail` with `message_id`, `status`, `submitted_at` and `test` |\n| `sendora.email.send_batch(messages)` | `BatchResult` with `results`, one per message in order |\n\n`accepted` means the message is stored and on its way to the mail server;\ndelivery, deferral, bounce and complaint arrive later as events on the\nmessage and as webhooks.\n\n`idempotency_key=` names the request so a retry cannot send twice. The\nSDK makes a random UUID when you give none; give your own when a retry\nmay come from another process. The API remembers a key for 24 hours and\nrefuses it with a different body (`idempotency_key_mismatch`). An error\nfrom a send carries the key it went under as `error.idempotency_key`.\n\nA batch never raises for one refused message. Each result is an\n`AcceptedBatchItem` with the id, or a `RefusedBatchItem` with the same\n`code` a single send would raise; a retried batch answers the items sent\nbefore with `replayed=True`.\n\n```python\nfrom sendora.types import RefusedBatchItem\n\nbatch = sendora.email.send_batch(messages, idempotency_key=\"invoices-2026-09\")\nfor result in batch.results:\n if isinstance(result, RefusedBatchItem):\n print(result.index, result.code, result.message)\n```\n\n## Messages\n\n| Call | Answers |\n| --------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| `sendora.messages.get(message_id)` | The message with its recipients, `tracking`, its attachments described and its events |\n| `sendora.messages.search(**filters)` | `MessagePage` with `messages` and `next`, newest first |\n| `sendora.messages.search_all(**filters)` | Every match, page by page, as an iterator |\n| `sendora.inbound.get(inbound_message_id)` | A received message: envelope, parties, headers, text, HTML, attachments described |\n| `sendora.inbound.search(**filters)` | `InboundPage` of received mail, newest first |\n| `sendora.inbound.search_all(**filters)` | Every received match, page by page, as an iterator |\n| `sendora.inbound.raw(inbound_message_id)` | The raw message as `bytes`, `message/rfc822` |\n| `sendora.inbound.attachment(inbound_message_id, attachment_id)` | An attachment's `bytes` |\n\nA stream can also receive on a domain of yours: every address on it,\nwhatever the local part, lands on the stream. Claim it with the stream\nid, add the two records the answer gives, an MX that names Sendora and a\nTXT record that proves the claim, and mail is accepted once both are\nseen. A stream holds one domain, and the first account to verify a name\nholds it.\n\n| Call | Answers |\n| ----------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `sendora.inbound_domains.create(stream_id=, domain=)` | The domain with `mx` and `txt` to add |\n| `sendora.inbound_domains.list()` | `InboundDomainList` with `domains` |\n| `sendora.inbound_domains.get(inbound_domain_id)` | One domain with the state of its records |\n| `sendora.inbound_domains.verify(inbound_domain_id)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `sendora.inbound_domains.delete(inbound_domain_id)` | `None`; mail to it is refused from then on |\n\nThe filters of `messages.search` are `recipient`, a whole address or\nthree characters or more of one, `stream_id`, `broadcast_id`, `tag`,\n`status` (`queued`, `delivered`, `deferred`, `bounced`, `expired`, or a\nlist of them), `from_` and `to` as an aware `datetime` or ISO 8601,\n`limit` (1 to 100, 50 by default) and `after`, the previous page's\n`next`. Bodies are never returned; the log keeps messages for 13 months.\n\nWhere mail is tracked, each recipient has `tracked`, `opens`, `clicks`,\n`first_open_at` and `first_human_open_at`. `messages.get` adds `tracking`, and\nits timeline holds `Open` and `Click` events with their labels. Tracking is\nnot open to accounts yet.\n\n```python\nfrom datetime import UTC, datetime\n\nmessage = sendora.messages.get(message_id)\ndelivered = all(recipient.status == \"delivered\" for recipient in message.recipients)\n\nfor bounced in sendora.messages.search_all(\n status=\"bounced\", from_=datetime(2026, 9, 1, tzinfo=UTC)\n):\n print(bounced.message_id, bounced.subject)\n```\n\n## Streams\n\nEvery message goes on a stream of its server. A server starts with a\ndefault transactional stream, and the account adds more: transactional\nand broadcast ones, and one inbound stream, which receives mail at its\n`inbound_address` instead of sending. Each sending stream has a\nsuppression list of its own.\n\n| Call | Answers |\n| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| `sendora.streams.create(kind=, name=)` | The stream; a taken name is `stream_exists`, a second inbound stream `inbound_stream_exists`, both with `existing_id` |\n| `sendora.streams.list()` | `StreamList` with `streams`, archived ones included |\n| `sendora.streams.get(stream_id)` | One stream |\n| `sendora.streams.update(stream_id, name=, content_retention_days=)` | The updated stream; the days apply to an inbound stream |\n| `sendora.streams.archive(stream_id)` | The stream with `archived_at`; it takes no new messages from then on |\n\n```python\nstream = sendora.streams.create(kind=\"transactional\", name=\"Aviseringar\")\nsendora.email.send(\n from_=\"no-reply@example.se\",\n to=[\"anna@example.com\"],\n subject=\"Leveransen är på väg\",\n text=\"Hej Anna, paketet lämnade lagret i dag.\",\n stream_id=stream.stream_id,\n)\n```\n\n## Broadcasts\n\nOne message to many on a broadcast stream, sent as a whole: the content\nonce and one entry per message, up to 50,000 and 50 MB per broadcast.\nEvery message is a message in the log with its own events and webhooks,\ncarrying the `broadcast_id`, and every recipient gets their own\nunsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the\nstream's suppression list are dropped and counted in `suppressed`. The\nSDK sets an idempotency key, so a retry answers the same broadcast. A\nmessage's `substitutions`, up to 20 strings, replace `{{ key }}` in the\nsubject, the text and the HTML, escaped in the HTML; a key the content\nnames that a message lacks raises `substitution_missing`.\n\n| Call | Answers |\n| ----------------------------------------- | ---------------------------------------------------------------------------------- |\n| `sendora.broadcasts.send(**broadcast)` | `AcceptedBroadcast` with `broadcast_id`, `total`, `suppressed` and `test` |\n| `sendora.broadcasts.get(broadcast_id)` | The broadcast with `status`, `released`, `failed`, `opened`, `clicked` and `links` |\n| `sendora.broadcasts.list(limit=, after=)` | `BroadcastPage` with `broadcasts` and `next`, newest first |\n| `sendora.broadcasts.cancel(broadcast_id)` | The cancelled broadcast; what had not left counts as failed |\n\n```python\naccepted = sendora.broadcasts.send(\n stream_id=\"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n from_={\"email\": \"nyheter@example.se\", \"name\": \"Example AB\"},\n subject=\"Nyheter i oktober, {{ first_name }}\",\n text=\"Hej! Här är månadens nyheter. Vill du inte ha fler? {{ unsubscribe_url }}\",\n messages=[\n {\"to\": [\"anna@example.com\"], \"substitutions\": {\"first_name\": \"Anna\"}},\n {\"to\": [\"bo@example.com\"], \"substitutions\": {\"first_name\": \"Bo\"}},\n ],\n)\nprogress = sendora.broadcasts.get(accepted.broadcast_id)\n```\n\n## Suppressions\n\nAddresses the server no longer sends to: hard bounces, spam complaints,\nunsubscribes and entries added by hand. A send to one of them raises\n`recipient_suppressed`.\n\n| Call | Answers |\n| ------------------------------------------------------- | ------------------------------------------------ |\n| `sendora.suppressions.list(stream_id=, limit=, after=)` | `SuppressionPage` with `suppressions` and `next` |\n| `sendora.suppressions.list_all(stream_id=)` | Every entry, as an iterator |\n| `sendora.suppressions.delete(address=, stream_id=)` | `None`; the address may be sent to again |\n\nEvery stream has a list of its own; without `stream_id` the calls mean\nthe default transactional stream. A spam complaint or a recipient's own\nunsubscribe cannot be lifted this way (`spam_complaint_locked`,\n`unsubscribe_locked`); only Sendora support can, at the recipient's own\nrequest. A refused send carries `stream_id` beside `suppressed`.\n\n```python\nsendora.suppressions.delete(address=\"anna@example.com\")\n```\n\n## Tokens\n\nThe server's own keys, from its own key. Every key of a server has the\nsame rights; the value is shown once, when it is created; a server holds\nup to two live keys, and a third is `token_limit`. The account key\nmanages the same keys under `account.servers.tokens`.\n\n| Call | Answers |\n| --------------------------------- | --------------------------------------------------- |\n| `sendora.tokens.create(name=)` | `CreatedToken` with its `token` value, once |\n| `sendora.tokens.list()` | `TokenList` with `tokens`, revoked ones included |\n| `sendora.tokens.get(token_id)` | One token, without its value |\n| `sendora.tokens.revoke(token_id)` | `None`; the last live key is refused (`last_token`) |\n\nA key's value is kept out of the answer's `repr`, so a logged answer does\nnot print it; read it from `.token`.\n\n## The server\n\nThe key's own server: its name and id, whether it is live or a test\nserver, every limit a send on it is held to with how much of it this\nminute and this month have used, and the account's sending domains a From\naddress may use.\n\n`inbound_cap` is the account's monthly cap on received mail, apart from the\nlimits on sending, with its `limit`, `used` and `resets_at`. Past it,\nmail to the account is refused for now, so senders retry. It is `None` on\na test server, which receives no mail.\n\n| Call | Answers |\n| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |\n| `sendora.server.get()` | `OwnServer` with `mode`, `limits`, each with `used` and `resets_at`, `inbound_cap`, and `sending_domains` to send from |\n\n```python\nown = sendora.server.get()\nfor limit in own.limits:\n print(limit.scope, limit.period, limit.used, \"of\", limit.limit)\nprint([sending.domain for sending in own.sending_domains])\n```\n\n## Webhooks\n\nAn https URL of yours that Sendora posts events to as they happen, signed\nwith a secret shown once at creation. A webhook holds up to two live\nsecrets, so a new one can be rolled in beside the old one: every delivery\nis then signed with each, the newest first, and the old secret is deleted\nonce your receiver has switched.\n\n| Call | Answers |\n| ---------------------------------------------------------------------- | --------------------------------------------- |\n| `sendora.webhooks.create(url=, events=, stream_id=, inbound_content=)` | `CreatedWebhook` with its `secret`, once |\n| `sendora.webhooks.list()` | `WebhookList` with `webhooks` |\n| `sendora.webhooks.get(webhook_id)` | One webhook |\n| `sendora.webhooks.delete(webhook_id)` | `None`; pending deliveries are dropped |\n| `sendora.webhooks.deliveries(webhook_id, status=)` | `DeliveryPage` with `deliveries` and `next` |\n| `sendora.webhooks.deliveries_all(webhook_id, status=)` | Every delivery, as an iterator |\n| `sendora.webhooks.replay(webhook_id, delivery_id)` | The delivery, queued again |\n| `sendora.webhooks.create_secret(webhook_id)` | A second secret with its `secret` value, once |\n| `sendora.webhooks.delete_secret(webhook_id, secret_id)` | `None`; the last live secret is refused |\n\nEvents: `delivered`, `bounced`, `deferred`, `spam_complaint` and\n`unsubscribed` about a message, with `message_id`, `stream_id`,\n`recipient`, `occurred_at`, `server_id`, your `tag` and `metadata`, and\n`details` from the receiver, or for `unsubscribed` the `source` (`link` or\n`one_click`); `cap_warning` and `cap_reached` about usage, with `scope`,\n`cap`, `used` and the period; `inbound` about a message received on an\ninbound stream, with `inbound_message_id`, `envelope_recipient`, the\nsizes, the `authentication` verdicts and, for a webhook with\n`inbound_content=\"full\"`, the message itself under `content`: `from_`,\n`to`, `subject`, `text`, `html`, `attachments` and the rest.\n`inbound_cap_warning` and `inbound_cap_reached` say that received mail\nnears or reaches the account's monthly cap, with `cap`, `used` and the\nperiod. Every event carries `id`, the delivery id, and `attempt`.\n\nA failed delivery is retried with growing delays for about a day, then it\nis `dead` until you replay it. Deliveries arrive at least once and not\nalways in order, so key your handling on `event.id`.\n\n### Receiving\n\nVerify the signature before you parse anything, answer 2xx quickly, and\ndo the work afterwards. `verify_webhook` takes the raw body exactly as it\narrived and the `Sendora-Signature` header, and answers the event as a\nclass of its own. The raw body is `request.get_data()` in Flask and\n`await request.body()` in FastAPI and Starlette. In Django, read it with\n`request.read()`, since `request.body` refuses a body over\n`DATA_UPLOAD_MAX_MEMORY_SIZE`, 2.5 MB by default, and an `inbound` event\nwith its content can be larger. In Flask, pass `dict(request.headers)` to\n`receive` below, since type checkers do not take Werkzeug's headers as a\n`Mapping`.\n\n```python\nimport os\nfrom collections.abc import Mapping\n\nfrom sendora import (\n BouncedEvent,\n InboundEvent,\n UnknownEvent,\n WebhookVerificationError,\n verify_webhook,\n)\n\n\ndef receive(body: bytes, headers: Mapping[str, str]) -> int:\n # Header names ignore case, and a plain dict does not.\n lowered = {name.lower(): value for name, value in headers.items()}\n try:\n event = verify_webhook(\n body,\n signature=lowered.get(\"sendora-signature\"),\n secret=os.environ[\"SENDORA_WEBHOOK_SECRET\"],\n )\n except WebhookVerificationError:\n return 400\n match event:\n case BouncedEvent(details=details) if details.hard:\n print(f\"{event.recipient} is undeliverable: {details.classification}\")\n case InboundEvent(content=content) if content is not None:\n print(content.subject, [part.name for part in content.attachments])\n case UnknownEvent():\n print(\"an event newer than this release:\", event.event, event.data)\n case _:\n pass\n return 204\n```\n\nTell the events apart with `isinstance` or a class pattern, as above;\ncomparing `event.event` with a string does not narrow the type. A signed\nevent this release cannot read, one newer than it, is an `UnknownEvent`\nwith its payload as it came rather than an error, so your endpoint does\nnot fail it until it is dead; upgrade to read it as its class.\n\nThe signature is `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`,\none `v1` per live secret of the webhook, each an HMAC-SHA256 with that\nsecret over `<t>.<raw body>`, compared in constant time; the one your\nsecret produces is enough. To roll a secret, create the new one with\n`webhooks.create_secret`, move your receiver to it while both sign every\ndelivery, then delete the old one. A signature older than five minutes is\nrefused (`stale_signature`; `tolerance=` changes the limit), a wrong one\nraises `invalid_signature`, and a missing secret raises `ValueError`.\n\n## The account: servers, keys, domains and erasures\n\nAn account key (`ak_…`) manages the account and never sends. An\nadministrator creates one in the dashboard under Account › API keys; the\naccount holds up to two live ones. `SendoraAccount` takes the same\narguments as `Sendora`.\n\n```python\nfrom sendora import Sendora, SendoraAccount\n\naccount = SendoraAccount() # the key from SENDORA_ACCOUNT_TOKEN\n\nserver = account.servers.create(name=\"Fakturering\")\nsendora = Sendora(server.token.token)\n```\n\nA server is created with its default transactional stream and its first\nkey, whose value is in the answer this once. A server holds up to two\nlive keys and never falls below one, so rotation is create the new key,\nswitch to it, revoke the old one.\n\n| Call | Answers |\n| ---------------------------------------------------- | ------------------------------------------------------------------------------------ |\n| `account.servers.create(name=, mode=)` | `CreatedServer` with its first key as `token`, once; a taken name is `server_exists` |\n| `account.servers.list()` | `ServerList` with `servers`, oldest first |\n| `account.servers.get(server_id)` | One server |\n| `account.servers.update(server_id, name=)` | The renamed server |\n| `account.servers.delete(server_id)` | `None`; `server_in_flight` while its messages are still being delivered |\n| `account.servers.tokens.create(server_id, name=)` | `CreatedToken` with its `token` value, once; a third live key is `token_limit` |\n| `account.servers.tokens.list(server_id)` | `TokenList` with `tokens`, revoked ones included |\n| `account.servers.tokens.get(server_id, token_id)` | One key, without its value |\n| `account.servers.tokens.revoke(server_id, token_id)` | `None`; the last live key is refused (`last_token`) |\n\nSending domains belong to the account, and every server sends from them.\nA domain sends once both DNS records are seen. A lookup that fails at the\nresolver (`dns_error`) changes nothing, and a verified domain whose record\ngoes missing keeps sending for 24 hours while the account's administrators\nare told, with `failing_since` saying when that began.\n\n| Call | Answers |\n| ----------------------------------- | ---------------------------------------------------------------------------- |\n| `account.domains.create(domain=)` | `SendingDomain` with `return_path` (CNAME) and `dkim` (TXT) to add |\n| `account.domains.list()` | `DomainList` with `domains` |\n| `account.domains.get(domain_id)` | One domain with the state of its records |\n| `account.domains.verify(domain_id)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `account.domains.delete(domain_id)` | `None`; mail from it is refused from then on |\n\n```python\ndomain = account.domains.create(domain=\"example.se\")\nprint(domain.return_path.type, domain.return_path.host, domain.return_path.value)\nprint(domain.dkim.type, domain.dkim.host, domain.dkim.value)\n```\n\nA tracking domain is the host that tracked links and the open pixel use: a\nsubdomain of a verified sending domain, with one CNAME record to add. CAA\nrecords above it must let Let's Encrypt issue its certificate. Tracking is\nnot open to accounts yet, so a host carries no mail until it is. A host\nwhose records are not right within 7 days goes.\n\n| Call | Answers |\n| ----------------------------------------------------- | --------------------------------------------------------------------- |\n| `account.tracking_domains.create(host=)` | `TrackingDomain` with `cname` to add |\n| `account.tracking_domains.list()` | `TrackingDomainList` with `tracking_domains` |\n| `account.tracking_domains.get(tracking_domain_id)` | One tracking domain with what the last check found |\n| `account.tracking_domains.verify(tracking_domain_id)` | The tracking domain as it stands: `state`, `cname.check`, `caa.check` |\n| `account.tracking_domains.delete(tracking_domain_id)` | `None`; new mail stops using it at once |\n\n```python\ntracking = account.tracking_domains.create(host=\"links.example.se\")\nprint(tracking.cname.type, tracking.cname.host, tracking.cname.value)\n```\n\nAn erasure removes one person's data from every server of the account,\nas a request under the right to erasure asks. The content of every\nmessage to or from the address, sent and received, is deleted, and the\naddress becomes a pseudonym in the message log. The erasure answers\n`pending`, and Sendora carries it out within a minute. Erasing an address\nagain changes nothing more, and later mail to or from it is sent and\nreceived as before.\n\n| Call | Answers |\n| ----------------------------------- | ------------------------------------------------------------------------------------------ |\n| `account.erasures.create(address=)` | `Erasure`, `pending` |\n| `account.erasures.get(erasure_id)` | The erasure; once `done`, its `pseudonym`, `messages` and `received_messages`, for 30 days |\n\n```python\nerasure = account.erasures.create(address=\"anna@example.com\")\nprint(account.erasures.get(erasure.erasure_id).status)\n```\n\nA key of the wrong kind raises `wrong_token_kind`: a server key on the\naccount's routes, or an account key on a server's.\n\n## Errors\n\nEvery failed call raises a `SendoraError`, whose class says what happened\nand whose `code` says why:\n\n| Class | When |\n| -------------------------- | ---------------------------------------------------------- |\n| `APIConnectionError` | No answer: `connection_failed` |\n| `APITimeoutError` | A step took longer than `timeout`: `timeout` |\n| `BadRequestError` | 400 |\n| `AuthenticationError` | 401 |\n| `PermissionDeniedError` | 403 |\n| `NotFoundError` | 404 |\n| `ConflictError` | 409 |\n| `UnprocessableEntityError` | 422 |\n| `RateLimitError` | 429, `rate_limited` and `monthly_cap_reached` alike |\n| `InternalServerError` | 500 or more |\n| `APIStatusError` | Any other status, and the base of the classes for a status |\n| `ResponseValidationError` | An answer the SDK could not read; the call took effect |\n| `WebhookVerificationError` | `verify_webhook`: `invalid_signature` or `stale_signature` |\n\nA path id that is empty, `.` or `..` raises `ValueError` before any\nrequest, since the URL would reach another route.\n\n```python\nfrom sendora import RateLimitError, SendoraError\n\ntry:\n sendora.email.send(**message)\nexcept RateLimitError as error:\n print(\"try again in\", error.retry_after, \"seconds\")\nexcept SendoraError as error:\n if error.code != \"recipient_suppressed\":\n raise\n print([(entry.address, entry.reason) for entry in error.suppressed])\n```\n\n| Code | Status | Meaning |\n| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------- |\n| `invalid_request` | 400 | A field is wrong; `issues` says which. |\n| `idempotency_key_required` | 400 | A batch was sent without a key. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `wrong_token_kind` | 403 | A server key where an account key is needed, or the reverse; `message` names the kind. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `account_paused` | 403 | Sendora has paused the account. |\n| `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |\n| `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |\n| `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |\n| `not_found` | 404 | No such id on this server, or no such suppressed address. |\n| `domain_exists` | 409 | The account already has the domain; `existing_id` names it. |\n| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |\n| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existing_id` names it. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself; choose a subdomain. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |\n| `tracking_host_taken` | 409 | Another account holds the host. |\n| `inbound_domain_exists` | 409 | The stream already has a domain (`existing_id` names it), or another account holds the name verified. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL; `existing_id` names it. |\n| `stream_exists` | 409 | The server already has a stream with that name; `existing_id` names it. |\n| `inbound_stream_exists` | 409 | The server already has a live inbound stream; `existing_id` names it. |\n| `default_stream` | 409 | The default stream cannot be archived. |\n| `last_token` | 409 | The only live key cannot be revoked. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted. |\n| `secret_limit` | 409 | The webhook already holds two live secrets; `max` says how many. |\n| `token_limit` | 409 | The server or the account already holds two live keys; `max` says how many. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_limit` | 409 | The account has as many servers as it may; `max` says how many. |\n| `server_reserved` | 409 | The server sends Sendora's own sign-in mail and cannot be renamed or removed. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; delete it later. |\n| `request_too_large` | 413 | The body exceeds 10 MB, or 64 MB for a broadcast. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain. |\n| `stream_not_found` | 422 | `stream_id` names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora paused the stream after complaints; support resumes it. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no list. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; this one is transactional. |\n| `substitution_missing` | 422 | The content names a `{{ key }}` a message does not give; `index` and `keys` say which. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled; nothing is left to cancel. |\n| `unsubscribe_placeholder_missing` | 422 | A broadcast message lacks `{{ unsubscribe_url }}` in a part. |\n| `list_unsubscribe_reserved` | 422 | Sendora writes List-Unsubscribe on broadcast streams; leave it out. |\n| `recipient_suppressed` | 422 | Recipients on the suppression list; `suppressed` lists them. |\n| `test_address_on_live_server` | 422 | Addresses at simulator.sendora.se are for test servers; `addresses` lists them. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `mode_immutable` | 400 | A server's mode is fixed at creation; create a new server for the other mode. |\n| `idempotency_key_mismatch` | 422 | The key was used before with a different body. |\n| `content_expired` | 410 | The received message's content window has passed; the message itself stays. |\n| `content_unreadable` | 409 | The received message's stored content cannot be opened; Sendora has been told. |\n| `rate_limited` | 429 | Per-minute limit; `retry_after`, `scope`, `limit`. From the edge, too many requests at once. |\n| `monthly_cap_reached` | 429 | The monthly cap is used up; `scope`, `cap`, `used`, `resets_at`. |\n| `sending_disabled` | 503 | Sending is paused for everyone; `retry_after`. |\n| `connection_failed` | none | The request did not reach the API, or its answer was lost; `__cause__` says why. |\n| `timeout` | none | A step of the request took longer than `timeout`. |\n| `unexpected_response` | any | An answer without a JSON error body, such as from a proxy. |\n| `invalid_signature` | none | A webhook's signature is missing, malformed or wrong. |\n| `stale_signature` | none | A webhook's signature is older than the tolerance. |\n\n`error.retryable` is true when waiting and calling again could succeed.\nNeither the key nor a request's subject, text, HTML or attachments is ever\npart of an error, its message, its `repr` or its pickled form, and an\nerror survives `pickle`, so it crosses a process pool as its own class. A\ncode newer than this release comes through as its string, its class\nchosen by the status.\n\n## New values and fields\n\nThe API may add a value to a set the SDK knows, such as a recipient\nstatus, before your release does. The SDK hands it over as its string\nrather than failing the call, so a `match` or an `if` chain over such a\nfield keeps a `case _:` or an `else`, and never `assert_never`. A field\nnewer than your release is not an attribute yet, but every answer keeps\nwhat the API sent: `answer.to_dict()` answers it as it came, with the\nAPI's camelCase keys, and `answer.to_json()` its text.\n\n## Retries and timeouts\n\nLost connections, timeouts, `rate_limited` (the edge's own included),\n`sending_disabled` and server errors are retried with jittered backoff,\ntwice by default, honouring `retry_after` when it fits within five\nseconds of waiting in all. A longer `retry_after` is raised at once for\nyou to schedule. A call that creates something (`streams.create`,\n`tokens.create`, `webhooks.create`, `webhooks.create_secret`,\n`webhooks.replay`, `inbound_domains.create`, `servers.create`,\n`servers.tokens.create`, `domains.create`, `tracking_domains.create`) is repeated only after a\n`rate_limited` or `sending_disabled` answer, never after a lost connection\nor a server error, so nothing is created twice. Nothing else is retried.\n\n`timeout` is in seconds, 30 by default, and bounds each step of a\nrequest: connecting, a read, a write and waiting for a connection.\n`max_retries=0` turns retries off. `with_options` answers a copy of the\nclient that shares its connections, for one call that may take longer or\na job that must not retry:\n\n```python\nfrom sendora import Sendora\n\nsendora = Sendora(timeout=10, max_retries=3)\npatient = sendora.with_options(timeout=120, max_retries=0)\n```\n\n## Async\n\n`AsyncSendora` and `AsyncSendoraAccount` have the same methods as\ncoroutines, and their iterators are async iterators. They run under\nasyncio, as FastAPI, Starlette and Django do; under another event loop,\nsuch as trio, the first call raises `TypeError`. Wrap a call in\n`asyncio.timeout()` to bound it as a whole; cancelling a task cancels its\nrequest.\n\n```python\nimport asyncio\n\nfrom sendora import AsyncSendora\n\n\nasync def main() -> None:\n async with AsyncSendora() as sendora:\n accepted = await sendora.email.send(\n from_=\"no-reply@example.se\",\n to=[\"anna@example.com\"],\n subject=\"Välkommen\",\n text=\"Hej Anna!\",\n tag=\"welcome\",\n )\n print(accepted.message_id)\n async for message in sendora.messages.search_all(tag=\"welcome\"):\n print(message.message_id, message.subject)\n\n\nasyncio.run(main())\n```\n\n## Your own HTTP client\n\nPass an `httpx2.Client` of your own, an `httpx2.AsyncClient` to the async\nclients, for a proxy, a private certificate authority or your own\nconnection limits. The SDK takes its connections, proxy and TLS settings\nand nothing else: the key, the timeout and the SDK's headers go on every\nrequest, redirects are never followed, and the SDK never closes a client\nyou gave it. Certificates are verified against the operating system's\nstore; a container without one needs its `ca-certificates` package or\n`SSL_CERT_FILE`.\n\n```python\nimport httpx2\n\nfrom sendora import Sendora\n\nhttp_client = httpx2.Client(proxy=\"http://proxy.example.se:3128\")\nsendora = Sendora(http_client=http_client)\n```\n\n## Testing your code\n\nFor tests that talk to Sendora, create a test server: it goes through\neverything a live one does, validation, the suppression list, the log and\nthe webhooks, but nothing it accepts is delivered. Its keys start\n`sk_test_`, and `is_test_key` tells them apart, so a production process\ncan refuse one at startup. Addresses at `simulator.sendora.se`\n(`hardbounce@`, `softbounce@`, `deferred@`, `complaint@`, with any\n`+label`) produce those outcomes; any other address is marked delivered.\nA server's mode is fixed when it is created, so going live means a new\nserver and a new key. Test mail is free and counts against its own\nmonthly cap, shared by the account's test servers.\n\n```python\nimport os\n\nfrom sendora import Sendora, SendoraAccount, is_test_key\n\naccount = SendoraAccount()\nci = account.servers.create(name=\"CI\", mode=\"test\")\n\ntoken = os.environ[\"SENDORA_API_TOKEN\"]\nif os.environ.get(\"APP_ENV\") == \"production\" and is_test_key(token):\n raise RuntimeError(\"SENDORA_API_TOKEN is a test key; nothing would be delivered\")\nsendora = Sendora(token)\n```\n\nFor unit tests that must not reach the network, give the client an\n`httpx2.Client` over a `MockTransport` that answers as the API would:\n\n```python\nimport json\n\nimport httpx2\n\nfrom sendora import Sendora\n\n\ndef answer(request: httpx2.Request) -> httpx2.Response:\n assert request.url.path == \"/v1/email\"\n assert json.loads(request.content)[\"to\"] == [\"anna@example.com\"]\n return httpx2.Response(\n 200,\n json={\n \"messageId\": \"0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-26T10:00:00.000Z\",\n \"test\": False,\n },\n )\n\n\nmock = httpx2.Client(transport=httpx2.MockTransport(answer))\nsendora = Sendora(\"sk_unit_test\", http_client=mock)\naccepted = sendora.email.send(\n from_=\"no-reply@example.se\", to=[\"anna@example.com\"], subject=\"Hej\", text=\"Hej\"\n)\nassert accepted.status == \"accepted\"\n```\n\n## Logging\n\nThe SDK logs one thing: each retry, at `DEBUG` on the `sendora` logger,\nwith the method, the path, the code and the wait. httpx2 logs one `INFO`\nline per request with its URL on `httpx2`, and httpcore2 logs response\nheaders at `DEBUG` on `httpcore2`, an attachment's filename among them, so\nset both to `WARNING` in production:\n\n```python\nimport logging\n\nlogging.getLogger(\"httpx2\").setLevel(logging.WARNING)\nlogging.getLogger(\"httpcore2\").setLevel(logging.WARNING)\n```\n\nAn error tracker that records the local variables of each frame, as\nSentry does by default, may record the contents of a request from\nhttpx2's and httpcore2's frames when a connection fails while it is being\nwritten. The SDK's own frames hold neither the key nor the message. Turn\nlocal variables off (`include_local_variables=False` in Sentry) if a\nmessage's contents must not reach the tracker.\n\n## Tracing\n\nThe client sends every request through the `httpx2.Client` you give it,\nso its event hooks see each attempt, retries included, and can time it:\n\n```python\nimport logging\nimport time\n\nimport httpx2\n\nfrom sendora import Sendora\n\nlog = logging.getLogger(\"app.sendora\")\n\n\ndef started(request: httpx2.Request) -> None:\n request.extensions[\"started\"] = time.monotonic()\n\n\ndef finished(response: httpx2.Response) -> None:\n request = response.request\n took = time.monotonic() - float(request.extensions[\"started\"])\n log.info(\n \"%s %s %s %.3fs\", request.method, request.url.path, response.status_code, took\n )\n\n\ntraced = httpx2.Client(event_hooks={\"request\": [started], \"response\": [finished]})\nsendora = Sendora(http_client=traced)\n```\n\nRecord the path and not the whole URL: a search's query carries the\nrecipient address you filtered on.\n\n## For coding agents\n\nThe package ships `sendora/skills/sendora/SKILL.md`, a short guide in the\nagent skill format with the rules above; point your agent at it or at\nthis README. Every public class and method carries a docstring with an\nexample, and the package is typed for your editor and your type checker.\n\n## Versions\n\nThe package follows semantic versioning. A breaking change comes only in\na major version: a renamed or removed method, argument or field, or a\nchanged error code. A minor adds methods, fields, codes or events, and a\npatch fixes a bug. `CHANGELOG.md` lists what each release changed, as\ndoes the Changelog page of the docs.\n\n## Docs\n\nGuides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).\n"},{"uri":"sendora://sdk/python/skill","title":"Rules for agents using the Python SDK","description":"The skill file sendora ships for coding agents.","text":"---\nname: sendora\ndescription: Send transactional email through Sendora with the sendora Python package, read the message log, manage streams, suppressions, keys, webhooks, servers and sending domains, and receive signed webhooks. Use when a task mentions Sendora, the sendora package, or sending email from a system hosted in Sweden.\n---\n\n# Sendora\n\n`sendora` is the Python client for the Sendora API. It runs on Python 3.11\nor newer with one dependency, httpx2, and is typed throughout. The README\nof this package is the complete reference; every call, every error code\nand every event is in it.\n\n## Rules\n\n- Two kinds of key, two clients. A server key (`sk_…`) drives\n `Sendora()`: `email`, `broadcasts`, `messages`, `inbound`,\n `inbound_domains`, `server`, `streams`, `suppressions`, `tokens`,\n `webhooks`, everything inside one server. An account key (`ak_…`)\n drives `SendoraAccount()`: `servers`, `servers.tokens`, `domains` and\n `tracking_domains`, and it never sends. A key of the wrong kind raises `wrong_token_kind`.\n `AsyncSendora` and `AsyncSendoraAccount` are the same clients for\n asyncio; use them in async code and never call the regular ones there.\n- Every key is a secret. Leave `token` out and the clients read\n `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN` from the environment;\n never hard-code a key or send it to a browser. A client without a key\n raises `ValueError`, so construct one per process at startup and share\n it.\n- A server is created with its first key:\n `account.servers.create(name=...)` answers `token.token` once, and\n `Sendora(token)` sends with it. A server or the account holds at most\n two live keys, so rotate by creating the new key, switching to it, then\n revoking the old one; the last live key can never be revoked.\n- For tests and CI, use a test server:\n `account.servers.create(name=..., mode=\"test\")`. It accepts, logs and\n fires webhooks like a live one but delivers nothing; its keys start\n `sk_test_`, and `is_test_key(token)` tells them apart at startup.\n Addresses at `simulator.sendora.se` (`hardbounce@`, `softbounce@`,\n `deferred@`, `complaint@`) produce those outcomes on a test server and\n are refused on a live one (`test_address_on_live_server`). A server's\n mode never changes. For unit tests, pass `http_client=` an\n `httpx2.Client` over an `httpx2.MockTransport`.\n- Arguments are keyword arguments with snake_case names; ids of a path\n come first and positionally. `from` is `from_`. Do not build request\n bodies by hand or call httpx2 yourself.\n- Do not add idempotency keys or retry loops of your own. The SDK gives\n every send a key and repeats a call only when repeating is safe. Pass\n `idempotency_key=` only when a retry may come from another process.\n- Every failed call raises a `SendoraError`. Catch its subclass or narrow\n on `error.code`, and let anything else propagate. Never swallow it.\n- Answers are frozen dataclasses. A field that holds one of a set of\n values, such as a recipient's `status`, may hold a value newer than the\n release, so a `match` over it keeps a `case _:`; never `assert_never`.\n- A batch never raises for one bad message: check each of\n `send_batch(...).results` with `isinstance(result, RefusedBatchItem)`.\n- Delivery is asynchronous. `email.send` answers `accepted`; whether the\n receiver took the message is in `messages.get(message_id).recipients`\n and in the `delivered`, `bounced`, `deferred` and `spam_complaint`\n webhook events.\n- Verify a webhook before parsing it with `verify_webhook`, given the raw\n body exactly as it arrived, `signature=` the `Sendora-Signature` header\n and `secret=` the webhook's secret. Tell the events apart with `isinstance` or\n a class pattern (`case BouncedEvent(details=details):`), never by\n comparing `event.event`, and handle `UnknownEvent`, an event newer than\n the release. Answer 2xx quickly and do the work afterwards. Deliveries\n arrive at least once, so key handling on `event.id`.\n- Addresses on the suppression list are refused with\n `recipient_suppressed`; lift a hard bounce with\n `suppressions.delete(address=...)`; a spam complaint or an unsubscribe\n cannot be lifted by the customer. Lists are per stream: pass\n `stream_id=` when the send went on a stream other than the default.\n- `from_` must be on a verified sending domain of the account;\n `account.domains.create(domain=...)` answers the two DNS records to\n add, and `account.domains.verify` checks them now. A server key cannot\n manage domains, but `server.get()` names those a From may use\n (`sending_domains`). A verified domain whose record goes missing keeps\n sending for 24 hours (`failing_since`); a `dns_error` changes nothing.\n- A person who asks to be forgotten is erased with the account key:\n `account.erasures.create(address=...)` answers a pending erasure, and\n `account.erasures.get(erasure_id)` shows it `done` within a minute. It\n deletes their messages' content and puts a pseudonym in the log on every\n server; it blocks nothing.\n- A message goes on the server's default transactional stream unless it\n names another with `stream_id=`; `streams.create` adds one. Never make a\n stream per message or per customer; a stream is a kind of mail.\n- Mail to many goes as one broadcast: `broadcasts.send` on a broadcast\n stream, the content once and `messages=` with one entry per\n recipient, at most 50,000 entries; follow it with\n `broadcasts.get(broadcast_id)` and stop it with `broadcasts.cancel`.\n Personalise with `{{ key }}` in the content and `substitutions` on each\n entry. Never loop over `email.send` for a list.\n- A message on a broadcast stream must carry `{{ unsubscribe_url }}` in\n its text and its HTML; Sendora puts each recipient's own link there and\n writes the List-Unsubscribe headers itself. An `unsubscribed` event\n tells your system to stop; the address is then on that stream's list.\n\n## The shape of a call\n\n```python\nfrom sendora import Sendora, SendoraError\n\nsendora = Sendora()\n\ntry:\n accepted = sendora.email.send(\n from_={\"email\": \"no-reply@example.se\", \"name\": \"Example AB\"},\n to=[\"anna@example.com\"],\n subject=\"Din faktura för september\",\n text=\"Hej Anna, fakturan finns bifogad.\",\n tag=\"invoice\",\n )\nexcept SendoraError as error:\n if error.code == \"recipient_suppressed\":\n print([entry.address for entry in error.suppressed])\n raise\n```\n\n## Where to read more\n\n- The README of this package: every method, every error code, retries,\n webhooks and events, testing, logging and tracing.\n- The guides and the HTTP reference at https://sendora.se/docs.\n"},{"uri":"sendora://sdk/php/readme","title":"The PHP SDK","description":"sendora/sendora-php's README: every call, error code and webhook event.","text":"# sendora/sendora-php\n\nThe PHP client for [Sendora](https://sendora.se), transactional email\ndelivery. PHP 8.2 or newer, with ext-curl and ext-json and no other\npackage. Typed throughout: every argument is named and typed for your\neditor and PHPStan, and every answer is a readonly class.\n\n```sh\ncomposer require sendora/sendora-php\n```\n\nIn a Laravel app, install `sendora/sendora-laravel` instead. It installs\nthis package, sends Laravel's mail with `MAIL_MAILER=sendora`, and takes\nwebhooks on a route of its own, with a Laravel event for each.\n\nTwo kinds of key, two clients. `Sendora` takes a server key (`sk_…`) and\ndoes everything inside one server: sending, broadcasts, the log, received\nmail, streams, suppressions, tokens, webhooks and the server's own limits.\n`SendoraAccount` takes an account key (`ak_…`) and manages the account:\nits servers, their keys, its sending and tracking domains. Both keys are secrets:\nkeep them on the server and never ship them to a browser.\n\n```php\nuse Sendora\\Attachment;\nuse Sendora\\Sendora;\n\n$sendora = new Sendora(); // the key from SENDORA_API_TOKEN\n\n$accepted = $sendora->email->send(\n from: ['email' => 'no-reply@example.se', 'name' => 'Example AB'],\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n attachments: [Attachment::fromFile('faktura.pdf', contentType: 'application/pdf')],\n tag: 'invoice',\n metadata: ['invoice_id' => '2026-0912'],\n);\necho $accepted->messageId;\n```\n\nA client without a key throws `InvalidArgumentException` at construction,\nso build it at startup. `new Sendora('sk_…')` takes the key directly, and\n`new SendoraAccount()` reads `SENDORA_ACCOUNT_TOKEN`. The SDK reads a\nvariable from `$_SERVER`, then `$_ENV`, then `getenv()`, so a framework's\n`.env` loader works. PHP-FPM clears its workers' environment by default,\nso pass the key in, or set it in the pool with `env[SENDORA_API_TOKEN]`.\nMake one client per process and share it. It reuses its connection and\nneeds no closing. `var_dump()` and `print_r()` of a client show its\n`baseUrl`, never the key.\n\nBoth clients take the same arguments, each of them optional:\n\n| Argument | Default | What it is |\n| --------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- |\n| `token` | `SENDORA_API_TOKEN`, or `SENDORA_ACCOUNT_TOKEN` for `SendoraAccount` | The key |\n| `baseUrl` | `SENDORA_BASE_URL`, then `https://api.sendora.se` | The API's address: https, or http to a loopback address alone |\n| `timeout` | 30 | The seconds a whole attempt may take over curl |\n| `maxRetries` | 2 | How often a failed call is repeated; 0 turns retries off |\n| `httpClient` | curl | A PSR-18 client of your own |\n| `requestFactory`, `streamFactory` | The client itself, when it implements PSR-17 | The PSR-17 factories that build the client's requests |\n| `logger` | None | A PSR-3 logger, which records each retry |\n\n`from` must be on a verified sending domain. `to`, `cc` and `bcc` take\nplain addresses or `['email' => …, 'name' => …]` arrays, at most 50 in\nall, and at least one of `text` and `html` is required. `streamId` names\nthe stream the message goes on; without it the default transactional\nstream. An attachment is `Attachment::fromFile()` or\n`Attachment::fromBytes()`, or an array whose `content` is already base64.\n`fromFile()` names it after its file unless `name:` says otherwise.\n\nEvery method takes what its route takes, with the API's camelCase names,\nand returns what the route returns. A path's ids come first, by position.\nName every argument after them, as the examples do. Your own keys, in\n`metadata`, `headers` and `substitutions`, are sent as you wrote them,\nkeys of digits included.\n\n## Sending\n\n| Call | Returns |\n| --------------------------------------- | -------------------------------------------------------------------- |\n| `$sendora->email->send(...$message)` | `AcceptedEmail` with `messageId`, `status`, `submittedAt` and `test` |\n| `$sendora->email->sendBatch($messages)` | `BatchResult` with `results`, one per message in order; 100 at most |\n\n`accepted` means the message is stored and on its way to the mail server.\nDelivery, deferral, bounce and complaint arrive later as events on the\nmessage and as webhooks.\n\n`send()` also takes a message as an array with the same keys as its\narguments, spread with `...`, and so does each item of `sendBatch()`.\nPHPStan knows the array as `EmailMessage`: import it with\n`@phpstan-import-type EmailMessage from Email`.\n\n`idempotencyKey:` names the request so a retry cannot send twice. The SDK\nmakes a random UUID when you give none. Give your own when a retry may\ncome from another process, such as a queued job. The API remembers a key\nfor 24 hours and refuses it with a different body\n(`idempotency_key_mismatch`). An exception from a send carries the key it\nwent under as `$error->idempotencyKey`.\n\nA batch never throws for one refused message. Each result is an\n`AcceptedBatchItem` with the `messageId`, or a `RefusedBatchItem` with\nthe `errorCode` and fields that a single send would throw. Both have\n`index`, the message's place in the list, counted from 0. A retried batch\nreturns the items sent before with `replayed` set.\n\n```php\nuse Sendora\\Types\\RefusedBatchItem;\n\n$batch = $sendora->email->sendBatch($messages, idempotencyKey: 'invoices-2026-09');\nforeach ($batch->results as $result) {\n if ($result instanceof RefusedBatchItem) {\n echo $result->index, ' ', $result->errorCode, ' ', $result->message, \"\\n\";\n }\n}\n```\n\n## Messages\n\n| Call | Returns |\n| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |\n| `$sendora->messages->get($messageId)` | `MessageDetail`: the message with its recipients, `tracking`, its attachments described and its events |\n| `$sendora->messages->search(...$filters)` | `MessagePage` with `messages` and `next`, newest first |\n| `$sendora->messages->searchAll(...$filters)` | Every match, page by page, as a generator |\n| `$sendora->inbound->get($inboundMessageId)` | A received message: envelope, parties, headers, text, HTML, attachments described |\n| `$sendora->inbound->search(...$filters)` | `InboundPage` of received mail, newest first |\n| `$sendora->inbound->searchAll(...$filters)` | Every received match, page by page, as a generator |\n| `$sendora->inbound->raw($inboundMessageId)` | The raw message's bytes as a string, `message/rfc822` |\n| `$sendora->inbound->attachment($inboundMessageId, $attachmentId)` | An attachment's bytes as a string |\n\nA stream can also receive on a domain of yours: every address on it,\nwhatever the local part, lands on the stream. Claim it with the stream\nid, and add the two records the answer gives: an MX that names Sendora\nand a TXT record that proves the claim. Mail is accepted once both are\nseen. A stream holds one domain, and the first account to verify a name\nholds it.\n\n| Call | Returns |\n| ------------------------------------------------------ | ---------------------------------------------------------------------------- |\n| `$sendora->inboundDomains->create(streamId:, domain:)` | `InboundDomain` with `mx` and `txt` to add |\n| `$sendora->inboundDomains->list()` | `InboundDomainList` with `domains` |\n| `$sendora->inboundDomains->get($inboundDomainId)` | One domain with the state of its records |\n| `$sendora->inboundDomains->verify($inboundDomainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `$sendora->inboundDomains->delete($inboundDomainId)` | Nothing; mail to it is refused from then on |\n\nThe filters of `messages->search()` are `recipient`, a whole address or\nthree characters or more of one, and `streamId`, `broadcastId` and `tag`.\n`status` takes `queued`, `delivered`, `deferred`, `bounced` or `expired`,\nor a list of them. `from` and `to` take a `DateTimeInterface` or RFC 3339\ntext. `limit` is 1 to 100, 50 by default, and `after` is the previous\npage's `next`. Bodies are never returned, and the log keeps messages for\n13 months.\n\nWhere mail is tracked, each `MessageRecipient` has `tracked`, `opens`,\n`clicks`, `firstOpenAt` and `firstHumanOpenAt`. `messages->get()` adds\n`tracking`, and its timeline holds `Open` and `Click` events with their\nlabels. Tracking is not open to accounts yet.\n\nThe filters of `inbound->search()` are `streamId`, and `from`, the\nsender's address. `recipient` is the address of yours that the mail went\nto, and `mailboxHash` the text after a plus sign in it. `receivedFrom`\nand `receivedTo` take times, and `limit` and `after` work as above.\n\n```php\nuse Sendora\\Types\\MessageRecipient;\nuse Sendora\\Types\\RecipientStatus;\n\n$message = $sendora->messages->get($messageId);\n$waiting = array_filter(\n $message->recipients,\n fn(MessageRecipient $recipient): bool => $recipient->status !== RecipientStatus::Delivered->value,\n);\necho count($waiting), \" recipients not delivered yet\\n\";\n\nforeach ($sendora->messages->searchAll(status: 'bounced', from: new DateTimeImmutable('2026-09-01')) as $bounced) {\n echo $bounced->messageId, ' ', $bounced->subject, \"\\n\";\n}\n```\n\n## Streams\n\nEvery message goes on a stream of its server. A server starts with a\ndefault transactional stream, and the account adds more: transactional\nand broadcast ones, and one inbound stream, which receives mail at its\n`inboundAddress` instead of sending. Each sending stream has a\nsuppression list of its own.\n\n| Call | Returns |\n| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |\n| `$sendora->streams->create(kind:, name:)` | `Stream`; a taken name is `stream_exists`, a second inbound stream `inbound_stream_exists`, both with `existingId` |\n| `$sendora->streams->list()` | `StreamList` with `streams`, archived ones included |\n| `$sendora->streams->get($streamId)` | One stream |\n| `$sendora->streams->update($streamId, name:, contentRetentionDays:)` | The updated stream; the days apply to an inbound stream |\n| `$sendora->streams->archive($streamId)` | The stream with `archivedAt`; it takes no new messages from then on |\n\n`kind` takes a `StreamKind` or its value. `contentRetentionDays: null`\nputs an inbound stream back to the default of 30 days, while leaving the\nargument out leaves the days as they are.\n\n```php\nuse Sendora\\Types\\StreamKind;\n\n$stream = $sendora->streams->create(kind: StreamKind::Transactional, name: 'Aviseringar');\n$sendora->email->send(\n from: 'no-reply@example.se',\n to: ['anna@example.com'],\n subject: 'Leveransen är på väg',\n text: 'Hej Anna, paketet lämnade lagret i dag.',\n streamId: $stream->streamId,\n);\n```\n\n## Broadcasts\n\nOne message to many on a broadcast stream, sent as a whole: the content\nonce and one entry per message, up to 50,000 and 50 MB per broadcast.\nEvery message is a message in the log with its own events and webhooks,\ncarrying the `broadcastId`. Every recipient gets their own unsubscribe\nlink where `{{ unsubscribe_url }}` stands. Addresses on the stream's\nsuppression list are dropped and counted in `suppressed`. The SDK sets an\nidempotency key, so a retry returns the same broadcast.\n\nA message's `substitutions`, up to 20 strings, replace `{{ key }}` in\nthe subject, the text and the HTML, escaped in the HTML. A key that the\ncontent names and a message lacks throws `substitution_missing`.\n\n| Call | Returns |\n| -------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `$sendora->broadcasts->send(...$broadcast)` | `AcceptedBroadcast` with `broadcastId`, `total`, `suppressed` and `test` |\n| `$sendora->broadcasts->get($broadcastId)` | `BroadcastDetail` with `status`, `released`, `failed`, `opened`, `clicked` and `links` |\n| `$sendora->broadcasts->list(limit:, after:)` | `BroadcastPage` with `broadcasts` and `next`, newest first |\n| `$sendora->broadcasts->cancel($broadcastId)` | The cancelled broadcast; what had not left counts as failed |\n\n```php\n$accepted = $sendora->broadcasts->send(\n streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n from: ['email' => 'nyheter@example.se', 'name' => 'Example AB'],\n subject: 'Nyheter i oktober, {{ first_name }}',\n messages: [\n ['to' => ['anna@example.com'], 'substitutions' => ['first_name' => 'Anna']],\n ['to' => ['bo@example.com'], 'substitutions' => ['first_name' => 'Bo']],\n ],\n text: 'Hej! Här är månadens nyheter. Vill du inte ha fler? {{ unsubscribe_url }}',\n);\n$progress = $sendora->broadcasts->get($accepted->broadcastId);\n```\n\n## Suppressions\n\nAddresses the server no longer sends to: hard bounces, spam complaints,\nunsubscribes and entries added by hand. A send to one of them throws\n`recipient_suppressed`.\n\n| Call | Returns |\n| --------------------------------------------------------- | ------------------------------------------------ |\n| `$sendora->suppressions->list(streamId:, limit:, after:)` | `SuppressionPage` with `suppressions` and `next` |\n| `$sendora->suppressions->listAll(streamId:)` | Every entry, as a generator |\n| `$sendora->suppressions->delete(address:, streamId:)` | Nothing; the address may be sent to again |\n\nEvery stream has a list of its own. Without `streamId`, the calls mean\nthe default transactional stream. A spam complaint or a recipient's own\nunsubscribe cannot be lifted this way (`spam_complaint_locked`,\n`unsubscribe_locked`). Only Sendora support can, at the recipient's own\nrequest. A refused send carries `streamId` beside `suppressed`.\n\n```php\n$sendora->suppressions->delete(address: 'anna@example.com');\n```\n\n## Tokens\n\nThe server's own keys, from its own key. Every key of a server has the\nsame rights, and its value is shown once, when it is created. A server\nholds up to two live keys, and a third is `token_limit`. The account key\nmanages the same keys under `$account->servers->tokens`.\n\n| Call | Returns |\n| ------------------------------------ | ---------------------------------------------------- |\n| `$sendora->tokens->create(name:)` | `CreatedToken` with its `token` value, once |\n| `$sendora->tokens->list()` | `TokenList` with `tokens`, revoked ones included |\n| `$sendora->tokens->get($tokenId)` | One token, without its value |\n| `$sendora->tokens->revoke($tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\nA key's value is kept out of `var_dump()`, `print_r()` and `json_encode()`\nof the answer, so a logged answer does not show it. Read it from\n`->token`. `toArray()`, `var_export()` and Symfony's `dump()`, which\nLaravel's `dd()` uses, still show it. A webhook's secret is kept the same\nway.\n\n## The server\n\nThe key's own server: its name and id, and whether it is live or a test\nserver. It lists every limit a send on it is held to, with how much of\neach this minute and this month have used. It also lists the account's\nsending domains a From address may use.\n\n`inboundCap` is the account's monthly cap on received mail, apart from\nthe limits on sending, with its `limit`, `used` and `resetsAt`. Past it,\nmail to the account is refused for now, so senders retry. It is `null` on\na test server, which receives no mail.\n\n| Call | Returns |\n| ------------------------- | ------------------------------------------------------------------------------------------------------------------- |\n| `$sendora->server->get()` | `OwnServer` with `mode`, `limits`, each with `used` and `resetsAt`, `inboundCap`, and `sendingDomains` to send from |\n\n```php\n$own = $sendora->server->get();\nforeach ($own->limits as $limit) {\n echo $limit->scope, ' ', $limit->period, ' ', $limit->used, ' of ', $limit->limit, \"\\n\";\n}\nforeach ($own->sendingDomains as $sending) {\n echo $sending->domain, \"\\n\";\n}\n```\n\n## Webhooks\n\nAn https URL of yours that Sendora posts events to as they happen, signed\nwith a secret shown once at creation. A webhook holds up to two live\nsecrets, so a new one can be rolled in beside the old one. Every delivery\nis then signed with each, the newest first, and the old secret is deleted\nonce your receiver has switched.\n\n| Call | Returns |\n| ----------------------------------------------------------------------- | --------------------------------------------- |\n| `$sendora->webhooks->create(url:, events:, streamId:, inboundContent:)` | `CreatedWebhook` with its `secret`, once |\n| `$sendora->webhooks->list()` | `WebhookList` with `webhooks` |\n| `$sendora->webhooks->get($webhookId)` | One webhook |\n| `$sendora->webhooks->delete($webhookId)` | Nothing; pending deliveries are dropped |\n| `$sendora->webhooks->deliveries($webhookId, status:, limit:, after:)` | `DeliveryPage` with `deliveries` and `next` |\n| `$sendora->webhooks->deliveriesAll($webhookId, status:)` | Every delivery, as a generator |\n| `$sendora->webhooks->replay($webhookId, $deliveryId)` | The delivery, queued again |\n| `$sendora->webhooks->createSecret($webhookId)` | A second secret with its `secret` value, once |\n| `$sendora->webhooks->deleteSecret($webhookId, $secretId)` | Nothing; the last live secret is refused |\n\nEvents about a message are `delivered`, `bounced`, `deferred`,\n`spam_complaint` and `unsubscribed`. Each has `messageId`, `streamId`,\n`recipient`, `occurredAt`, `serverId`, your `tag` and `metadata`, and\n`details` from the receiver, or for `unsubscribed` the `source` (`link`\nor `one_click`). `cap_warning` and `cap_reached` are about usage, with\n`scope`, `cap`, `used` and the period.\n\n`inbound` is about a message received on an inbound stream. It has\n`inboundMessageId`, `envelopeRecipient`, the sizes and the\n`authentication` verdicts. For a webhook with `inboundContent: 'full'`,\nthe default, it has the message itself under `content`: `from`, `to`,\n`subject`, `text`, `html`, `attachments` and the rest.\n`inbound_cap_warning` and `inbound_cap_reached` say that received mail\nnears or reaches the account's monthly cap, with `cap`, `used` and the\nperiod. Every event carries `id`, the delivery id, and `attempt`.\n\nA failed delivery is retried with growing delays for about a day, then it\nis `dead` until you replay it. Deliveries arrive at least once and not\nalways in order, so key your handling on `$event->id`.\n\n### Receiving\n\nVerify the signature before you parse anything, answer 2xx quickly, and\ndo the work afterwards. Under PHP-FPM, `fastcgi_finish_request()` sends\nthe answer and lets the work go on; elsewhere, hand the work to a queued\njob. `WebhookVerifier::verify()` takes the raw body exactly as it arrived\nand the `Sendora-Signature` header. It returns the event as a class of\nits own.\n\n- The raw body is `file_get_contents('php://input')` in plain PHP and\n `$request->getContent()` in Symfony and Laravel.\n- The header is `$_SERVER['HTTP_SENDORA_SIGNATURE']` in plain PHP and\n `$request->headers->get('Sendora-Signature')` in Symfony and Laravel.\n- An `inbound` event with its content can take a few megabytes, more than\n nginx's default `client_max_body_size` of 1 MB. Raise it for the\n webhook's route.\n\n```php\nuse Sendora\\Events\\BouncedEvent;\nuse Sendora\\Events\\InboundEvent;\nuse Sendora\\Events\\UnknownEvent;\nuse Sendora\\Exception\\WebhookVerificationException;\nuse Sendora\\WebhookVerifier;\n\n$signature = $_SERVER['HTTP_SENDORA_SIGNATURE'] ?? null;\n\ntry {\n $event = WebhookVerifier::verify(\n (string) file_get_contents('php://input'),\n signature: is_string($signature) ? $signature : null,\n secret: (string) getenv('SENDORA_WEBHOOK_SECRET'),\n );\n} catch (WebhookVerificationException) {\n http_response_code(400);\n exit;\n}\n\nif ($event instanceof BouncedEvent && $event->details->hard) {\n error_log(\"{$event->messageId} bounced for good: {$event->details->classification}\");\n} elseif ($event instanceof InboundEvent && $event->content !== null) {\n error_log(\"{$event->inboundMessageId} arrived with {$event->attachmentCount} attachments\");\n} elseif ($event instanceof UnknownEvent) {\n error_log(\"An event newer than this release: {$event->event}\");\n}\nhttp_response_code(204);\n```\n\nTell the events apart with `instanceof`, as above. Comparing\n`$event->event` with a string does not narrow the type. Each class is in\n`Sendora\\Events` and extends `WebhookEvent`, which has `id`, `attempt`\nand `event`.\n\n| `event` | Class |\n| --------------------- | ------------------------ |\n| `delivered` | `DeliveredEvent` |\n| `bounced` | `BouncedEvent` |\n| `deferred` | `DeferredEvent` |\n| `spam_complaint` | `SpamComplaintEvent` |\n| `unsubscribed` | `UnsubscribedEvent` |\n| `cap_warning` | `CapWarningEvent` |\n| `cap_reached` | `CapReachedEvent` |\n| `inbound` | `InboundEvent` |\n| `inbound_cap_warning` | `InboundCapWarningEvent` |\n| `inbound_cap_reached` | `InboundCapReachedEvent` |\n| any other | `UnknownEvent` |\n\nA signed event this release cannot read, one newer than it, is an\n`UnknownEvent` with its payload in `data`, rather than an error. So your\nendpoint can still answer it with 2xx, rather than fail it until Sendora\ngives up. Upgrade to read it as its class.\n\nThe signature is `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`,\none `v1` per live secret of the webhook. Each is an HMAC-SHA256 with that\nsecret over `<t>.<raw body>`, compared in constant time, and the one your\nsecret produces is enough. To roll a secret, create the new one with\n`webhooks->createSecret()`, and move your receiver to it while both sign\nevery delivery. Then delete the old one. A signature older than five\nminutes is refused (`stale_signature`), and `tolerance:` changes the\nlimit. A wrong one throws `invalid_signature`, and a missing or empty\nsecret throws `InvalidArgumentException`.\n\n## The account: servers, keys, domains and erasures\n\nAn account key (`ak_…`) manages the account and never sends. An\nadministrator creates one in the dashboard under Account › API keys, and\nthe account holds up to two live ones. `SendoraAccount` takes the same\narguments as `Sendora`.\n\n```php\nuse Sendora\\Sendora;\nuse Sendora\\SendoraAccount;\n\n$account = new SendoraAccount(); // the key from SENDORA_ACCOUNT_TOKEN\n\n$server = $account->servers->create(name: 'Fakturering');\n$sendora = new Sendora($server->token->token);\n```\n\nA server is created with its default transactional stream and its first\nkey, whose value is in the answer this once. A server holds up to two\nlive keys and never falls below one. So to rotate a key, create the new\none, switch to it, and revoke the old one.\n\n| Call | Returns |\n| -------------------------------------------------------- | ------------------------------------------------------------------------------------ |\n| `$account->servers->create(name:, mode:)` | `CreatedServer` with its first key as `token`, once; a taken name is `server_exists` |\n| `$account->servers->list()` | `ServerList` with `servers`, oldest first |\n| `$account->servers->get($serverId)` | One server |\n| `$account->servers->update($serverId, name:)` | The renamed server |\n| `$account->servers->delete($serverId)` | Nothing; `server_in_flight` while its messages are still being delivered |\n| `$account->servers->tokens->create($serverId, name:)` | `CreatedToken` with its `token` value, once; a third live key is `token_limit` |\n| `$account->servers->tokens->list($serverId)` | `TokenList` with `tokens`, revoked ones included |\n| `$account->servers->tokens->get($serverId, $tokenId)` | One key, without its value |\n| `$account->servers->tokens->revoke($serverId, $tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\nSending domains belong to the account, and every server sends from them.\nA domain sends once both DNS records are seen. A lookup that fails at the\nresolver (`dns_error`) changes nothing. A verified domain whose record\ngoes missing keeps sending for 24 hours while the account's\nadministrators are told, and `failingSince` says when that began.\n\n| Call | Returns |\n| -------------------------------------- | ---------------------------------------------------------------------------- |\n| `$account->domains->create(domain:)` | `SendingDomain` with `returnPath` (CNAME) and `dkim` (TXT) to add |\n| `$account->domains->list()` | `DomainList` with `domains` |\n| `$account->domains->get($domainId)` | One domain with the state of its records |\n| `$account->domains->verify($domainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `$account->domains->delete($domainId)` | Nothing; mail from it is refused from then on |\n\n```php\n$domain = $account->domains->create(domain: 'example.se');\necho $domain->returnPath->type, ' ', $domain->returnPath->host, ' ', $domain->returnPath->value, \"\\n\";\necho $domain->dkim->type, ' ', $domain->dkim->host, ' ', $domain->dkim->value, \"\\n\";\n```\n\nA tracking domain is the host that tracked links and the open pixel use: a\nsubdomain of a verified sending domain, with one CNAME record to add. CAA\nrecords above it must let Let's Encrypt issue its certificate. Tracking is\nnot open to accounts yet, so a host carries no mail until it is. A host\nwhose records are not right within 7 days goes.\n\n| Call | Answers |\n| ------------------------------------------------------ | ----------------------------------------------------------------------- |\n| `$account->trackingDomains->create(host:)` | `TrackingDomain` with `cname` to add |\n| `$account->trackingDomains->list()` | `TrackingDomainList` with `trackingDomains` |\n| `$account->trackingDomains->get($trackingDomainId)` | One tracking domain with what the last check found |\n| `$account->trackingDomains->verify($trackingDomainId)` | The tracking domain as it stands: `state`, `cname->check`, `caa->check` |\n| `$account->trackingDomains->delete($trackingDomainId)` | Nothing; new mail stops using it at once |\n\n```php\n$tracking = $account->trackingDomains->create(host: 'links.example.se');\necho $tracking->cname->type, ' ', $tracking->cname->host, ' ', $tracking->cname->value, \"\\n\";\n```\n\nAn erasure removes one person's data from every server of the account,\nas a request under the right to erasure asks. The content of every\nmessage to or from the address, sent and received, is deleted, and the\naddress becomes a pseudonym in the message log. The erasure is\n`pending`, and Sendora carries it out within a minute. Erasing an address\nagain changes nothing more, and later mail to or from it is sent and\nreceived as before.\n\n| Call | Returns |\n| -------------------------------------- | ----------------------------------------------------------------------------------------- |\n| `$account->erasures->create(address:)` | `Erasure`, `pending` |\n| `$account->erasures->get($erasureId)` | The erasure; once `done`, its `pseudonym`, `messages` and `receivedMessages`, for 30 days |\n\n```php\n$erasure = $account->erasures->create(address: 'anna@example.com');\necho $account->erasures->get($erasure->erasureId)->status, \"\\n\";\n```\n\nA key of the wrong kind throws `wrong_token_kind`: a server key on the\naccount's routes, or an account key on a server's.\n\n## Errors\n\nEvery failed call throws a `SendoraException`, which extends\n`RuntimeException`. Its class says what happened and its `errorCode` says\nwhy. Every class is in `Sendora\\Exception`.\n\n| Class | When |\n| ------------------------------ | --------------------------------------------------------------------- |\n| `ApiConnectionException` | No answer: `connection_failed` |\n| `ApiTimeoutException` | An attempt took longer than `timeout`: `timeout` |\n| `BadRequestException` | 400 |\n| `AuthenticationException` | 401 |\n| `PermissionDeniedException` | 403 |\n| `NotFoundException` | 404 |\n| `ConflictException` | 409 |\n| `UnprocessableEntityException` | 422 |\n| `RateLimitException` | 429, `rate_limited` and `monthly_cap_reached` alike |\n| `InternalServerException` | 500 or more |\n| `ApiStatusException` | Any other status, and the base of the classes for a status |\n| `ResponseValidationException` | An answer the SDK could not read; the call took effect |\n| `WebhookVerificationException` | `WebhookVerifier::verify()`: `invalid_signature` or `stale_signature` |\n\n`ApiTimeoutException` extends `ApiConnectionException`. `getCode()`\nreturns the HTTP status, or 0 when no answer came, and `getMessage()`\nthe API's message.\n\nA bad argument throws the SDK's `InvalidArgumentException` before any\nrequest: a key with a character no key has, a string that is not UTF-8,\nor a path id that is empty, `.` or `..`. It extends PHP's own, and is no\n`SendoraException`, since nothing was sent. Both implement\n`ExceptionInterface`, so `catch (ExceptionInterface $error)` catches\neverything the SDK throws.\n\n```php\nuse Sendora\\Exception\\RateLimitException;\nuse Sendora\\Exception\\SendoraException;\nuse Sendora\\Types\\ErrorCode;\n\ntry {\n $sendora->email->send(...$message);\n} catch (RateLimitException $error) {\n if ($error->resetsAt !== null) {\n echo 'The monthly cap is used up until ', $error->resetsAt->format('Y-m-d H:i'), \" UTC\\n\";\n } else {\n echo 'Try again in ', $error->retryAfter ?? 60, \" seconds\\n\";\n }\n} catch (SendoraException $error) {\n if ($error->errorCode !== ErrorCode::RecipientSuppressed->value) {\n throw $error;\n }\n foreach ($error->suppressed as $entry) {\n echo $entry->address, ' ', $entry->reason, \"\\n\";\n }\n}\n```\n\n| Code | Status | Meaning |\n| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------- |\n| `invalid_request` | 400 | A field is wrong; `issues` says which. |\n| `idempotency_key_required` | 400 | A batch was sent without a key, which the SDK always sends. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `wrong_token_kind` | 403 | A server key where an account key is needed, or the reverse; `message` names the kind. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `account_paused` | 403 | Sendora has paused the account. |\n| `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |\n| `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |\n| `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |\n| `not_found` | 404 | No such id on this server, or no such suppressed address. |\n| `domain_exists` | 409 | The account already has the domain; `existingId` names it. |\n| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |\n| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existingId` names it. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself; choose a subdomain. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |\n| `tracking_host_taken` | 409 | Another account holds the host. |\n| `inbound_domain_exists` | 409 | The stream already has a domain (`existingId` names it), or another account holds the name verified. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL; `existingId` names it. |\n| `stream_exists` | 409 | The server already has a stream with that name; `existingId` names it. |\n| `inbound_stream_exists` | 409 | The server already has a live inbound stream; `existingId` names it. |\n| `default_stream` | 409 | The default stream cannot be archived. |\n| `last_token` | 409 | The only live key cannot be revoked. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted. |\n| `secret_limit` | 409 | The webhook already holds two live secrets; `max` says how many. |\n| `token_limit` | 409 | The server or the account already holds two live keys; `max` says how many. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_limit` | 409 | The account has as many servers as it may; `max` says how many. |\n| `server_reserved` | 409 | The server sends Sendora's own sign-in mail and cannot be renamed or removed. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; delete it later. |\n| `request_too_large` | 413 | The body exceeds 10 MB, or 64 MB for a broadcast. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain. |\n| `stream_not_found` | 422 | `streamId` names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora paused the stream after complaints; support resumes it. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no list. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; this one is transactional. |\n| `substitution_missing` | 422 | The content names a `{{ key }}` a message does not give; `index` and `keys` say which. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled; nothing is left to cancel. |\n| `unsubscribe_placeholder_missing` | 422 | A broadcast message lacks `{{ unsubscribe_url }}` in a part. |\n| `list_unsubscribe_reserved` | 422 | Sendora writes List-Unsubscribe on broadcast streams; leave it out. |\n| `recipient_suppressed` | 422 | Recipients on the suppression list; `suppressed` lists them. |\n| `test_address_on_live_server` | 422 | Addresses at simulator.sendora.se are for test servers; `addresses` lists them. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `mode_immutable` | 400 | A server's mode is fixed at creation; create a new server for the other mode. |\n| `idempotency_key_mismatch` | 422 | The key was used before with a different body. |\n| `content_expired` | 410 | The received message's content window has passed; the message itself stays. |\n| `content_unreadable` | 409 | The received message's stored content cannot be opened; Sendora has been told. |\n| `rate_limited` | 429 | Per-minute limit; `retryAfter`, `scope`, `limit`. From the API's proxy, too many requests at once. |\n| `monthly_cap_reached` | 429 | The monthly cap is used up; `scope`, `cap`, `used`, `resetsAt`. |\n| `sending_disabled` | 503 | Sending is paused for everyone; `retryAfter`. |\n| `connection_failed` | none | The request did not reach the API, or its answer was lost; `getMessage()` says why. |\n| `timeout` | none | An attempt took longer than `timeout`. |\n| `unexpected_response` | any | An answer without a JSON error body, such as from a proxy. |\n| `invalid_signature` | none | A webhook's signature is missing, malformed or wrong. |\n| `stale_signature` | none | A webhook's signature is older than the tolerance. |\n\n`$error->isRetryable()` is true when waiting and calling again could\nsucceed. `ErrorCode` lists every code this release knows. A code newer\nthan the release comes through as its string, its class chosen by the\nstatus.\n\nNeither the key nor a request's subject, text, HTML or attachments is\never part of an exception, its message or a dump of it. The recipient\naddresses the API names are in `suppressed` and `addresses` alone.\n\n## New values and fields\n\nThe API may add a value to a set the SDK knows, such as a recipient's\nstatus, before your release does. So such a property is a `string`,\nwhich takes the new value rather than failing the call. A backed enum,\nsuch as `RecipientStatus`, lists the values the release knows. Compare\nwith `RecipientStatus::Delivered->value`, or use `tryFrom()`. A `match`\nover such a property keeps a `default` arm.\n\nA field newer than your release is not a property yet, but every answer\nkeeps what the API sent. `$answer->toArray()` returns it as arrays, with\nthe API's camelCase keys, in which an empty object becomes an empty\narray. `json_encode($answer)` writes the JSON back as it came.\n\n## Retries and timeouts\n\nLost connections, timeouts, `rate_limited`, `sending_disabled` and server\nerrors are retried with jittered backoff, twice by default. That includes\nthe plain-text 429 of the proxy in front of the API. A retry honours\n`retryAfter`, the seconds the API asks for, when it fits within five\nseconds of waiting in all. A longer `retryAfter` is thrown at once for\nyou to schedule. No other failure is retried, so `monthly_cap_reached`\nis thrown at once, with `resetsAt`.\n\nA call that creates something is repeated only after a `rate_limited` or\n`sending_disabled` answer. It is never repeated after a lost connection\nor a server error, so nothing is created twice. These calls are\n`streams->create()`, `tokens->create()`, `webhooks->create()`,\n`webhooks->createSecret()`, `webhooks->replay()`,\n`inboundDomains->create()`, `servers->create()`,\n`servers->tokens->create()`, `domains->create()` and `trackingDomains->create()`.\n\n`timeout` is in seconds, 30 by default, and bounds a whole attempt,\nconnecting included. `maxRetries: 0` turns retries off. `withOptions()`\nreturns a copy of the client that shares its connection, for one call\nthat may take longer or a job that must not retry:\n\n```php\nuse Sendora\\Sendora;\n\n$sendora = new Sendora(timeout: 10, maxRetries: 3);\n$patient = $sendora->withOptions(timeout: 120, maxRetries: 0);\n```\n\nThe SDK waits in the process between attempts, so a retry holds a web\nrequest up to five seconds longer. PHP has no standard event loop, so\nthere is no async client. When a request must not wait, send from a\nqueued job, such as a Symfony Messenger handler or a Laravel job.\n\n## Symfony Mailer\n\n`SendoraTransport` sends Symfony Mailer's messages through the API rather\nthan over SMTP. Its factory takes the DSN `sendora+api://KEY@default`,\nwith the server key as the user. Install `symfony/mailer`, from 6.4 to\n8.x, beside the SDK.\n\nIn a Symfony app, register the factory as a service, and set the DSN:\n\n```yaml\n# config/services.yaml\nservices:\n Sendora\\Mailer\\SendoraTransportFactory:\n parent: mailer.transport_factory.abstract\n tags: ['mailer.transport_factory']\n```\n\n```dotenv\nMAILER_DSN=sendora+api://sk_…@default\n```\n\nSymfony's own `config/packages/mailer.yaml` reads `MAILER_DSN`, so the\napp's `MailerInterface` then sends through Sendora. `?stream=<stream id>`\nsends on a stream other than the default. Any host but `default` is used\nas given, over https. Without the framework, give the transport a\nclient, or build the DSN's transport from the factory:\n\n```php\nuse Sendora\\Mailer\\SendoraTransport;\nuse Sendora\\Mailer\\SendoraTransportFactory;\nuse Sendora\\Sendora;\nuse Symfony\\Component\\Mailer\\Header\\MetadataHeader;\nuse Symfony\\Component\\Mailer\\Header\\TagHeader;\nuse Symfony\\Component\\Mailer\\Mailer;\nuse Symfony\\Component\\Mailer\\Transport;\nuse Symfony\\Component\\Mime\\Email;\n\n$mailer = new Mailer(new SendoraTransport(new Sendora()));\n\n// Or from the DSN, beside Symfony's own transports:\n$factories = [new SendoraTransportFactory(), ...Transport::getDefaultFactories()];\n$mailer = new Mailer((new Transport($factories))->fromString((string) getenv('MAILER_DSN')));\n\n$email = (new Email())\n ->from('no-reply@example.se')\n ->to('anna@example.com')\n ->subject('Din faktura för september')\n ->text('Hej Anna, fakturan finns bifogad.')\n ->attachFromPath('faktura.pdf', contentType: 'application/pdf');\n$email->getHeaders()\n ->add(new TagHeader('invoice'))\n ->add(new MetadataHeader('invoice_id', '2026-0912'));\n$mailer->send($email);\n```\n\n`Transport::fromDsn()` alone knows only Symfony's own transports. A\nmessage maps to a send this way:\n\n| From the message | To the send |\n| ------------------------------------------------------------------------------------------- | ------------------------------------------------- |\n| The From header's address and name | `from`; a second address fails the send |\n| The envelope's recipients, each placed by the To, Cc and Bcc headers | `to`, `cc`, `bcc` |\n| The subject, and the text and HTML bodies | `subject`, `text`, `html` |\n| Attachments, and inline parts with their content id | `attachments` |\n| `Reply-To`, `In-Reply-To`, `References`, the two unsubscribe headers and other `X-` headers | `headers`, the first of each name |\n| `X-Sendora-Tag`, or else `TagHeader` | `tag`; two different tags fail the send |\n| `MetadataHeader` and `X-Sendora-Metadata-<key>` | `metadata`, `X-Sendora-Metadata-` winning a tie |\n| `X-Sendora-Stream`, or else the DSN's `stream` | `streamId` |\n| `X-Sendora-Idempotency-Key` | `idempotencyKey`, or else a new key for each send |\n\n- Any other header, such as `Importance`, is left off, as over SMTP. The\n `X-Sendora-`, `X-Tag` and `X-Metadata-` headers never reach `headers`.\n- The envelope decides who gets the message, as over SMTP. A recipient\n that a listener adds, such as Symfony's `EnvelopeListener` redirecting\n a staging app's mail, goes to `to`. A Cc or Bcc address that the\n envelope lacks gets nothing.\n- After a send, the sent message's `getMessageId()` is Sendora's message\n id, the one the log and the webhooks use. Symfony's `SentMessageEvent`\n carries it.\n- A failure is a `TransportException` with the SDK's message, the HTTP\n status as its code and the SDK's exception as its previous. So\n Symfony's failover and round-robin transports work.\n- Set `X-Sendora-Idempotency-Key` from an id of your own, such as an\n order's, when a queued mail may be sent again. It helps only when the\n message renders the same each time. An embedded image gets a new\n content id on each render, and the API then refuses the key with\n `idempotency_key_mismatch`. The transport's message then says that the\n mail went out on an earlier attempt.\n\nTo send over SMTP instead, Symfony's own `smtp://` transport reaches\nSendora, as the SMTP guide at [sendora.se/docs](https://sendora.se/docs)\nshows.\n\n## Your own HTTP client\n\nWithout a client of your own, the SDK speaks to the API through curl. It\ntakes a proxy from `https_proxy`, `HTTPS_PROXY`, `all_proxy` or\n`ALL_PROXY`, and honours `NO_PROXY`, so a proxy needs no setting in the\nSDK. PHP-FPM passes those variables to its workers only when the pool\nsets them with `env[HTTPS_PROXY]` or `clear_env = no`.\n\nCertificates are verified against `curl.cainfo` or `openssl.cafile` in\n`php.ini`, or curl's default store. A file set there takes the place of\ncurl's store, so it must hold the public roots as well as your own\nauthority, such as the one a TLS-inspecting proxy signs with. On Windows,\nPHP ships no certificate file, so when neither setting is set, the SDK\nuses Windows' own store. A container without a store needs its\n`ca-certificates` package.\n\nPass any PSR-18 client as `httpClient:` for a private certificate\nauthority, tracing or tests. The SDK still builds every request, with its\nown headers, and still retries. `requestFactory:` and `streamFactory:`\nbuild the requests, and default to the client itself when it implements\nPSR-17, as Symfony's `Psr18Client` does. Guzzle's client needs both, such\nas `new GuzzleHttp\\Psr7\\HttpFactory()`. A client of your own uses its own\nproxy and certificate settings.\n\n- Set a timeout on the client. PSR-18 has none, so the SDK's `timeout`\n does not reach it.\n- Turn redirects off. The API never redirects.\n- Pass a client without default headers. The SDK's own headers win, but\n the client's others still go out.\n\n```php\nuse Sendora\\Sendora;\nuse Symfony\\Component\\HttpClient\\HttpClient;\nuse Symfony\\Component\\HttpClient\\Psr18Client;\n\n$http = HttpClient::create([\n 'max_duration' => 30,\n 'max_redirects' => 0,\n 'cafile' => '/etc/ssl/certs/internal-ca.pem',\n]);\n$sendora = new Sendora(httpClient: new Psr18Client($http));\n```\n\nSymfony's `Psr18Client` needs a PSR-7 implementation, such as\n`nyholm/psr7`. A failure of the client's is `connection_failed`, and its\nclass and message are in the exception's message.\n\n## Testing your code\n\nFor tests that talk to Sendora, create a test server. It goes through\neverything a live one does: validation, the suppression list, the log\nand the webhooks. But nothing it accepts is delivered. Its keys start\n`sk_test_`, and `Keys::isTestKey()` tells them apart, so a production\nprocess can refuse one at startup.\n\nAddresses at `simulator.sendora.se` (`hardbounce@`, `softbounce@`,\n`deferred@`, `complaint@`, with any `+label`) produce those outcomes,\nand any other address is marked delivered. A server's mode is fixed\nwhen it is created, so going live means a new server and a new key.\nTest mail is free and counts against its own monthly cap, shared by the\naccount's test servers.\n\n```php\nuse Sendora\\Keys;\nuse Sendora\\Sendora;\nuse Sendora\\SendoraAccount;\n\n$account = new SendoraAccount();\n$ci = $account->servers->create(name: 'CI', mode: 'test');\n\n$token = (string) getenv('SENDORA_API_TOKEN');\nif (getenv('APP_ENV') === 'production' && Keys::isTestKey($token)) {\n throw new RuntimeException('SENDORA_API_TOKEN is a test key, and nothing would be delivered.');\n}\n$sendora = new Sendora($token);\n```\n\nFor unit tests that must not reach the network, give the client a fake\nPSR-18 client that returns what the API would, such as Symfony's\n`MockHttpClient` inside a `Psr18Client`:\n\n```php\nuse PHPUnit\\Framework\\TestCase;\nuse Sendora\\Sendora;\nuse Symfony\\Component\\HttpClient\\MockHttpClient;\nuse Symfony\\Component\\HttpClient\\Psr18Client;\nuse Symfony\\Component\\HttpClient\\Response\\JsonMockResponse;\n\nfinal class InvoiceMailTest extends TestCase\n{\n public function testTheInvoiceIsSent(): void\n {\n $answer = new JsonMockResponse([\n 'messageId' => '0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11',\n 'status' => 'accepted',\n 'submittedAt' => '2026-09-26T10:00:00.000Z',\n 'test' => false,\n ]);\n $sendora = new Sendora('sk_unit_test', httpClient: new Psr18Client(new MockHttpClient($answer)));\n\n $accepted = $sendora->email->send(from: 'no-reply@example.se', to: ['anna@example.com'], subject: 'Hej', text: 'Hej');\n\n self::assertSame('https://api.sendora.se/v1/email', $answer->getRequestUrl());\n self::assertSame('accepted', $accepted->status);\n }\n}\n```\n\nTo test a failure, answer with the API's error body and status, such as\n`422` with `{\"error\": \"from_domain_not_verified\", \"message\": \"…\"}`. The SDK\nrepeats a call after a retryable answer, such as a `503`, so pass\n`maxRetries: 0` in a test that gives one.\n\nCode that takes a resource, such as `Email`, rather than the client can\ntake a double of it. Each resource class can be doubled with PHPUnit's\n`createMock()` or with Mockery. An answer's or a webhook event's\nconstructor is outside the semver promise, since a new property adds a\nparameter. So build either with named arguments, as below, and expect an\nupgrade to ask for more.\n\n```php\nuse PHPUnit\\Framework\\TestCase;\nuse Sendora\\Resources\\Email;\nuse Sendora\\Types\\AcceptedEmail;\n\nfinal class WelcomeMail\n{\n public function __construct(private readonly Email $email) {}\n\n public function send(string $address): string\n {\n $accepted = $this->email->send(from: 'no-reply@example.se', to: [$address], subject: 'Välkommen', text: 'Hej!');\n\n return $accepted->messageId;\n }\n}\n\nfinal class WelcomeMailTest extends TestCase\n{\n public function testTheWelcomeIsSent(): void\n {\n $email = $this->createMock(Email::class);\n $email->expects(self::once())->method('send')->willReturn(new AcceptedEmail(\n messageId: '0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11',\n status: 'accepted',\n submittedAt: new DateTimeImmutable('2026-09-26T10:00:00Z'),\n test: true,\n ));\n\n self::assertSame('0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11', (new WelcomeMail($email))->send('anna@example.com'));\n }\n}\n```\n\n## Logging and tracing\n\nThe SDK logs one thing: each retry, at debug level, on the PSR-3 logger\nyou pass as `logger:`. The entry has the method, the path, the code and\nthe wait. Monolog and the loggers of Symfony and Laravel all take PSR-3.\n\nA stack trace never shows the key, or a message's addresses, subject,\ntext, HTML or attachments. The SDK marks those parameters\n`#[\\SensitiveParameter]`, so PHP puts a placeholder in their place. That\nholds in an error tracker's traces and in Laravel's failed jobs alike.\n\nThe client sends every attempt through the PSR-18 client you give it,\nretries included. So a decorator around it sees each attempt and can\ntime it:\n\n```php\nuse Psr\\Http\\Client\\ClientInterface;\nuse Psr\\Http\\Message\\RequestInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Sendora\\Sendora;\nuse Symfony\\Component\\HttpClient\\Psr18Client;\n\nfinal class TimedClient implements ClientInterface\n{\n public function __construct(\n private readonly ClientInterface $inner,\n private readonly LoggerInterface $logger,\n ) {}\n\n public function sendRequest(RequestInterface $request): ResponseInterface\n {\n $started = microtime(true);\n $response = $this->inner->sendRequest($request);\n $this->logger->info('{method} {path} {status} in {seconds} s', [\n 'method' => $request->getMethod(),\n 'path' => $request->getUri()->getPath(),\n 'status' => $response->getStatusCode(),\n 'seconds' => round(microtime(true) - $started, 3),\n ]);\n\n return $response;\n }\n}\n\n$psr18 = new Psr18Client();\n$sendora = new Sendora(\n httpClient: new TimedClient($psr18, $logger),\n requestFactory: $psr18,\n streamFactory: $psr18,\n);\n```\n\nRecord the path, never the request's body, which holds the message.\n\n## For coding agents\n\nThe package ships `skills/sendora/SKILL.md`, a short guide in the agent\nskill format with the rules above. Point your agent at\n`vendor/sendora/sendora-php/skills/sendora/SKILL.md` or at this README.\nEvery public method carries a doc comment, and PHPStan reads the array\nshapes in them.\n\n## Versions\n\nThe package follows semantic versioning. A breaking change comes only in\na major version: a renamed or removed method, argument or property, or a\nchanged error code. A minor adds methods, arguments, properties, codes or\nevents, and a patch fixes a bug. `composer require` writes a constraint\nsuch as `^1.0`, which takes every later 1.x release.\n\nA release adds an argument only at the end of its method, so a call by\nposition keeps working. An answer's or a webhook event's constructor is\noutside the promise.\n`CHANGELOG.md` lists what each release changed.\n\n## Docs\n\nGuides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).\n"},{"uri":"sendora://sdk/php/skill","title":"Rules for agents using the PHP SDK","description":"The skill file sendora/sendora-php ships for coding agents.","text":"---\nname: sendora\ndescription: Send transactional email through Sendora with the sendora/sendora-php package, read the message log, manage streams, suppressions, keys, webhooks, servers and sending domains, receive signed webhooks, and send Symfony Mailer's mail through the API. Use when a task mentions Sendora, the sendora/sendora-php package, or sending email from a PHP system hosted in Sweden.\n---\n\n# Sendora\n\n`sendora/sendora-php` is the PHP client for the Sendora API. It runs on\nPHP 8.2 or newer with ext-curl and ext-json, and is typed for PHPStan.\nThe README of this package, `README.md` at its root, is the complete\nreference: every call, every error code and every event is in it.\n\n## Rules\n\n- Two kinds of key, two clients. A server key (`sk_…`) drives\n `new Sendora()`: `email`, `broadcasts`, `messages`, `inbound`,\n `inboundDomains`, `server`, `streams`, `suppressions`, `tokens` and\n `webhooks`, everything inside one server. An account key (`ak_…`)\n drives `new SendoraAccount()`: `servers`, `servers->tokens`, `domains`,\n `trackingDomains` and `erasures`, and it never sends. A key of the wrong kind throws\n `wrong_token_kind`.\n- Every key is a secret. Leave `token` out and the clients read\n `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN` from `$_SERVER`,\n `$_ENV` or `getenv()`. Never hard-code a key or send it to a browser.\n Under PHP-FPM, set the variable in the pool with `env[...]` or pass the\n key in. A client without a key throws `InvalidArgumentException`, so\n construct one per process at startup and share it, as a service in a\n container.\n- A server is created with its first key:\n `$account->servers->create(name: ...)` returns `->token->token` once,\n and `new Sendora($token)` sends with it. A server or the account holds\n at most two live keys. So rotate by creating the new key, switching to\n it, then revoking the old one. The last live key can never be revoked.\n- For tests and CI, use a test server:\n `$account->servers->create(name: ..., mode: 'test')`. It accepts, logs\n and fires webhooks like a live one but delivers nothing. Its keys start\n `sk_test_`, and `Keys::isTestKey($token)` tells them apart at startup.\n Addresses at `simulator.sendora.se` (`hardbounce@`, `softbounce@`,\n `deferred@`, `complaint@`) produce those outcomes on a test server and\n are refused on a live one (`test_address_on_live_server`). A server's\n mode never changes. For unit tests, pass `httpClient:` a fake PSR-18\n client, such as Symfony's `MockHttpClient` inside a `Psr18Client`, or\n double a resource class with `createMock()`.\n- Arguments are named, with the API's camelCase names. The ids of a path\n come first, by position, and every other argument is named. Do not\n build request bodies by hand or call curl yourself.\n- Do not add idempotency keys or retry loops of your own. The SDK gives\n every send a key and repeats a call only when repeating is safe. Pass\n `idempotencyKey:` only when a retry may come from another process,\n such as a queued job. There is no async client: send from a queued job\n when a request must not wait.\n- Every failed call throws a `SendoraException`. Catch its subclass or\n compare `$error->errorCode` with an `ErrorCode` case's `->value`, and\n rethrow anything else. Never swallow it. A bad argument throws the\n SDK's `InvalidArgumentException` before any request.\n- Answers are readonly classes. A property that holds one of a set of\n values, such as a recipient's `status`, is a string that may hold a\n value newer than the release. Compare it with an enum's `->value`, such\n as `RecipientStatus::Delivered->value`, and give a `match` over it a\n `default` arm.\n- A batch never throws for one bad message: check each of\n `sendBatch(...)->results` with `instanceof RefusedBatchItem`.\n- Delivery is asynchronous. `email->send()` returns `accepted`. Whether\n the receiver took the message is in\n `messages->get($messageId)->recipients` and in the `delivered`,\n `bounced`, `deferred` and `spam_complaint` webhook events.\n- Verify a webhook before parsing it with `WebhookVerifier::verify()`.\n Give it the raw body exactly as it arrived,\n `file_get_contents('php://input')` or `$request->getContent()`, with\n `signature:` the `Sendora-Signature` header and `secret:` the\n webhook's secret. Tell the events apart with `instanceof`, never by\n comparing `$event->event`, and handle `UnknownEvent`, an event newer\n than the release. Answer 2xx quickly and do the work afterwards.\n Deliveries arrive at least once, so key handling on `$event->id`.\n- Addresses on the suppression list are refused with\n `recipient_suppressed`. Lift a hard bounce with\n `suppressions->delete(address: ...)`. A spam complaint or an\n unsubscribe cannot be lifted by the customer. Lists are per stream:\n pass `streamId:` when the send went on a stream other than the default.\n- `from` must be on a verified sending domain of the account.\n `$account->domains->create(domain: ...)` returns the two DNS records to\n add, and `$account->domains->verify()` checks them now. A server key\n cannot manage domains, but `server->get()` names those a From may use\n (`sendingDomains`). A verified domain whose record goes missing keeps\n sending for 24 hours (`failingSince`), and a `dns_error` changes\n nothing.\n- A person who asks to be forgotten is erased with the account key.\n `$account->erasures->create(address: ...)` returns a pending erasure,\n and `$account->erasures->get($erasureId)` shows it `done` within a\n minute. It deletes their messages' content and puts a pseudonym in the\n log on every server. It blocks nothing.\n- A message goes on the server's default transactional stream unless it\n names another with `streamId:`, and `streams->create()` adds one. Never\n make a stream per message or per customer: a stream is a kind of mail.\n- Mail to many goes as one broadcast: `broadcasts->send()` on a broadcast\n stream, with the content once and `messages:` holding one entry per\n recipient, at most 50,000. Follow it with `broadcasts->get()` and stop\n it with `broadcasts->cancel()`. Personalise with `{{ key }}` in the\n content and `substitutions` on each entry. Never loop over\n `email->send()` for a list.\n- A message on a broadcast stream must carry `{{ unsubscribe_url }}` in\n its text and its HTML. Sendora puts each recipient's own link there and\n writes the List-Unsubscribe headers itself. An `unsubscribed` event\n tells your system to stop, and the address is then on that stream's\n list.\n- Symfony Mailer sends through the API with\n `MAILER_DSN=sendora+api://KEY@default`, once `SendoraTransportFactory`\n is registered as the README shows. Symfony's `TagHeader` and\n `MetadataHeader` become the tag and the metadata.\n- In a Laravel app, install `sendora/sendora-laravel`, which installs\n this package. `MAIL_MAILER=sendora` sends Laravel's mail, the clients\n come from the container, and the package's route checks each webhook\n and fires a Laravel event. Its README has the settings.\n\n## The shape of a call\n\n```php\nuse Sendora\\Exception\\SendoraException;\nuse Sendora\\Sendora;\nuse Sendora\\Types\\ErrorCode;\n\n$sendora = new Sendora();\n\ntry {\n $accepted = $sendora->email->send(\n from: ['email' => 'no-reply@example.se', 'name' => 'Example AB'],\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n tag: 'invoice',\n );\n echo $accepted->messageId, \"\\n\";\n} catch (SendoraException $error) {\n if ($error->errorCode !== ErrorCode::RecipientSuppressed->value) {\n throw $error;\n }\n foreach ($error->suppressed as $entry) {\n echo $entry->address, ' is suppressed: ', $entry->reason, \"\\n\";\n }\n}\n```\n\n## Where to read more\n\n- The README of this package: every method, every error code, retries,\n webhooks and events, the Symfony Mailer transport, testing, logging\n and tracing.\n- The guides and the HTTP reference at https://sendora.se/docs.\n"},{"uri":"sendora://sdk/laravel/readme","title":"The Laravel package","description":"sendora/sendora-laravel's README: Laravel's mail through Sendora, the clients in the container, and the webhook route and its events.","text":"# sendora/sendora-laravel\n\n[Sendora](https://sendora.se), transactional email delivery, for Laravel.\nIt puts the API's clients in Laravel's container, with a facade. It sends\nLaravel's mail through the API with `MAIL_MAILER=sendora`. And it fires a\nLaravel event for each webhook, from a route that checks its signature.\nLaravel 12 on PHP 8.2 or newer, and Laravel 13 on PHP 8.3 or newer.\n\n```sh\ncomposer require sendora/sendora-laravel\n```\n\nLaravel finds the package by itself. Then set the server key, and send\nmail through it:\n\n```dotenv\nSENDORA_API_TOKEN=sk_…\nMAIL_MAILER=sendora\nMAIL_FROM_ADDRESS=no-reply@example.se\n```\n\nAn account has servers, and each server has its own mail, log and keys.\nA server key (`sk_…`) sends and reads everything of its server, and you\nget one when you create the server in the dashboard. An account key\n(`ak_…`) manages the account's servers, keys and domains. Both are\nsecrets, so keep them on the server.\n\nThe package builds on\n[`sendora/sendora-php`](https://packagist.org/packages/sendora/sendora-php),\nthe PHP client, which it installs. That package's README has every call,\nresponse, error code and webhook event. This one covers what Laravel\nadds.\n\n## Settings\n\nThe settings live in `config/sendora.php`, and the environment sets the\nones a deploy changes. Publish the file to change the rest:\n\n```sh\nphp artisan vendor:publish --tag=sendora-config\n```\n\n| Key | From the environment | Default |\n| -------------------- | ------------------------ | --------------------------- |\n| `token` | `SENDORA_API_TOKEN` | none |\n| `account_token` | `SENDORA_ACCOUNT_TOKEN` | none |\n| `base_url` | `SENDORA_BASE_URL` | `https://api.sendora.se` |\n| `timeout` | | 10 seconds an attempt |\n| `max_retries` | | 2; 0 turns retries off |\n| `http_client` | | none, so curl |\n| `stream` | `SENDORA_STREAM` | the server's default stream |\n| `webhook.enabled` | | true |\n| `webhook.path` | | `sendora/webhook` |\n| `webhook.domain` | | none, so any domain |\n| `webhook.middleware` | | none |\n| `webhook.secrets` | `SENDORA_WEBHOOK_SECRET` | none, so no route |\n| `webhook.tolerance` | | 300 seconds |\n\n- Three attempts of 10 seconds and the waits between them stay within\n the 60 seconds that `queue:work` gives a job by default.\n- `http_client` names a PSR-18 client in the container, for a proxy or\n for tests. Otherwise the clients send through curl, so Laravel's\n `Http::fake()` never sees their requests.\n- A stream is a lane of a server's mail, transactional or broadcast, with\n a suppression list of its own.\n- `webhook.secrets` takes several secrets, separated by commas, since\n each webhook has its own.\n\nA client is built on first use, so an app that never calls Sendora needs\nno key. A client without its key throws `Sendora\\Exception\\InvalidArgumentException`,\nnaming the variable. A setting of the wrong type throws it too, naming\nthe setting.\n\n## Sending mail\n\nWith `MAIL_MAILER=sendora`, Laravel's mail goes through the API, and\n`config/mail.php` needs no entry for it. A mailable's attachments, Cc,\nBcc and Reply-To go through as Laravel sets them. Its tags and metadata\nbecome the message's `tag` and `metadata` in Sendora.\n\n```php\nuse Illuminate\\Mail\\Mailable;\nuse Illuminate\\Mail\\Mailables\\Content;\nuse Illuminate\\Mail\\Mailables\\Envelope;\nuse Illuminate\\Support\\Facades\\Mail;\n\nfinal class InvoiceMail extends Mailable\n{\n public function __construct(public readonly string $invoiceId) {}\n\n public function envelope(): Envelope\n {\n return new Envelope(\n subject: 'Din faktura för september',\n tags: ['invoice'],\n metadata: ['invoice_id' => $this->invoiceId],\n );\n }\n\n public function content(): Content\n {\n return new Content(htmlString: '<p>Hej Anna, fakturan finns bifogad.</p>');\n }\n}\n\n$sent = Mail::to('anna@example.com')->send(new InvoiceMail('2026-0912'));\nif ($sent !== null) {\n $messageId = $sent->getMessageId(); // Sendora's message id, as the log and the webhooks name it\n}\n```\n\n- `send()` returns null when the mail is queued, or when a listener of\n Laravel's `MessageSending` stops it. For queued mail, Laravel's\n `MessageSent` event carries the sent message, and with it Sendora's id.\n- A message has one tag. The same tag twice counts once, and an\n `X-Sendora-Tag` header wins over the mailable's tags. Two different\n tags fail the send before anything goes out.\n- Laravel sends from `MAIL_FROM_ADDRESS` unless a mailable sets its own\n sender. Either way, the address must be on a domain that the account\n has verified.\n- Notifications sent by mail go the same way, and the `tag()` and\n `metadata()` of a `MailMessage` set Sendora's.\n- The headers `X-Sendora-Metadata-<key>` and `X-Sendora-Stream` set\n metadata and the stream from a mailable's `headers()`.\n\nA failure is Symfony's `TransportException`, with the SDK's exception as\nits previous one. Laravel's queue retries a queued mail that fails, as\nits settings say. Laravel's `failover` mailer moves to its next mailer\nwhen Sendora fails, as it does for any transport. The SDK's README maps\neach part of a message to the API's request.\n\n### Several servers\n\nA mailer of `config/mail.php` with the transport `sendora` may set its\nown `token`, `base_url` and `stream`, for an app that sends from several\nservers:\n\n```php\n// config/mail.php, beside the mailers that are there\nreturn [\n 'mailers' => [\n 'billing' => [\n 'transport' => 'sendora',\n 'token' => env('SENDORA_BILLING_TOKEN'),\n 'stream' => env('SENDORA_BILLING_STREAM'),\n ],\n ],\n];\n```\n\nThen `Mail::mailer('billing')->to(...)->send(...)` sends with that\nserver's key.\n\n- A setting that the mailer leaves out, or that is null as an unset\n variable leaves it, comes from `config/sendora.php`. So does every\n other setting, such as the timeout.\n- The stream is the exception. A mailer with its own `token` sends on\n its server's default stream, unless it sets `stream`.\n- For calls on that server, build a client with its key:\n `new \\Sendora\\Sendora(config('mail.mailers.billing.token'))`.\n- The secrets of that server's webhooks go in `SENDORA_WEBHOOK_SECRET`\n too, beside the first server's.\n\n### Queued mail\n\nThe SDK sends each request under an idempotency key, so a retried request\nnever sends twice. But Laravel retries a failed queued mail as a new\nsend, under a new key. If the first attempt went out before it failed,\nthe retry sends the mail again. Set `X-Sendora-Idempotency-Key` from an\nid of your own to stop that:\n\n```php\nuse Illuminate\\Mail\\Mailable;\nuse Illuminate\\Mail\\Mailables\\Headers;\n\nfinal class ReceiptMail extends Mailable\n{\n public function __construct(public readonly string $orderId) {}\n\n public function headers(): Headers\n {\n return new Headers(text: ['X-Sendora-Idempotency-Key' => \"receipt-{$this->orderId}\"]);\n }\n}\n```\n\n- The API remembers a key for 24 hours.\n- The key protects a retry only when the mailable renders the same bytes\n each time. An embedded image gets a new content id on each render, and\n a signed URL a new expiry.\n- Otherwise the API refuses the reused key with\n `idempotency_key_mismatch`. The failure's message then says that the\n mail went out on an earlier attempt, so that retry has nothing left to\n send. Inside a `failover` mailer, though, the next mailer sends it\n again.\n\n## The clients\n\nThe `Sendora` facade reaches each resource of the server client:\n`broadcasts()`, `email()`, `inbound()`, `inboundDomains()`, `messages()`,\n`server()`, `streams()`, `suppressions()`, `tokens()` and `webhooks()`.\n\n```php\nuse Sendora\\Laravel\\Facades\\Sendora;\n\n$accepted = Sendora::email()->send(\n from: 'no-reply@example.se',\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n tag: 'invoice',\n);\n$message = Sendora::messages()->get($accepted->messageId);\n```\n\nThe container holds one `Sendora\\Sendora`, from `SENDORA_API_TOKEN`, and\none `Sendora\\SendoraAccount`, from `SENDORA_ACCOUNT_TOKEN`, each with\nLaravel's logger. It binds each resource class too, such as `Email` and\n`Messages` of the server client, and `Servers` and `Domains` of the\naccount client. So a class may take what it uses:\n\n```php\nuse Sendora\\Resources\\Messages;\n\nfinal class DeliveryStatus\n{\n public function __construct(private readonly Messages $messages) {}\n\n public function of(string $messageId): string\n {\n return $this->messages->get($messageId)->status;\n }\n}\n```\n\nThe account client has no facade. Take `SendoraAccount` from the\ncontainer, or one of its resources: `Domains`, `TrackingDomains`, `Erasures`\nfor erasing an address's data, `Servers` and `ServerTokens`.\n\n## Webhooks\n\nA webhook is a URL of yours that Sendora posts events to, as they happen.\nEach post is a delivery, signed with the webhook's secret. The package's\nroute takes the deliveries, checks each signature, and fires a Laravel\nevent for each.\n\n1. Create the webhook in the dashboard, or with the client. The route\n does not exist until a secret is set, so build its URL from its path:\n\n ```php\n use Sendora\\Laravel\\Facades\\Sendora;\n\n $webhook = Sendora::webhooks()->create(url: url('sendora/webhook'));\n $secret = $webhook->secret; // shown this once\n ```\n\n It receives every event, unless `events:` lists the ones it wants.\n\n2. Put the secret in `SENDORA_WEBHOOK_SECRET`, and deploy.\n3. If the app caches its config or its routes, run `config:cache` and\n `route:cache` again where the secret is set.\n\nThe route is `POST /sendora/webhook`, named `sendora.webhook`. Until a\nsecret is set, the route does not exist. A delivery then gets a 404, and\nSendora tries it again later. The route is outside the `web` middleware\ngroup, so Laravel's CSRF check never applies to it.\n\nListen to the event of each webhook you want, as to any Laravel event:\n\n```php\nuse Illuminate\\Support\\Facades\\Log;\nuse Sendora\\Laravel\\Events\\MessageBounced;\n\nfinal class MarkTheAddressUndeliverable\n{\n public function handle(MessageBounced $event): void\n {\n if ($event->webhook->details->hard) {\n Log::warning(\"{$event->webhook->messageId} bounced for good: {$event->webhook->details->classification}\");\n }\n }\n}\n```\n\nLaravel finds a listener in `app/Listeners` by the type of its `handle()`\nargument. `Event::listen()` registers one in a provider instead.\n\n| Webhook event | Laravel event | `$event->webhook` |\n| --------------------- | ------------------------ | ------------------------ |\n| `delivered` | `MessageDelivered` | `DeliveredEvent` |\n| `bounced` | `MessageBounced` | `BouncedEvent` |\n| `deferred` | `MessageDeferred` | `DeferredEvent` |\n| `spam_complaint` | `SpamComplaintReceived` | `SpamComplaintEvent` |\n| `unsubscribed` | `RecipientUnsubscribed` | `UnsubscribedEvent` |\n| `cap_warning` | `CapWarning` | `CapWarningEvent` |\n| `cap_reached` | `CapReached` | `CapReachedEvent` |\n| `inbound` | `InboundEmailReceived` | `InboundEvent` |\n| `inbound_cap_warning` | `InboundCapWarning` | `InboundCapWarningEvent` |\n| `inbound_cap_reached` | `InboundCapReached` | `InboundCapReachedEvent` |\n| any other | `UnknownWebhookReceived` | `WebhookEvent` |\n\nThe Laravel events are in `Sendora\\Laravel\\Events`. `$event->webhook` is\nthe SDK's event, from `Sendora\\Events`, whose fields the SDK's README\nlists. A cap is a monthly limit, on sending or on received mail. Every\nLaravel event implements `WebhookReceived`, so a listener of that\ninterface hears them all.\n\nA webhook that the package has no event for fires\n`UnknownWebhookReceived`. Its `webhook->event` is the event's name, and\n`webhook->toArray()` its payload as it came. Upgrade the package to read\nit as its class.\n\n### Responding in time\n\n- The route responds with 204 once the listeners that are not queued\n have run. Sendora waits 10 seconds for the response, and counts a later\n one as a failure. So queue slow work.\n- A listener that throws makes the response 500. After a failure,\n Sendora delivers the webhook again, with growing delays, for about a\n day.\n- A queued listener that fails is retried by Laravel's queue, not by\n Sendora, which has had its 204.\n- A delivery can arrive more than once, so key your handling on\n `$event->webhook->id`. The id is the same on each attempt, and\n `webhook->attempt` counts them.\n- A bad or stale signature gets a 400 and fires nothing. The log gets\n the failure's code at debug level, and nothing of the request.\n\n### Received mail\n\n`InboundEmailReceived` carries the received mail under\n`webhook->content` when the webhook was created with\n`inboundContent: 'full'`, the default. A webhook created with\n`inboundContent: 'reference'` carries no content. Then fetch the mail\nwith `Sendora::inbound()->get($event->webhook->inboundMessageId)`, for 30\ndays by default.\n\nA queued listener of `InboundEmailReceived` would put the mail in the\nqueue's store, in clear, and could pass a queue's size limit, such as\nSQS's. So queue the message's id alone and fetch the mail in the job:\n\n```php\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Support\\Facades\\Event;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Sendora\\Laravel\\Events\\InboundEmailReceived;\nuse Sendora\\Resources\\Inbound;\n\nfinal class FileTheTicket implements ShouldQueue\n{\n use Dispatchable;\n use Queueable;\n\n public function __construct(public readonly string $inboundMessageId) {}\n\n public function handle(Inbound $inbound): void\n {\n $mail = $inbound->get($this->inboundMessageId);\n Storage::put(\"tickets/{$this->inboundMessageId}.txt\", $mail->text ?? '');\n }\n}\n\nEvent::listen(static function (InboundEmailReceived $event): void {\n FileTheTicket::dispatch($event->webhook->inboundMessageId);\n});\n```\n\nA listener that is `ShouldBeEncrypted` keeps the mail unreadable in the\nqueue, but not small. A delivery with the mail's content can take a few\nmegabytes. That is more than nginx's default `client_max_body_size` of\n1 MB, so raise it for the route, or create the webhook with\n`inboundContent: 'reference'`.\n\n### The route's settings\n\n- `webhook.path` and `webhook.domain` move the route, and\n `webhook.middleware` adds middleware to it, such as a rate limit.\n- `webhook.enabled` set to false removes the route.\n- `webhook.tolerance` sets how old a signature may be, in seconds,\n either way. Keep the server's clock in sync.\n\nCached routes keep the path, the domain and the middleware that they were\ncached with. So cache the routes where the secret is set, as at a deploy,\nand not in an image built without it. The route reads the secrets and the\ntolerance with each delivery. It responds with 404 once it is turned off\nor its last secret is gone.\n\n### Rolling a secret\n\nA webhook holds up to two live secrets, and signs each delivery with\nboth. So a secret rolls without a gap:\n\n1. Create the second secret with\n `Sendora::webhooks()->createSecret($webhookId)`. Its `secret` is the\n new value, shown once.\n2. Add it to `SENDORA_WEBHOOK_SECRET`, after a comma, and deploy.\n3. Delete the old secret with\n `Sendora::webhooks()->deleteSecret($webhookId, $secretId)`.\n4. Remove the old secret from the variable, and deploy.\n\nWith the config cached, run `config:cache` after each change to the\nvariable.\n\n## Testing your code\n\n`Mail::fake()` and `Event::fake()` work as usual, and a faked mailer\nnever reaches Sendora.\n\nTo test code that calls the API, replace a resource in the container.\n`Sendora::email()` and a class that takes `Email` then both get the\ndouble. The two client classes are final, so `Sendora::shouldReceive()`\ncannot double them. The resource classes are not, so double those.\n\n```php\nuse Illuminate\\Foundation\\Testing\\TestCase;\nuse Mockery\\MockInterface;\nuse Sendora\\Laravel\\Facades\\Sendora;\nuse Sendora\\Resources\\Email;\nuse Sendora\\Types\\AcceptedEmail;\n\nfinal class WelcomeMailTest extends TestCase\n{\n public function testTheWelcomeIsSent(): void\n {\n $this->mock(Email::class, static function (MockInterface $email): void {\n $email->expects('send')->andReturn(new AcceptedEmail(\n messageId: '0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11',\n status: 'accepted',\n submittedAt: new \\DateTimeImmutable('2026-09-26T10:00:00Z'),\n test: true,\n ));\n });\n\n $accepted = Sendora::email()->send(from: 'no-reply@example.se', to: ['anna@example.com'], subject: 'Välkommen', text: 'Hej!');\n\n self::assertSame('accepted', $accepted->status);\n }\n}\n```\n\nA response's constructor is outside the semver promise, since a new\nproperty adds a parameter. So build one with named arguments, and expect\nan upgrade to ask for more.\n\nTo fake the API itself, give a client a fake PSR-18 client, as the SDK's\nREADME shows. Then swap it in:\n`Sendora::swap(new \\Sendora\\Sendora('sk_test_…', httpClient: $fake))`.\n\nTo test that a delivery reaches your code through the route, set a\nsecret in `phpunit.xml`, so the route exists when the app boots:\n\n```xml\n<env name=\"SENDORA_WEBHOOK_SECRET\" value=\"whsec_test\"/>\n```\n\nThen post a delivery signed as Sendora signs it. The route checks the\nsignature's time against Laravel's clock, so `travelTo()` moves it too.\n\n```php\nuse Illuminate\\Foundation\\Testing\\TestCase;\nuse Illuminate\\Support\\Facades\\Event;\nuse Sendora\\Laravel\\Events\\CapWarning;\n\nfinal class CapWarningTest extends TestCase\n{\n public function testTheRouteFiresTheWarning(): void\n {\n Event::fake([CapWarning::class]);\n $body = (string) json_encode([\n 'event' => 'cap_warning',\n 'id' => '4d6f8a0b-2c4e-4f6a-8b0c-2d4e6f8a0b1c',\n 'attempt' => 1,\n 'scope' => 'account',\n 'serverId' => null,\n 'cap' => 50000,\n 'used' => 40000,\n 'periodStart' => '2026-09-01T00:00:00.000Z',\n 'periodEnd' => '2026-10-01T00:00:00.000Z',\n 'occurredAt' => '2026-09-24T09:30:00.000Z',\n ]);\n $at = now()->getTimestamp();\n $signature = \"t={$at},v1=\" . hash_hmac('sha256', \"{$at}.{$body}\", 'whsec_test');\n\n $this->call('POST', route('sendora.webhook'), server: ['HTTP_SENDORA_SIGNATURE' => $signature], content: $body)\n ->assertNoContent();\n\n Event::assertDispatched(CapWarning::class, static fn(CapWarning $event): bool => $event->webhook->used === 40000);\n }\n}\n```\n\n`Event::fake()` keeps the listeners from running, so this checks that\nthe route fires the event. `Event::assertListening()` then checks that\nyour listener hears it. Leave the events unfaked to run the listener\nitself.\n\nFor tests that talk to Sendora itself, use the key of a test server,\nwhich starts `sk_test_`. A test server goes through everything a live\none does, but nothing it accepts is delivered.\n\n## Logging\n\nThe clients log each retry at debug level on Laravel's logger, with the\nmethod, the path, the code and the wait. The route logs a refused\ndelivery's code at debug level. Neither logs a key, an address or a\nmessage's content.\n\n## Versions\n\nThe package follows semantic versioning. A breaking change comes only in\na major version: a renamed or removed class, method, setting or event, or\na changed default. A minor adds them, and a patch fixes a bug. Each release\nrequires the major of `sendora/sendora-php` that it was tested with, from\n1.0.0, and Composer installs it beside the package. `CHANGELOG.md` lists\nwhat each release changed.\n\n## Docs\n\nGuides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).\n"},{"uri":"sendora://reference/post-v1-email","title":"POST /v1/email","description":"Send one message","text":"# POST /v1/email\n\nSend one message\n\nAccepts one message for delivery and answers at once with its id. Delivery, deferral, bounce and complaint arrive later as events on the message and as webhooks. With an Idempotency-Key, a retry within 24 hours gets the first answer again, marked by the Idempotent-Replayed header, and sends nothing twice.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.email.send(message)`. Python: `sendora.email.send(**message)`. PHP: `$sendora->email->send(...$message)`.\n\n## Parameters\n\n- `idempotency-key` (header, string): Any text of 1 to 255 printable characters that identifies this request; the same key with a different body is refused.\n\n## Request body\n\n- `from` (one of 2, required): The sender. Its domain must be a verified sending domain of the server.\n- `streamId` (string (uuid)): The stream of this server the message goes on; the default transactional stream when absent.\n- `to` (array of one of 2, at least 1, required): Recipients; at most 50 across to, cc and bcc.\n- `cc` (array of one of 2): Copied recipients, shown in the message.\n- `bcc` (array of one of 2): Blind-copied recipients.\n- `subject` (string (at least 1, at most 998 characters), required): The subject line.\n- `text` (string (at least 1 character)): The plain-text part. At least one of text and html is required.\n- `html` (string (at least 1 character)): The HTML part.\n- `headers` (map of string to string): Custom headers by name, at most 20. Sendora writes these itself, so a message may not set them: From, Sender, To, Cc, Bcc, Subject, Date, Message-ID, MIME-Version, Return-Path, Received, Delivered-To, DKIM-Signature, DomainKey-Signature, Authentication-Results, Received-SPF, and any name that starts with Content-, Resent-, ARC- or X-Kumo.\n- `attachments` (array of object, at most 20): At most 20. A message with its attachments encoded may be at most 10 MB.\n - `name` (string, required)\n - `content` (string (base64), required)\n - `contentType` (string)\n - `contentId` (string or null)\n- `tag` (string or null): A label of up to 100 characters, returned with the message and its events.\n- `metadata` (map of string to string): Up to 20 key-value pairs of your own, returned with the message and its events.\n\nThe forms of `from`, and of each item of `to`, `cc` and `bcc`:\n\n- Form 1: string (email)\n- Form 2\n - `email` (string (email), required)\n - `name` (string (at least 1, at most 200 characters) or null): A display name of up to 200 characters.\n\nExample:\n\n```json\n{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your invoice for September\",\n \"text\": \"Hi Anna, your invoice is attached.\",\n \"attachments\": [\n {\n \"name\": \"invoice.pdf\",\n \"content\": \"JVBERi0xLjcK\",\n \"contentType\": \"application/pdf\"\n }\n ],\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n }\n}\n```\n\n## Responses\n\n### 200 Accepted for delivery.\n\n- `messageId` (string (uuid), required): The id the message log, the events and the webhooks refer to.\n- `status` (\"accepted\", required)\n- `submittedAt` (string (date-time), required): When the message was accepted.\n- `test` (boolean, required): True when the server is a test server: the message goes through everything but delivery, and nobody receives it.\n\nHeaders:\n\n- `Idempotent-Replayed` (\"true\"): true when this is the first answer again, to a request sent before under the same Idempotency-Key; absent otherwise.\n\nExample:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `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. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |\n| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |\n| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |\n| `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. |\n| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |\n| `rate_limited` | 429 | More was sent within a minute than the limit allows. |\n| `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. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n\nHeaders sent with an error:\n\n- `Retry-After` (integer) on 429: With rate_limited, the seconds until a send is accepted; absent with monthly_cap_reached.\n- `Retry-After` (integer) on 503: Seconds to wait before trying again.\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `recipient_suppressed`\n\n- `streamId` (string (uuid), required): The stream whose suppression list refused the send.\n- `suppressed` (array of object, required): Each refused address, lower-cased, with the reason it is on the list.\n - `address` (string, required)\n - `reason` (\"hard_bounce\" | \"spam_complaint\" | \"manual\" | \"unsubscribe\", required)\n\nExample:\n\n```json\n{\n \"error\": \"recipient_suppressed\",\n \"message\": \"One or more recipients are on the suppression list of this stream; suppressed says which and why.\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"suppressed\": [\n {\n \"address\": \"anna@example.com\",\n \"reason\": \"hard_bounce\"\n }\n ]\n}\n```\n\n### `test_address_on_live_server`\n\n- `addresses` (array of string, required): The recipients at simulator.sendora.se, each once.\n\nExample:\n\n```json\n{\n \"error\": \"test_address_on_live_server\",\n \"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.\",\n \"addresses\": [\n \"hardbounce@simulator.sendora.se\"\n ]\n}\n```\n\n### `rate_limited`\n\n- `scope` (\"account\" | \"server\" | \"test\", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.\n- `limit` (integer, required): Emails per minute allowed for that scope.\n- `retryAfter` (integer, required): Seconds until the next send is accepted; also sent as the Retry-After header.\n\nExample:\n\n```json\n{\n \"error\": \"rate_limited\",\n \"message\": \"This server may send 60 emails per minute. Try again in 12 seconds.\",\n \"scope\": \"server\",\n \"limit\": 60,\n \"retryAfter\": 12\n}\n```\n\n### `monthly_cap_reached`\n\n- `scope` (\"account\" | \"server\" | \"test\", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.\n- `cap` (integer, required): Emails the scope may send in its month: from the day and time of the first payment for a paying account's live servers, otherwise the calendar month in UTC. After a cancellation, the emails that the monthly fee includes.\n- `used` (integer, required): Emails sent this month, the refused send not counted. After a cancellation, received emails count too.\n- `resetsAt` (string (date-time), required): When the month ends and the count starts again.\n\nExample:\n\n```json\n{\n \"error\": \"monthly_cap_reached\",\n \"message\": \"This account has used 50000 of 50000 emails this month. The cap resets at 2026-11-01T00:00:00.000Z.\",\n \"scope\": \"account\",\n \"cap\": 50000,\n \"used\": 50000,\n \"resetsAt\": \"2026-11-01T00:00:00.000Z\"\n}\n```"},{"uri":"sendora://reference/post-v1-email-batch","title":"POST /v1/email/batch","description":"Send up to 100 messages","text":"# POST /v1/email/batch\n\nSend up to 100 messages\n\nAccepts an array of messages and answers one result per message, in order: accepted with its id, or refused with the same error the single send would give. The Idempotency-Key is required. A retry under the same key sends nothing twice: every item accepted before is answered again with replayed set, and only the items that failed are attempted again.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.email.sendBatch(messages)`. Python: `sendora.email.send_batch(messages)`. PHP: `$sendora->email->sendBatch($messages)`.\n\n## Parameters\n\n- `idempotency-key` (header, required, string): Any text of 1 to 255 printable characters that identifies this batch; the same key with a different item is refused for that item.\n\n## Request body\n\nA array of object, at least 1, at most 100.\n\nExample:\n\n```json\n[\n {\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your invoice for September\",\n \"text\": \"Hi Anna, your invoice is attached.\",\n \"attachments\": [\n {\n \"name\": \"invoice.pdf\",\n \"content\": \"JVBERi0xLjcK\",\n \"contentType\": \"application/pdf\"\n }\n ],\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n }\n }\n]\n```\n\n## Responses\n\n### 200 Every message answered on its own; the batch as a whole never fails halfway.\n\n- `results` (array of one of 18, required): One result per message, in request order.\n\nThe forms of each item of `results`:\n\n- Form 1: The message was accepted for delivery.\n - `messageId` (string (uuid), required): The id the message log, the events and the webhooks refer to.\n - `status` (\"accepted\", required)\n - `submittedAt` (string (date-time), required): When the message was accepted.\n - `test` (boolean, required): True when the server is a test server: the message goes through everything but delivery, and nobody receives it.\n - `index` (integer, required): The position of the message in the request.\n - `replayed` (boolean, required): True when an earlier request under the same key already sent this item.\n\nA refusal carries `message`, `index`, `status` and `error`, one of these codes, with the fields the code adds:\n\n| Code | Meaning | Adds |\n| --- | --- | --- |\n| `invalid_request` | The message does not match what a send takes. | `issues` (`path`, `message`) |\n| `from_domain_not_verified` | The From domain is not a verified sending domain of this server. | |\n| `stream_not_found` | The streamId names no stream of this server. | |\n| `stream_archived` | The stream is archived and takes no new messages. | |\n| `stream_paused` | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. | |\n| `stream_not_sendable` | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. | |\n| `unsubscribe_placeholder_missing` | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. | |\n| `list_unsubscribe_reserved` | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. | |\n| `recipient_suppressed` | One or more recipients are on the suppression list of the stream. | `streamId`, `suppressed` (`address`, `reason`) |\n| `test_address_on_live_server` | Addresses at simulator.sendora.se act out an outcome on a test server; a live server never sends to them. | `addresses` |\n| `idempotency_key_mismatch` | The Idempotency-Key was already used for a different request. | |\n| `rate_limited` | More was sent within a minute than the limit allows. | `scope`, `limit`, `retryAfter` |\n| `monthly_cap_reached` | 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. | `scope`, `cap`, `used`, `resetsAt` |\n| `sending_disabled` | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. | |\n| `account_paused` | The account is paused by Sendora. | |\n| `account_not_active` | The account is closed, or not yet approved for a live server's sends or an inbound stream. | |\n| `payment_required` | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. | |\n\nExample:\n\n```json\n{\n \"results\": [\n {\n \"index\": 0,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"replayed\": false\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `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. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n\nHeaders sent with an error:\n\n- `Retry-After` (integer) on 503: Seconds to wait before trying again.\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-broadcasts","title":"GET /v1/broadcasts","description":"List the broadcasts","text":"# GET /v1/broadcasts\n\nList the broadcasts\n\nThe broadcasts of this server, newest first, a page at a time.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.list(query)`. Python: `sendora.broadcasts.list(limit=..., after=...)`. PHP: `$sendora->broadcasts->list(limit: $limit, after: $after)`.\n\n## Parameters\n\n- `limit` (query, integer (1 to 100)): Page size, 1 to 100.\n- `after` (query, string (uuid)): The `next` value of the previous page.\n\n## Responses\n\n### 200 A page of the broadcasts, newest first.\n\n- `broadcasts` (array of object, required)\n - `broadcastId` (string (uuid), required)\n - `streamId` (string (uuid), required): The broadcast stream the messages go on.\n - `status` (\"accepted\" | \"processing\" | \"completed\" | \"cancelled\", required): accepted until the first message leaves, processing while messages are leaving, completed when every message has left or failed, cancelled once cancelled.\n - `from` (string, required)\n - `subject` (string, required)\n - `tag` (string or null, required)\n - `metadata` (map of string to string, required)\n - `total` (integer, required): Messages stored, after suppressed addresses were dropped.\n - `released` (integer, required): Messages handed to the mail server so far.\n - `failed` (integer, required): Messages that could not be handed over, cancelled ones included.\n - `suppressed` (integer, required): Addresses dropped from the list for standing on the stream’s suppression list.\n - `submittedAt` (string (date-time), required)\n - `completedAt` (string (date-time) or null, required): Set once nothing is left to send, or on cancel.\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"broadcasts\": [\n {\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"status\": \"processing\",\n \"from\": \"news@acme.se\",\n \"subject\": \"News from Acme in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 0,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": null\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-broadcasts","title":"POST /v1/broadcasts","description":"Send a broadcast","text":"# POST /v1/broadcasts\n\nSend a broadcast\n\nStores the content once and one message per entry of `messages`, up to 50,000, on a broadcast stream, in one transaction. Each message is a message in the log with its own events and webhooks, sent with the recipient’s own unsubscribe link where `{{ unsubscribe_url }}` stands. Each message may carry `substitutions`, up to 20 strings that replace `{{ key }}` in the subject, the text and the HTML, escaped in the HTML; a key the content names must be given by every message. Addresses on the stream’s suppression list are dropped and counted; a list with nothing left, a missing placeholder, a missing substitution, a transactional or archived stream and a count over the monthly cap are refused whole. The Idempotency-Key is required: a retry under the same key answers the same broadcast.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.send(broadcast)`. Python: `sendora.broadcasts.send(**broadcast)`. PHP: `$sendora->broadcasts->send(...$broadcast)`.\n\n## Parameters\n\n- `idempotency-key` (header, required, string): Any text of 1 to 255 printable characters that identifies this broadcast; the same key with a different body is refused.\n\n## Request body\n\n- `streamId` (string (uuid), required): The broadcast stream of this server the messages go on.\n- `from` (one of 2, required): The sender. Its domain must be a verified sending domain of the server.\n- `subject` (string (at least 1, at most 998 characters), required): The subject line.\n- `text` (string (at least 1 character)): The plain-text part, with {{ unsubscribe_url }} where the link goes. At least one of text and html is required.\n- `html` (string (at least 1 character)): The HTML part, with {{ unsubscribe_url }} where the link goes.\n- `headers` (map of string to string): Custom headers on every message, at most 20. The List-Unsubscribe pair is Sendora’s too. Sendora writes these itself, so a message may not set them: From, Sender, To, Cc, Bcc, Subject, Date, Message-ID, MIME-Version, Return-Path, Received, Delivered-To, DKIM-Signature, DomainKey-Signature, Authentication-Results, Received-SPF, and any name that starts with Content-, Resent-, ARC- or X-Kumo.\n- `attachments` (array of object, at most 20): At most 20, sent with every message. A message with its attachments encoded may be at most 10 MB.\n - `name` (string, required)\n - `content` (string (base64), required)\n - `contentType` (string)\n - `contentId` (string or null)\n- `tag` (string or null): A label of up to 100 characters, returned with the message and its events.\n- `metadata` (map of string to string): Up to 20 key-value pairs of your own, on the broadcast and on every message.\n- `messages` (array of object, at least 1, at most 50000, required): One entry per message, at most 50000; a larger list is several broadcasts under one tag.\n - `to` (array of one of 2, at least 1, required): Recipients of this message; at most 50 across to, cc and bcc.\n - `cc` (array of one of 2): Copied recipients, shown in the message.\n - `bcc` (array of one of 2): Blind-copied recipients.\n - `metadata` (map of string to string): Up to 20 key-value pairs for this message, merged over the broadcast's.\n - `headers` (map of string to string): Custom headers for this message, merged over the broadcast's.\n - `substitutions` (map of string to string): Up to 20 strings that replace {{ key }} in the subject, the text and the HTML of this message; a key the content names must be given.\n\nThe forms of `from`:\n\n- Form 1: string (email)\n- Form 2\n - `email` (string (email), required)\n - `name` (string (at least 1, at most 200 characters) or null): A display name of up to 200 characters.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"from\": {\n \"email\": \"news@acme.se\",\n \"name\": \"Acme\"\n },\n \"subject\": \"News from Acme in October\",\n \"text\": \"Hi!\\n\\nHere is the news for October.\\n\\nRather not get these? {{ unsubscribe_url }}\",\n \"html\": \"<p>Hi!</p><p>Here is the news for October.</p><p><a href=\\\"{{ unsubscribe_url }}\\\">Unsubscribe</a></p>\",\n \"tag\": \"newsletter-2026-10\",\n \"messages\": [\n {\n \"to\": [\n \"anna@example.com\"\n ],\n \"metadata\": {\n \"customer\": \"1042\"\n }\n },\n {\n \"to\": [\n {\n \"email\": \"bo@example.com\",\n \"name\": \"Bo Berg\"\n }\n ]\n }\n ]\n}\n```\n\n## Responses\n\n### 200 The broadcast was stored; Sendora sends its messages from here.\n\n- `broadcastId` (string (uuid), required): The id the messages, the progress and the cancel refer to.\n- `status` (\"accepted\", required)\n- `total` (integer, required): Messages stored; each is a message in the log.\n- `suppressed` (integer, required): Addresses dropped for standing on the stream’s suppression list.\n- `submittedAt` (string (date-time), required)\n- `test` (boolean, required): True when the server is a test server: the messages go through everything but delivery, and nobody receives them.\n\nHeaders:\n\n- `Idempotent-Replayed` (\"true\"): true when this is the first answer again, to a request sent before under the same Idempotency-Key; absent otherwise.\n\nExample:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"status\": \"accepted\",\n \"total\": 2,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"test\": false\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `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. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; the streamId names a transactional one. |\n| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |\n| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |\n| `substitution_missing` | 422 | The subject, text or HTML names a {{ key }} that a message does not give. |\n| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |\n| `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. |\n| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |\n| `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. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n\nHeaders sent with an error:\n\n- `Retry-After` (integer) on 503: Seconds to wait before trying again.\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `substitution_missing`\n\n- `index` (integer, required): The position of the message in `messages`.\n- `keys` (array of string, required): The keys the content names that the message lacks.\n\nExample:\n\n```json\n{\n \"error\": \"substitution_missing\",\n \"message\": \"The content names {{ firstName }}, which message 1 does not give.\",\n \"index\": 1,\n \"keys\": [\n \"firstName\"\n ]\n}\n```\n\n### `recipient_suppressed`\n\n- `streamId` (string (uuid), required): The stream whose suppression list refused the send.\n- `suppressed` (array of object, required): Each refused address, lower-cased, with the reason it is on the list.\n - `address` (string, required)\n - `reason` (\"hard_bounce\" | \"spam_complaint\" | \"manual\" | \"unsubscribe\", required)\n\nExample:\n\n```json\n{\n \"error\": \"recipient_suppressed\",\n \"message\": \"One or more recipients are on the suppression list of this stream; suppressed says which and why.\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"suppressed\": [\n {\n \"address\": \"anna@example.com\",\n \"reason\": \"hard_bounce\"\n }\n ]\n}\n```\n\n### `test_address_on_live_server`\n\n- `addresses` (array of string, required): The recipients at simulator.sendora.se, each once.\n\nExample:\n\n```json\n{\n \"error\": \"test_address_on_live_server\",\n \"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.\",\n \"addresses\": [\n \"hardbounce@simulator.sendora.se\"\n ]\n}\n```\n\n### `monthly_cap_reached`\n\n- `scope` (\"account\" | \"server\" | \"test\", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.\n- `cap` (integer, required): Emails the scope may send in its month: from the day and time of the first payment for a paying account's live servers, otherwise the calendar month in UTC. After a cancellation, the emails that the monthly fee includes.\n- `used` (integer, required): Emails sent this month, the refused send not counted. After a cancellation, received emails count too.\n- `resetsAt` (string (date-time), required): When the month ends and the count starts again.\n\nExample:\n\n```json\n{\n \"error\": \"monthly_cap_reached\",\n \"message\": \"This account has used 50000 of 50000 emails this month. The cap resets at 2026-11-01T00:00:00.000Z.\",\n \"scope\": \"account\",\n \"cap\": 50000,\n \"used\": 50000,\n \"resetsAt\": \"2026-11-01T00:00:00.000Z\"\n}\n```"},{"uri":"sendora://reference/get-v1-broadcasts-id","title":"GET /v1/broadcasts/{id}","description":"Get a broadcast","text":"# GET /v1/broadcasts/{id}\n\nGet a broadcast\n\nThe broadcast and how far it has come: its status, the counts of messages released and failed, and what tracking recorded of its opens and clicks.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.get(broadcastId)`. Python: `sendora.broadcasts.get(broadcast_id)`. PHP: `$sendora->broadcasts->get($broadcastId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The broadcast id.\n\n## Responses\n\n### 200 A broadcast, how far it has come and what tracking recorded of it.\n\n- `broadcastId` (string (uuid), required)\n- `streamId` (string (uuid), required): The broadcast stream the messages go on.\n- `status` (\"accepted\" | \"processing\" | \"completed\" | \"cancelled\", required): accepted until the first message leaves, processing while messages are leaving, completed when every message has left or failed, cancelled once cancelled.\n- `from` (string, required)\n- `subject` (string, required)\n- `tag` (string or null, required)\n- `metadata` (map of string to string, required)\n- `total` (integer, required): Messages stored, after suppressed addresses were dropped.\n- `released` (integer, required): Messages handed to the mail server so far.\n- `failed` (integer, required): Messages that could not be handed over, cancelled ones included.\n- `suppressed` (integer, required): Addresses dropped from the list for standing on the stream’s suppression list.\n- `submittedAt` (string (date-time), required)\n- `completedAt` (string (date-time) or null, required): Set once nothing is left to send, or on cancel.\n- `opened` (integer, required): Recipients with an open, machines’ included, or a click that was likely a person’s.\n- `openedByPeople` (integer, required): Those of them likely to be people.\n- `clicked` (integer, required): Recipients who clicked a link, machines’ included.\n- `clickedByPeople` (integer, required): Those of them likely to be people.\n- `links` (array of object, required): Each tracked link that has a click, by its index, counted from the clicks each recipient keeps.\n - `index` (integer, required): The link’s position in the HTML, the same in every copy, counting from 0.\n - `url` (string or null, required): Where the first click led, since a substitution can give a link another URL per recipient; null once it has gone with the content.\n - `clicked` (integer, required): Recipients who clicked this link, machines’ included.\n - `clickedByPeople` (integer, required): Those of them likely to be people.\n\nExample:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"status\": \"processing\",\n \"from\": \"news@acme.se\",\n \"subject\": \"News from Acme in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 0,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": null,\n \"opened\": 1,\n \"openedByPeople\": 1,\n \"clicked\": 1,\n \"clickedByPeople\": 1,\n \"links\": [\n {\n \"index\": 0,\n \"url\": \"https://acme.se/news/october\",\n \"clicked\": 1,\n \"clickedByPeople\": 1\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such broadcast of this server. |"},{"uri":"sendora://reference/post-v1-broadcasts-id-cancel","title":"POST /v1/broadcasts/{id}/cancel","description":"Cancel a broadcast","text":"# POST /v1/broadcasts/{id}/cancel\n\nCancel a broadcast\n\nHolds every message of the broadcast that has not been handed to the mail server yet; those count as failed. A message already on its way is delivered. A completed or cancelled broadcast answers 409.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.cancel(broadcastId)`. Python: `sendora.broadcasts.cancel(broadcast_id)`. PHP: `$sendora->broadcasts->cancel($broadcastId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The broadcast id.\n\n## Responses\n\n### 200 A broadcast and how far it has come.\n\n- `broadcastId` (string (uuid), required)\n- `streamId` (string (uuid), required): The broadcast stream the messages go on.\n- `status` (\"accepted\" | \"processing\" | \"completed\" | \"cancelled\", required): accepted until the first message leaves, processing while messages are leaving, completed when every message has left or failed, cancelled once cancelled.\n- `from` (string, required)\n- `subject` (string, required)\n- `tag` (string or null, required)\n- `metadata` (map of string to string, required)\n- `total` (integer, required): Messages stored, after suppressed addresses were dropped.\n- `released` (integer, required): Messages handed to the mail server so far.\n- `failed` (integer, required): Messages that could not be handed over, cancelled ones included.\n- `suppressed` (integer, required): Addresses dropped from the list for standing on the stream’s suppression list.\n- `submittedAt` (string (date-time), required)\n- `completedAt` (string (date-time) or null, required): Set once nothing is left to send, or on cancel.\n\nExample:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"status\": \"cancelled\",\n \"from\": \"news@acme.se\",\n \"subject\": \"News from Acme in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 1,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": \"2026-10-01T08:05:00.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such broadcast of this server. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled already; nothing is left to cancel. |"},{"uri":"sendora://reference/get-v1-messages-id","title":"GET /v1/messages/{id}","description":"Read a message","text":"# GET /v1/messages/{id}\n\nRead a message\n\nThe message as the log holds it, its attachments described but not returned, and the events on its timeline, with what tracking recorded. Bodies are never returned.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.messages.get(messageId)`. Python: `sendora.messages.get(message_id)`. PHP: `$sendora->messages->get($messageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The message id.\n\n## Responses\n\n### 200 The message with its attachments described and its timeline.\n\n- `messageId` (string (uuid), required)\n- `streamId` (string (uuid), required): The stream the message went on.\n- `broadcastId` (string (uuid) or null, required): The broadcast the message belongs to; null for a message sent on its own.\n- `status` (\"accepted\" | \"injected\" | \"failed\", required): `accepted` until handed to the mail server, `injected` after; `failed` if that never worked.\n- `from` (string, required)\n- `subject` (string, required)\n- `tag` (string or null, required)\n- `metadata` (map of string to string, required)\n- `submittedAt` (string (date-time), required)\n- `test` (boolean, required): True when the message went through a test server: its events were simulated and nobody received it.\n- `recipients` (array of object, required)\n - `address` (string, required)\n - `kind` (\"to\" | \"cc\" | \"bcc\", required)\n - `status` (\"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\", required): queued until the receiver answers; deferred while it keeps saying try later.\n - `tracked` (boolean, required): 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.\n - `opens` (integer, required): Opens recorded, machines’ included.\n - `clicks` (integer, required): Clicks recorded, machines’ included.\n - `firstOpenAt` (string (date-time) or null, required): The first open, or the first click that was likely a person’s. Null before that.\n - `firstHumanOpenAt` (string (date-time) or null, required): The same, counting only what was likely a person. Null before that.\n- `tracking` (object, required): Whether opens and clicks are recorded now, and why tracking may have stayed out.\n - `recordingOpens` (boolean, required): Whether new opens of this message are recorded now, by its own setting, then its broadcast’s, then its stream’s at this moment. This says nothing of what a copy already sent carries.\n - `recordingClicks` (boolean, required): The same for clicks on its links.\n - `skipped` (\"no_tracking_domain\" | \"gmail_clipping\" or null, required): Why tracking stayed out of at least one copy: no_tracking_domain when the from domain had no working tracking domain, gmail_clipping when tracked links would have taken the HTML past where Gmail clips it. Null otherwise. Each recipient’s tracked says whether its copy was tracked.\n- `attachments` (array of object, required)\n - `name` (string, required)\n - `contentType` (string, required)\n - `contentId` (string or null, required)\n - `size` (integer, required): Bytes before encoding.\n- `events` (array of object, required): Oldest first. Opens and clicks are capped as Limits says, while each recipient’s opens and clicks count them all.\n - `recipient` (string or null, required): The recipient the event is about; null when it concerns the message as a whole.\n - `type` (\"Reception\" | \"Delivery\" | \"Bounce\" | \"TransientFailure\" | \"Expiration\" | \"AdminBounce\" | \"OOB\" | \"Feedback\" | \"AdminRebind\" | \"DeferredInjectionRebind\" | \"Rejection\" | \"Delayed\" | \"Open\" | \"Click\", required): Reception when the mail server took it, Delivery, TransientFailure, Bounce, OOB for a bounce report received by mail, Feedback for a spam complaint, Open and Click for what tracking recorded.\n - `at` (string (date-time), required)\n - `code` (integer or null, required): The SMTP status code the receiver answered.\n - `details` (string or null, required): The receiver’s response text.\n - `classification` (string or null, required): How a bounce was classified, such as InvalidRecipient.\n - `tracking` (object or null, required): What tracking knows of an Open or a Click; null on any other event.\n - `automated` (\"likely\" | \"unlikely\" | \"unknown\", required): Whether a machine rather than a person did it: likely, unlikely, or unknown where nobody can tell, as with Apple Mail.\n - `agent` (\"apple_privacy\" | \"gmail_prefetch\" | \"gmail_proxy\" | \"yahoo_proxy\" | \"outlook_prefetch\" | \"proton_proxy\" | \"scanner\" | \"crawler\" or null, required): The proxy or machine it came through, where Sendora knows it.\n - `reasons` (array of \"privacy_proxy\" | \"prefetch_signature\" | \"delivery_timing\" | \"link_burst\" | \"scanner_network\" | \"bot_user_agent\" | \"many_networks\", required): Why it was labelled a machine’s.\n - `client` (\"apple_mail\" | \"gmail\" | \"outlook\" | \"yahoo\" | \"thunderbird\" | \"proton\" | \"browser\" | \"other\" or null, required): The mail program, where Sendora knows it.\n - `device` (\"desktop\" | \"mobile\" | \"tablet\" or null, required): The kind of device, where Sendora knows it.\n - `first` (boolean, required): The recipient’s first event of this kind.\n - `firstHuman` (boolean, required): The recipient’s first event of this kind that was likely a person’s.\n - `link` (object or null, required): The link clicked; null on an Open.\n - `index` (integer, required): The link’s position in the HTML, the same in every copy, counting from 0.\n - `url` (string or null, required): Where the link led; null once it has gone with the message’s text and HTML.\n\nExample:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your invoice for September\",\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": true,\n \"opens\": 2,\n \"clicks\": 1,\n \"firstOpenAt\": \"2026-09-15T12:03:10.000Z\",\n \"firstHumanOpenAt\": \"2026-09-15T12:03:10.000Z\"\n }\n ],\n \"tracking\": {\n \"recordingOpens\": true,\n \"recordingClicks\": true,\n \"skipped\": null\n },\n \"attachments\": [\n {\n \"name\": \"invoice.pdf\",\n \"contentType\": \"application/pdf\",\n \"contentId\": null,\n \"size\": 48213\n }\n ],\n \"events\": [\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Reception\",\n \"at\": \"2026-09-15T12:00:00.000Z\",\n \"code\": 250,\n \"details\": null,\n \"classification\": null,\n \"tracking\": null\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Delivery\",\n \"at\": \"2026-09-15T12:00:02.000Z\",\n \"code\": 250,\n \"details\": \"2.0.0 OK\",\n \"classification\": null,\n \"tracking\": null\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Open\",\n \"at\": \"2026-09-15T12:03:10.000Z\",\n \"code\": null,\n \"details\": null,\n \"classification\": null,\n \"tracking\": {\n \"automated\": \"unlikely\",\n \"agent\": null,\n \"reasons\": [],\n \"client\": \"apple_mail\",\n \"device\": \"mobile\",\n \"first\": true,\n \"firstHuman\": true,\n \"link\": null\n }\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Click\",\n \"at\": \"2026-09-15T12:03:40.000Z\",\n \"code\": null,\n \"details\": null,\n \"classification\": null,\n \"tracking\": {\n \"automated\": \"unlikely\",\n \"agent\": null,\n \"reasons\": [],\n \"client\": \"apple_mail\",\n \"device\": \"mobile\",\n \"first\": true,\n \"firstHuman\": true,\n \"link\": {\n \"index\": 0,\n \"url\": \"https://example.se/invoices/2026-0912\"\n }\n }\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such message of this server. |"},{"uri":"sendora://reference/post-v1-messages-search","title":"POST /v1/messages/search","description":"Search the message log","text":"# POST /v1/messages/search\n\nSearch the message log\n\nThe messages of the server, newest first, one page at a time. A POST so that an address travels in the body and never in a URL. Messages older than the retention window are gone.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.messages.search(filters)`. Python: `sendora.messages.search(**filters)`. PHP: `$sendora->messages->search(...$filters)`.\n\n## Request body\n\n- `recipient` (string (at least 3, at most 254 characters)): Messages to this address, in to, cc or bcc. A whole address matches exactly. Anything else of three characters or more matches anywhere in an address, such as kommun.se.\n- `streamId` (string (uuid)): Messages on this stream of the server.\n- `broadcastId` (string (uuid)): Messages of this broadcast.\n- `tag` (string (at least 1, at most 100 characters))\n- `status` (one of 2): Messages with at least one recipient in this state, or in any of these states.\n- `from` (string (date-time)): Submitted at or after this time.\n- `to` (string (date-time)): Submitted before this time.\n- `limit` (integer (1 to 100)): Page size, 1 to 100.\n- `after` (string (uuid)): The `next` value of the previous page.\n\nThe forms of `status`:\n\n- Form 1: \"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\"\n- Form 2: array of \"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\", at least 1, at most 5\n\nExample:\n\n```json\n{\n \"recipient\": \"anna@example.com\",\n \"status\": \"bounced\",\n \"limit\": 20\n}\n```\n\n## Responses\n\n### 200 A page of the messages that match, newest first.\n\n- `messages` (array of object, required)\n - `messageId` (string (uuid), required)\n - `streamId` (string (uuid), required): The stream the message went on.\n - `broadcastId` (string (uuid) or null, required): The broadcast the message belongs to; null for a message sent on its own.\n - `status` (\"accepted\" | \"injected\" | \"failed\", required): `accepted` until handed to the mail server, `injected` after; `failed` if that never worked.\n - `from` (string, required)\n - `subject` (string, required)\n - `tag` (string or null, required)\n - `metadata` (map of string to string, required)\n - `submittedAt` (string (date-time), required)\n - `test` (boolean, required): True when the message went through a test server: its events were simulated and nobody received it.\n - `recipients` (array of object, required)\n - `address` (string, required)\n - `kind` (\"to\" | \"cc\" | \"bcc\", required)\n - `status` (\"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\", required): queued until the receiver answers; deferred while it keeps saying try later.\n - `tracked` (boolean, required): 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.\n - `opens` (integer, required): Opens recorded, machines’ included.\n - `clicks` (integer, required): Clicks recorded, machines’ included.\n - `firstOpenAt` (string (date-time) or null, required): The first open, or the first click that was likely a person’s. Null before that.\n - `firstHumanOpenAt` (string (date-time) or null, required): The same, counting only what was likely a person. Null before that.\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"messages\": [\n {\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your invoice for September\",\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": true,\n \"opens\": 2,\n \"clicks\": 1,\n \"firstOpenAt\": \"2026-09-15T12:03:10.000Z\",\n \"firstHumanOpenAt\": \"2026-09-15T12:03:10.000Z\"\n }\n ]\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-inbound-search","title":"POST /v1/inbound/search","description":"Search received messages","text":"# POST /v1/inbound/search\n\nSearch received messages\n\nThe messages the server has received, newest first, one page at a time. A POST so that an address travels in the body and never in a URL. The sender is matched by keyed hash, so the search itself stores no address. Messages older than 13 months are gone.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inbound.search(filters)`. Python: `sendora.inbound.search(**filters)`. PHP: `$sendora->inbound->search(...$filters)`.\n\n## Request body\n\n- `streamId` (string (uuid)): Messages received on this inbound stream of the server.\n- `from` (string (email)): Messages from this address, in the envelope or the From header.\n- `recipient` (string (email)): Messages sent to this address of yours, the envelope recipient.\n- `mailboxHash` (string (at most 255 characters)): Messages whose address carried this text after the plus sign.\n- `receivedFrom` (string (date-time)): Accepted at or after this time.\n- `receivedTo` (string (date-time)): Accepted before this time.\n- `limit` (integer (1 to 100)): Page size, 1 to 100.\n- `after` (string (uuid)): The `next` value of the previous page.\n\nExample:\n\n```json\n{\n \"from\": \"anna@example.com\",\n \"receivedFrom\": \"2026-09-15T00:00:00+02:00\",\n \"limit\": 20\n}\n```\n\n## Responses\n\n### 200 A page of the received messages that match, newest first.\n\n- `messages` (array of object, required)\n - `inboundMessageId` (string (uuid), required)\n - `streamId` (string (uuid), required): The inbound stream that received it.\n - `receivedAt` (string (date-time), required): When the message was accepted from the sending server.\n - `envelopeRecipient` (string, required): The address of yours the message was sent to.\n - `mailboxHash` (string or null, required): The text after the plus sign in that address, when the sender used one.\n - `sizeBytes` (integer, required)\n - `attachmentCount` (integer, required)\n - `hasText` (boolean, required)\n - `hasHtml` (boolean, required)\n - `parseIssue` (string or null, required): What the parser met; null when the message parsed clean. A hard issue empties the parsed parts, and the raw message stays: `no_headers`, `header_block_too_large`, `too_many_headers`, `too_many_parts`, `nesting_too_deep`, `missing_boundary`, `decoded_too_large`, `parse_timeout`, `parser_error`. A soft issue keeps them and says what was changed or dropped: `truncated_multipart`, `too_many_attachments`, `unknown_transfer_encoding`, `undecodable_container`, `filename_sanitised`, `control_chars_stripped`, `multiple_from`, `duplicate_header`, `malformed_header_dropped`, `address_list_truncated`, `header_value_truncated`. A message with several names the hard one, or the first soft one in this order.\n - `authentication` (object, required): What Sendora found when it checked the message; unchecked until the checks have run.\n - `spf` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the address in MAIL FROM.\n - `spfHelo` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the name the sending server gave in HELO.\n - `dkim` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarc` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarcPolicy` (\"none\" | \"quarantine\" | \"reject\" or null, required): What the sender’s domain asks for; told only when DMARC failed.\n - `arc` (\"none\" | \"pass\" | \"fail\" | \"unchecked\", required)\n - `checkedAt` (string (date-time) or null, required): When the checks ran; null until they have.\n - `contentAvailable` (boolean, required): False once the stream’s content window has passed; only the reference remains.\n - `contentExpiresAt` (string (date-time), required): When the content goes.\n - `from` (object or null, required): The From header; null without it or once the content is gone.\n - `address` (string, required)\n - `name` (string or null, required)\n - `subject` (string or null, required)\n - `date` (string (date-time) or null, required): The sender’s Date header as ISO 8601, when it was a real moment.\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"messages\": [\n {\n \"inboundMessageId\": \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"receivedAt\": \"2026-09-19T08:00:02.000Z\",\n \"envelopeRecipient\": \"7c9e6679742540de944be07fc1f90ae7@inbound.sendora.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 48213,\n \"attachmentCount\": 1,\n \"hasText\": true,\n \"hasHtml\": false,\n \"parseIssue\": null,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-19T08:00:03.000Z\"\n },\n \"contentAvailable\": true,\n \"contentExpiresAt\": \"2026-10-19T08:00:02.000Z\",\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"subject\": \"A question about my order\",\n \"date\": \"2026-09-19T08:00:00.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-inbound-id","title":"GET /v1/inbound/{id}","description":"Read a received message","text":"# GET /v1/inbound/{id}\n\nRead a received message\n\nThe message with its envelope, parties, headers, text, HTML and attachment descriptors; the attachment bytes and the raw message are downloads of their own. Once the stream’s content window has passed, the content fields are null and contentAvailable is false.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inbound.get(inboundMessageId)`. Python: `sendora.inbound.get(inbound_message_id)`. PHP: `$sendora->inbound->get($inboundMessageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n\n## Responses\n\n### 200 The received message with everything but the attachment bytes.\n\n- `inboundMessageId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream that received it.\n- `receivedAt` (string (date-time), required): When the message was accepted from the sending server.\n- `envelopeRecipient` (string, required): The address of yours the message was sent to.\n- `mailboxHash` (string or null, required): The text after the plus sign in that address, when the sender used one.\n- `sizeBytes` (integer, required)\n- `attachmentCount` (integer, required)\n- `hasText` (boolean, required)\n- `hasHtml` (boolean, required)\n- `parseIssue` (string or null, required): What the parser met; null when the message parsed clean. A hard issue empties the parsed parts, and the raw message stays: `no_headers`, `header_block_too_large`, `too_many_headers`, `too_many_parts`, `nesting_too_deep`, `missing_boundary`, `decoded_too_large`, `parse_timeout`, `parser_error`. A soft issue keeps them and says what was changed or dropped: `truncated_multipart`, `too_many_attachments`, `unknown_transfer_encoding`, `undecodable_container`, `filename_sanitised`, `control_chars_stripped`, `multiple_from`, `duplicate_header`, `malformed_header_dropped`, `address_list_truncated`, `header_value_truncated`. A message with several names the hard one, or the first soft one in this order.\n- `authentication` (object, required): What Sendora found when it checked the message; unchecked until the checks have run.\n - `spf` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the address in MAIL FROM.\n - `spfHelo` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the name the sending server gave in HELO.\n - `dkim` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarc` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarcPolicy` (\"none\" | \"quarantine\" | \"reject\" or null, required): What the sender’s domain asks for; told only when DMARC failed.\n - `arc` (\"none\" | \"pass\" | \"fail\" | \"unchecked\", required)\n - `checkedAt` (string (date-time) or null, required): When the checks ran; null until they have.\n- `contentAvailable` (boolean, required): False once the stream’s content window has passed; only the reference remains.\n- `contentExpiresAt` (string (date-time), required): When the content goes.\n- `from` (object or null, required): The From header; null without it or once the content is gone.\n - `address` (string, required)\n - `name` (string or null, required)\n- `subject` (string or null, required)\n- `date` (string (date-time) or null, required): The sender’s Date header as ISO 8601, when it was a real moment.\n- `envelope` (object or null, required): Null once the content is gone.\n - `sender` (string or null, required): The MAIL FROM address; null for a bounce.\n- `replyTo` (array of object, required)\n - `address` (string, required)\n - `name` (string or null, required)\n- `to` (array of object, required): Up to 100 entries; toCount is the whole number.\n - `address` (string, required)\n - `name` (string or null, required)\n- `toCount` (integer, required)\n- `cc` (array of object, required): Up to 100 entries; ccCount is the whole number.\n - `address` (string, required)\n - `name` (string or null, required)\n- `ccCount` (integer, required)\n- `messageIdHeader` (string or null, required)\n- `inReplyTo` (string or null, required)\n- `references` (array of string, required)\n- `headers` (array of object or null, required): Every header in order; null once the content is gone.\n - `name` (string, required)\n - `value` (string, required)\n- `text` (string or null, required): The plain-text body; null without one or once the content is gone.\n- `html` (string or null, required): The HTML body as received; null without one or once the content is gone.\n- `attachments` (array of object, required)\n - `attachmentId` (string (uuid), required)\n - `position` (integer, required): Its place among the message’s attachments, from 0.\n - `name` (string or null, required): The filename, sanitised; null once the content is gone.\n - `contentType` (string or null, required): The type the sender declared; every download is served as application/octet-stream.\n - `contentId` (string or null, required): The Content-ID an HTML body refers to with cid:.\n - `size` (integer, required): Bytes.\n - `inline` (boolean, required): True for a part shown in the body rather than offered as a file.\n\nExample:\n\n```json\n{\n \"inboundMessageId\": \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"receivedAt\": \"2026-09-19T08:00:02.000Z\",\n \"envelopeRecipient\": \"7c9e6679742540de944be07fc1f90ae7@inbound.sendora.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 48213,\n \"attachmentCount\": 1,\n \"hasText\": true,\n \"hasHtml\": false,\n \"parseIssue\": null,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-19T08:00:03.000Z\"\n },\n \"contentAvailable\": true,\n \"contentExpiresAt\": \"2026-10-19T08:00:02.000Z\",\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"subject\": \"A question about my order\",\n \"date\": \"2026-09-19T08:00:00.000Z\",\n \"envelope\": {\n \"sender\": \"anna@example.com\"\n },\n \"replyTo\": [],\n \"to\": [\n {\n \"address\": \"7c9e6679742540de944be07fc1f90ae7@inbound.sendora.se\",\n \"name\": null\n }\n ],\n \"toCount\": 1,\n \"cc\": [],\n \"ccCount\": 0,\n \"messageIdHeader\": \"<question-1@example.com>\",\n \"inReplyTo\": null,\n \"references\": [],\n \"headers\": [\n {\n \"name\": \"From\",\n \"value\": \"Anna Andersson <anna@example.com>\"\n },\n {\n \"name\": \"Subject\",\n \"value\": \"A question about my order\"\n }\n ],\n \"text\": \"Hi!\\n\",\n \"html\": null,\n \"attachments\": [\n {\n \"attachmentId\": \"9b7e2c41-3f6d-4a8e-b2c5-1d0f7e6a9c58\",\n \"position\": 0,\n \"name\": \"invoice.pdf\",\n \"contentType\": \"application/pdf\",\n \"contentId\": null,\n \"size\": 46102,\n \"inline\": false\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound message of this server. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |"},{"uri":"sendora://reference/get-v1-inbound-id-raw","title":"GET /v1/inbound/{id}/raw","description":"Download the raw message","text":"# GET /v1/inbound/{id}/raw\n\nDownload the raw message\n\nThe message byte for byte as it was received, as message/rfc822 under Content-Disposition: attachment. 410 once the content window has passed.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inbound.raw(inboundMessageId)`. Python: `sendora.inbound.raw(inbound_message_id)`. PHP: `$sendora->inbound->raw($inboundMessageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n\n## Responses\n\n### 200 The raw message.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound message of this server. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |\n| `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. |"},{"uri":"sendora://reference/get-v1-inbound-id-attachments-attachmentid","title":"GET /v1/inbound/{id}/attachments/{attachmentId}","description":"Download an attachment","text":"# GET /v1/inbound/{id}/attachments/{attachmentId}\n\nDownload an attachment\n\nThe attachment’s bytes as application/octet-stream whatever the sender declared, under Content-Disposition: attachment with its sanitised name. 410 once the content window has passed.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inbound.attachment(inboundMessageId, attachmentId)`. Python: `sendora.inbound.attachment(inbound_message_id, attachment_id)`. PHP: `$sendora->inbound->attachment($inboundMessageId, $attachmentId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n- `attachmentId` (path, required, string (uuid)): The attachment id, from the message.\n\n## Responses\n\n### 200 The attachment.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound message of this server. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |\n| `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. |"},{"uri":"sendora://reference/get-v1-inbound-domains","title":"GET /v1/inbound/domains","description":"List the inbound domains","text":"# GET /v1/inbound/domains\n\nList the inbound domains\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.list()`. Python: `sendora.inbound_domains.list()`. PHP: `$sendora->inboundDomains->list()`.\n\n## Responses\n\n### 200 Every inbound domain of the server.\n\n- `domains` (array of object, required)\n - `inboundDomainId` (string (uuid), required)\n - `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n - `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n - `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n - `createdAt` (string (date-time), required)\n - `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n - `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n - `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n - `txt` (object, required): The TXT record that proves the claim; it carries a value only you were shown.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"domains\": [\n {\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-inbound-domains","title":"POST /v1/inbound/domains","description":"Claim a domain for an inbound stream","text":"# POST /v1/inbound/domains\n\nClaim a domain for an inbound stream\n\nClaims the domain for one of the server’s inbound streams and answers the two DNS records to add: an MX that brings the domain’s mail to Sendora and a TXT record that proves the claim. Mail to any address on the domain is accepted once both records are seen, which Sendora checks on its own and on request. A stream holds one domain; the first account to verify a name holds it.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.create({ streamId, domain })`. Python: `sendora.inbound_domains.create(stream_id=..., domain=...)`. PHP: `$sendora->inboundDomains->create(streamId: $streamId, domain: $domain)`.\n\n## Request body\n\n- `streamId` (string (uuid), required): An inbound stream of the server.\n- `domain` (string, required): The domain mail is sent from, such as example.se; lower-cased and IDNA-encoded.\n\nExample:\n\n```json\n{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\"\n}\n```\n\n## Responses\n\n### 201 A domain on an inbound stream and the two records it needs.\n\n- `inboundDomainId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n- `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n- `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n- `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `txt` (object, required): The TXT record that proves the claim; it carries a value only you were shown.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `domain_reserved` | 400 | The name is Sendora’s own, or a return-path host; no account can receive on it. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `inbound_domain_exists` | 409 | The stream already has a domain, or another account holds the name verified. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `inbound_domain_exists`\n\n- `inboundDomainId` (string (uuid) or null, required): The domain the stream already has, when that is the reason; null when another account holds the name verified.\n\nExample:\n\n```json\n{\n \"error\": \"inbound_domain_exists\",\n \"message\": \"The stream already has a domain; remove it first.\",\n \"inboundDomainId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-inbound-domains-id","title":"GET /v1/inbound/domains/{id}","description":"Read an inbound domain","text":"# GET /v1/inbound/domains/{id}\n\nRead an inbound domain\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.get(inboundDomainId)`. Python: `sendora.inbound_domains.get(inbound_domain_id)`. PHP: `$sendora->inboundDomains->get($inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 200 A domain on an inbound stream and the two records it needs.\n\n- `inboundDomainId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n- `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n- `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n- `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `txt` (object, required): The TXT record that proves the claim; it carries a value only you were shown.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound domain of this server. |"},{"uri":"sendora://reference/delete-v1-inbound-domains-id","title":"DELETE /v1/inbound/domains/{id}","description":"Remove an inbound domain","text":"# DELETE /v1/inbound/domains/{id}\n\nRemove an inbound domain\n\nMail to the domain is refused from then on, and the name is free for another claim. What was received stays on the stream.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.delete(inboundDomainId)`. Python: `sendora.inbound_domains.delete(inbound_domain_id)`. PHP: `$sendora->inboundDomains->delete($inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound domain of this server. |"},{"uri":"sendora://reference/post-v1-inbound-domains-id-verify","title":"POST /v1/inbound/domains/{id}/verify","description":"Check the records now","text":"# POST /v1/inbound/domains/{id}/verify\n\nCheck the records now\n\nLooks both records up at once and records the result; the answer carries the domain as it stands and what each lookup found. A TXT record that is right while another account holds the name verified reads as mismatch.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.verify(inboundDomainId)`. Python: `sendora.inbound_domains.verify(inbound_domain_id)`. PHP: `$sendora->inboundDomains->verify($inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 200 The domain as it stands, with what each lookup found.\n\n- `inboundDomainId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n- `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n- `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n- `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `txt` (object, required): The TXT record that proves the claim; it carries a value only you were shown.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `check` (object, required)\n - `mx` (\"ok\" | \"missing\" | \"mismatch\" | \"dns_error\", required): dns_error is a resolver failure, such as a DNSSEC problem or a timeout, not an absent record.\n - `txt` (\"ok\" | \"missing\" | \"mismatch\" | \"dns_error\", required): dns_error is a resolver failure, such as a DNSSEC problem or a timeout, not an absent record.\n\nExample:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"check\": {\n \"mx\": \"missing\",\n \"txt\": \"missing\"\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound domain of this server. |"},{"uri":"sendora://reference/get-v1-streams","title":"GET /v1/streams","description":"List the streams","text":"# GET /v1/streams\n\nList the streams\n\nEvery stream of the server, archived ones included, oldest first.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.streams.list()`. Python: `sendora.streams.list()`. PHP: `$sendora->streams->list()`.\n\n## Responses\n\n### 200 Every stream of the server, archived ones included, oldest first.\n\n- `streams` (array of object, required)\n - `streamId` (string (uuid), required): What a send names as streamId.\n - `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n - `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n - `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n - `createdAt` (string (date-time), required)\n - `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n - `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n - `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n - `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streams\": [\n {\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"kind\": \"transactional\",\n \"name\": \"transactional\",\n \"isDefault\": true,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n },\n {\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n },\n {\n \"streamId\": \"9b2f7c41-3e5d-4a8b-9c1e-2d3f4a5b6c7d\",\n \"kind\": \"inbound\",\n \"name\": \"Support\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": \"9b2f7c413e5d4a8b9c1e2d3f4a5b6c7d@inbound.sendora.se\",\n \"contentRetentionDays\": 14\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-streams","title":"POST /v1/streams","description":"Create a stream","text":"# POST /v1/streams\n\nCreate a stream\n\nAdds a stream to this server. A transactional or a broadcast stream is available to every account; an inbound stream receives mail at its own address, and a server has one at a time, and a test server none. Each sending stream has a suppression list of its own.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.streams.create({ kind, name })`. Python: `sendora.streams.create(kind=..., name=...)`. PHP: `$sendora->streams->create(kind: $kind, name: $name)`.\n\n## Request body\n\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional or broadcast for sending, open to every account; a server has one live inbound stream.\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n\nExample:\n\n```json\n{\n \"kind\": \"transactional\",\n \"name\": \"Notices\"\n}\n```\n\n## Responses\n\n### 201 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `stream_exists` | 409 | The server already has a stream with this name, archived or not. |\n| `inbound_stream_exists` | 409 | A server has one inbound stream at a time; archive it to make another. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `stream_exists`\n\n- `streamId` (string (uuid), required): The stream that already carries the name.\n\nExample:\n\n```json\n{\n \"error\": \"stream_exists\",\n \"message\": \"This server already has a stream with that name.\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```\n\n### `inbound_stream_exists`\n\n- `streamId` (string (uuid), required): The server's live inbound stream.\n\nExample:\n\n```json\n{\n \"error\": \"inbound_stream_exists\",\n \"message\": \"This server already has an inbound stream. Archive it to make another; mail to the old address is then refused.\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-streams-id","title":"GET /v1/streams/{id}","description":"Read a stream","text":"# GET /v1/streams/{id}\n\nRead a stream\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.streams.get(streamId)`. Python: `sendora.streams.get(stream_id)`. PHP: `$sendora->streams->get($streamId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such stream of this server. |"},{"uri":"sendora://reference/patch-v1-streams-id","title":"PATCH /v1/streams/{id}","description":"Update a stream","text":"# PATCH /v1/streams/{id}\n\nUpdate a stream\n\nThe id stays. A new name must be free within the server. contentRetentionDays applies to an inbound stream only and sets how long received content is kept.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. 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)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters)): 1 to 100 characters.\n- `contentRetentionDays` (integer (1 to 30) or null): Days an inbound stream keeps received content, 1 to 30; null for the default of 30.\n\nExample:\n\n```json\n{\n \"name\": \"Driftinformation\"\n}\n```\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Driftinformation\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such stream of this server. |\n| `stream_exists` | 409 | The server already has a stream with this name, archived or not. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `stream_exists`\n\n- `streamId` (string (uuid), required): The stream that already carries the name.\n\nExample:\n\n```json\n{\n \"error\": \"stream_exists\",\n \"message\": \"This server already has a stream with that name.\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/post-v1-streams-id-archive","title":"POST /v1/streams/{id}/archive","description":"Archive a stream","text":"# POST /v1/streams/{id}/archive\n\nArchive a stream\n\nThe stream takes no new messages from now on; what it already holds is still delivered, and its log and suppression list stay readable. An archived inbound stream refuses mail to its address, and the server may create another. Archiving an archived stream changes nothing. The default stream cannot be archived.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.streams.archive(streamId)`. Python: `sendora.streams.archive(stream_id)`. PHP: `$sendora->streams->archive($streamId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": \"2026-09-16T13:00:00.000Z\",\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such stream of this server. |\n| `default_stream` | 409 | The default stream of a server cannot be archived. |"},{"uri":"sendora://reference/get-v1-server","title":"GET /v1/server","description":"Read this key's server","text":"# GET /v1/server\n\nRead this key's server\n\nThe server the key belongs to, whether it is live or a test server, every limit a send on it is held to with how much of it this minute and this month have used, the account's cap on received mail, and the sending domains a from address on it may use.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.server.get()`. Python: `sendora.server.get()`. PHP: `$sendora->server->get()`.\n\n## Responses\n\n### 200 The server this key belongs to, its mode, its limits with their use and the domains it may send from.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n- `limits` (array of object, required): Every limit a single send is held to: on a live server the account's per-minute limit and monthly cap, and the server's own where Sendora set them; on a test server the test cap its account's test servers share.\n - `scope` (\"account\" | \"server\" | \"test\", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.\n - `period` (\"minute\" | \"month\", required): The UTC minute, or the month the limit counts over: from the day and time of the first payment for a paying account's live server, otherwise the calendar month in UTC.\n - `limit` (integer, required): Emails allowed in the period, one per recipient.\n - `used` (integer, required): Emails the period has taken so far.\n - `resetsAt` (string (date-time), required): When the period ends and the count starts again.\n- `inboundCap` (object or null, required): The account's monthly cap on received mail and its use; null on a test server, which receives no mail.\n - `limit` (integer, required): Emails the account may receive in its month, over every inbound stream. A paying account's month runs from the day and time of its first payment, and any other account's is the calendar month in UTC. It is the account's monthly cap on sending until an administrator moves it in the dashboard.\n - `used` (integer, required): Emails received this month so far, one per recipient.\n - `resetsAt` (string (date-time), required): When the month ends and the count starts again.\n- `sendingDomains` (array of object, required): The account's sending domains a from address on this server may use now: every verified one, one in its 24-hour warning with failingSince set. Sending domains are the account's; an account key or the dashboard adds one.\n - `domain` (string, required): The domain, as the part of a from address after the @.\n - `failingSince` (string (date-time) or null, required): When a check first found one of its records missing or wrong; mail keeps going out, and the first check 24 hours after this that still misses one ends the verification. Null while every record is in place.\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"limits\": [\n {\n \"scope\": \"account\",\n \"period\": \"minute\",\n \"limit\": 300,\n \"used\": 4,\n \"resetsAt\": \"2026-09-24T10:01:00.000Z\"\n },\n {\n \"scope\": \"account\",\n \"period\": \"month\",\n \"limit\": 10000,\n \"used\": 812,\n \"resetsAt\": \"2026-10-01T00:00:00.000Z\"\n }\n ],\n \"inboundCap\": {\n \"limit\": 10000,\n \"used\": 214,\n \"resetsAt\": \"2026-10-01T00:00:00.000Z\"\n },\n \"sendingDomains\": [\n {\n \"domain\": \"example.se\",\n \"failingSince\": null\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/get-v1-servers","title":"GET /v1/servers","description":"List the servers","text":"# GET /v1/servers\n\nList the servers\n\nEvery server of the account, oldest first.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.list()`. Python: `account.servers.list()`. PHP: `$account->servers->list()`.\n\n## Responses\n\n### 200 Every server of the account, oldest first.\n\n- `servers` (array of object, required)\n - `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n - `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n - `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n - `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"servers\": [\n {\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\"\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-servers","title":"POST /v1/servers","description":"Create a server","text":"# POST /v1/servers\n\nCreate a server\n\nAdds a server to the account with its default transactional stream and its first key, whose value is answered this once. A server holds up to two live keys, so a second can be created before the first is revoked. Names are unique within the account, and the account holds up to its cap of servers.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.create({ name })`. Python: `account.servers.create(name=...)`. PHP: `$account->servers->create(name: $name)`.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n- `mode` (\"live\" | \"test\"): live unless told otherwise. A test server’s keys start sk_test_, and addresses at simulator.sendora.se act out a delivery, a bounce, a deferral or a complaint on it.\n\nExample:\n\n```json\n{\n \"name\": \"Invoicing\"\n}\n```\n\n## Responses\n\n### 201 The new server with its first key, its value shown this once.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n- `token` (object, required): The server’s first key, named default; its value is never shown again.\n - `tokenId` (string (uuid), required)\n - `name` (string, required): The name given at creation, for people to tell keys apart.\n - `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n - `createdAt` (string (date-time), required)\n - `revokedAt` (string (date-time) or null, required)\n - `token` (string, required): The key itself, shown this once.\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"token\": {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_limit` | 409 | The account has as many servers as it may; Sendora raises the cap on request. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `server_limit`\n\n- `maxServers` (integer, required): Servers the account may have at once.\n\nExample:\n\n```json\n{\n \"error\": \"server_limit\",\n \"message\": \"The account may have at most 10 servers.\",\n \"maxServers\": 10\n}\n```"},{"uri":"sendora://reference/get-v1-servers-id","title":"GET /v1/servers/{id}","description":"Read a server","text":"# GET /v1/servers/{id}\n\nRead a server\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.get(serverId)`. Python: `account.servers.get(server_id)`. PHP: `$account->servers->get($serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 200 A server of the account: an isolation boundary with its own keys, streams, log, suppressions and webhooks.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |"},{"uri":"sendora://reference/patch-v1-servers-id","title":"PATCH /v1/servers/{id}","description":"Rename a server","text":"# PATCH /v1/servers/{id}\n\nRename a server\n\nThe id, the keys, the mode and everything the server holds stay. The new name must be free within the account. A body that names a mode is refused: a server is live or test from its creation.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.update(serverId, { name })`. Python: `account.servers.update(server_id, name=...)`. PHP: `$account->servers->update($serverId, name: $name)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n\nExample:\n\n```json\n{\n \"name\": \"Ekonomi\"\n}\n```\n\n## Responses\n\n### 200 A server of the account: an isolation boundary with its own keys, streams, log, suppressions and webhooks.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Ekonomi\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `mode_immutable` | 400 | A server’s mode is fixed when it is created; create a new server for the other mode. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/delete-v1-servers-id","title":"DELETE /v1/servers/{id}","description":"Delete a server","text":"# DELETE /v1/servers/{id}\n\nDelete a server\n\nRemoves the server with its keys, streams, messages, suppressions and webhooks. Refused while messages of the server are still being delivered; try again once they have left. What the server sent stays counted on the account’s usage.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.delete(serverId)`. Python: `account.servers.delete(server_id)`. PHP: `$account->servers->delete($serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |\n| `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; try again once they have left. |"},{"uri":"sendora://reference/get-v1-servers-id-tokens","title":"GET /v1/servers/{id}/tokens","description":"List the keys of a server","text":"# GET /v1/servers/{id}/tokens\n\nList the keys of a server\n\nEvery key of the server, revoked ones included.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.tokens.list(serverId)`. Python: `account.servers.tokens.list(server_id)`. PHP: `$account->servers->tokens->list($serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 200 Every key of the server, revoked ones included.\n\n- `tokens` (array of object, required)\n - `tokenId` (string (uuid), required)\n - `name` (string, required): The name given at creation, for people to tell keys apart.\n - `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n - `createdAt` (string (date-time), required)\n - `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokens\": [\n {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |"},{"uri":"sendora://reference/post-v1-servers-id-tokens","title":"POST /v1/servers/{id}/tokens","description":"Create a key for a server","text":"# POST /v1/servers/{id}/tokens\n\nCreate a key for a server\n\nCreates another live key for the server and answers its value once; it is never shown again. Every key of a server has the same rights. A server holds at most two live keys: the one in use and the one being rotated in.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.tokens.create(serverId, { name })`. Python: `account.servers.tokens.create(server_id, name=...)`. PHP: `$account->servers->tokens->create($serverId, name: $name)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n\nExample:\n\n```json\n{\n \"name\": \"Invoicing system\"\n}\n```\n\n## Responses\n\n### 201 The new key, its value shown this once.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n- `token` (string, required): The key itself, shown this once.\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |\n| `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. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `token_limit`\n\n- `max` (integer, required): Live keys the owner may hold at once.\n\nExample:\n\n```json\n{\n \"error\": \"token_limit\",\n \"message\": \"The server may hold at most 2 live keys; revoke one before creating another.\",\n \"max\": 2\n}\n```"},{"uri":"sendora://reference/get-v1-servers-id-tokens-tokenid","title":"GET /v1/servers/{id}/tokens/{tokenId}","description":"Read a key of a server","text":"# GET /v1/servers/{id}/tokens/{tokenId}\n\nRead a key of a server\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.tokens.get(serverId, tokenId)`. Python: `account.servers.tokens.get(server_id, token_id)`. PHP: `$account->servers->tokens->get($serverId, $tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n- `tokenId` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 200 A key of this server; the value itself is shown only when it is created.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account, or no such key of the server. |"},{"uri":"sendora://reference/delete-v1-servers-id-tokens-tokenid","title":"DELETE /v1/servers/{id}/tokens/{tokenId}","description":"Revoke a key of a server","text":"# DELETE /v1/servers/{id}/tokens/{tokenId}\n\nRevoke a key of a server\n\nThe key stops working at once. The last live key of a server cannot be revoked, so a server is never locked out; revoking a key that is already revoked is fine.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.tokens.revoke(serverId, tokenId)`. Python: `account.servers.tokens.revoke(server_id, token_id)`. PHP: `$account->servers->tokens->revoke($serverId, $tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n- `tokenId` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account, or no such key of the server. |\n| `last_token` | 409 | This is the only live key of the server; create another before revoking it. |"},{"uri":"sendora://reference/get-v1-domains","title":"GET /v1/domains","description":"List the sending domains","text":"# GET /v1/domains\n\nList the sending domains\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.list()`. Python: `account.domains.list()`. PHP: `$account->domains->list()`.\n\n## Responses\n\n### 200 Every sending domain of the account.\n\n- `domains` (array of object, required)\n - `domainId` (string (uuid), required)\n - `domain` (string, required)\n - `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n - `createdAt` (string (date-time), required)\n - `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n - `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n - `returnPath` (object, required): The return-path record, which also satisfies SPF.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n - `dkim` (object, required): The DKIM public key record.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"domains\": [\n {\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"s1._domainkey.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-domains","title":"POST /v1/domains","description":"Add a sending domain","text":"# POST /v1/domains\n\nAdd a sending domain\n\nCreates the domain with its own DKIM key and answers the two DNS records to add: a CNAME for the return path and a TXT record for the DKIM key. The domain may send once both records are seen, which Sendora checks on its own and on request. Domains belong to the account: every server sends from them, and an account key manages them; a server key cannot.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.create({ domain })`. Python: `account.domains.create(domain=...)`. PHP: `$account->domains->create(domain: $domain)`.\n\n## Request body\n\n- `domain` (string, required): The domain mail is sent from, such as example.se; lower-cased and IDNA-encoded.\n\nExample:\n\n```json\n{\n \"domain\": \"example.se\"\n}\n```\n\n## Responses\n\n### 201 A sending domain and the two records it needs.\n\n- `domainId` (string (uuid), required)\n- `domain` (string, required)\n- `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n- `returnPath` (object, required): The return-path record, which also satisfies SPF.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `dkim` (object, required): The DKIM public key record.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"s1._domainkey.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `domain_exists` | 409 | The account already has that domain. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `domain_exists`\n\n- `domainId` (string (uuid), required): The id of the domain the account already has.\n\nExample:\n\n```json\n{\n \"error\": \"domain_exists\",\n \"message\": \"Your account already has that domain.\",\n \"domainId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-domains-id","title":"GET /v1/domains/{id}","description":"Read a sending domain","text":"# GET /v1/domains/{id}\n\nRead a sending domain\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.get(domainId)`. Python: `account.domains.get(domain_id)`. PHP: `$account->domains->get($domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 200 A sending domain and the two records it needs.\n\n- `domainId` (string (uuid), required)\n- `domain` (string, required)\n- `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n- `returnPath` (object, required): The return-path record, which also satisfies SPF.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `dkim` (object, required): The DKIM public key record.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"s1._domainkey.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such domain of this account. |"},{"uri":"sendora://reference/delete-v1-domains-id","title":"DELETE /v1/domains/{id}","description":"Remove a sending domain","text":"# DELETE /v1/domains/{id}\n\nRemove a sending domain\n\nMail from the domain is refused from then on; its DKIM key is gone.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.delete(domainId)`. Python: `account.domains.delete(domain_id)`. PHP: `$account->domains->delete($domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such domain of this account. |"},{"uri":"sendora://reference/post-v1-domains-id-verify","title":"POST /v1/domains/{id}/verify","description":"Check the records now","text":"# POST /v1/domains/{id}/verify\n\nCheck the records now\n\nLooks both records up at once and records the result; the answer carries the domain as it stands and what each lookup found. A lookup that fails at the resolver changes nothing. A check that begins a verified domain's 24-hour warning or ends its verification mails the account's administrators, as Sendora's own checks do.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.verify(domainId)`. Python: `account.domains.verify(domain_id)`. PHP: `$account->domains->verify($domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 200 The domain as it stands, with what each lookup found.\n\n- `domainId` (string (uuid), required)\n- `domain` (string, required)\n- `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n- `returnPath` (object, required): The return-path record, which also satisfies SPF.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `dkim` (object, required): The DKIM public key record.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `check` (object, required)\n - `returnPath` (\"ok\" | \"missing\" | \"mismatch\" | \"dns_error\", required): dns_error is a resolver failure, such as a DNSSEC problem or a timeout, not an absent record.\n - `dkim` (\"ok\" | \"missing\" | \"mismatch\" | \"dns_error\", required): dns_error is a resolver failure, such as a DNSSEC problem or a timeout, not an absent record.\n\nExample:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"s1._domainkey.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"check\": {\n \"returnPath\": \"missing\",\n \"dkim\": \"missing\"\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such domain of this account. |"},{"uri":"sendora://reference/get-v1-tracking-domains","title":"GET /v1/tracking-domains","description":"List the tracking domains","text":"# GET /v1/tracking-domains\n\nList the tracking domains\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.list()`. Python: `account.tracking_domains.list()`. PHP: `$account->trackingDomains->list()`.\n\n## Responses\n\n### 200 Every tracking domain of the account.\n\n- `trackingDomains` (array of object, required)\n - `trackingDomainId` (string (uuid), required)\n - `host` (string, required): The host, lower-cased and IDNA-encoded.\n - `sendingDomainId` (string (uuid) or null, required): The sending domain whose new mail will use the host; null once that domain is deleted.\n - `state` (\"pending\" | \"waiting_for_certificate\" | \"ready\" | \"failing\" | \"stopped\" | \"sending_domain_lost\", required): pending: the records have not been right yet, and the host goes at expiresAt. waiting_for_certificate: the records are right. ready: new mail from the sending domain uses the host. failing: a check found the records wrong, and new mail keeps using a host that was ready until 24 hours after failingSince. stopped: those 24 hours have passed. sending_domain_lost: the sending domain is deleted or has lost its verification, so new mail does not use the host.\n - `createdAt` (string (date-time), required)\n - `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n - `certificateReadyAt` (string (date-time) or null, required): When the certificate was proved to work; null before that.\n - `failingSince` (string (date-time) or null, required): When a check first found the records wrong on a host whose records have been right. Null unless it is in that warning or past it.\n - `expiresAt` (string (date-time) or null, required): When a host whose records have never been right goes; null once they have been.\n - `cname` (object, required): The CNAME record to add.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `check` (\"ok\" | \"missing\" | \"mismatch\" | \"proxied\" | \"dns_error\" or null, required): What the last check found; null before the first. mismatch is a record that points elsewhere, an A record included. proxied is a proxy in front of the name, such as Cloudflare’s, which must be switched off. dns_error is a resolver failure, not an absent record, and changes nothing.\n - `caa` (object, required): Whether CAA records let Let’s Encrypt issue the certificate.\n - `check` (\"ok\" | \"no_lets_encrypt\" | \"other_account\" | \"no_http_01\" | \"dns_error\" or null, required): What the last check of the CAA records on the host and its parents found; null before the first. dns_error is a resolver failure and changes nothing.\n - `record` (object or null, required): What lets the certificate be issued; null unless the check found a problem.\n - `type` (\"CAA\", required)\n - `host` (string, required): The name that holds the CAA records in the way.\n - `value` (string, required): The record to add beside them.\n\nExample:\n\n```json\n{\n \"trackingDomains\": [\n {\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": null\n },\n \"caa\": {\n \"check\": null,\n \"record\": null\n }\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-tracking-domains","title":"POST /v1/tracking-domains","description":"Add a tracking domain","text":"# POST /v1/tracking-domains\n\nAdd a tracking domain\n\nClaims a host under one of the account’s verified sending domains and answers the CNAME record to add. A host whose records are not right within 7 days goes. An account key manages tracking domains; a server key cannot.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.create({ host })`. Python: `account.tracking_domains.create(host=...)`. PHP: `$account->trackingDomains->create(host: $host)`.\n\n## Request body\n\n- `host` (string, required): The host tracked links and the open pixel use, such as links.example.se: a subdomain of a verified sending domain of the account. Lower-cased and IDNA-encoded.\n\nExample:\n\n```json\n{\n \"host\": \"links.example.se\"\n}\n```\n\n## Responses\n\n### 201 A tracking domain, the record it needs and what the last check found.\n\n- `trackingDomainId` (string (uuid), required)\n- `host` (string, required): The host, lower-cased and IDNA-encoded.\n- `sendingDomainId` (string (uuid) or null, required): The sending domain whose new mail will use the host; null once that domain is deleted.\n- `state` (\"pending\" | \"waiting_for_certificate\" | \"ready\" | \"failing\" | \"stopped\" | \"sending_domain_lost\", required): pending: the records have not been right yet, and the host goes at expiresAt. waiting_for_certificate: the records are right. ready: new mail from the sending domain uses the host. failing: a check found the records wrong, and new mail keeps using a host that was ready until 24 hours after failingSince. stopped: those 24 hours have passed. sending_domain_lost: the sending domain is deleted or has lost its verification, so new mail does not use the host.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `certificateReadyAt` (string (date-time) or null, required): When the certificate was proved to work; null before that.\n- `failingSince` (string (date-time) or null, required): When a check first found the records wrong on a host whose records have been right. Null unless it is in that warning or past it.\n- `expiresAt` (string (date-time) or null, required): When a host whose records have never been right goes; null once they have been.\n- `cname` (object, required): The CNAME record to add.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `check` (\"ok\" | \"missing\" | \"mismatch\" | \"proxied\" | \"dns_error\" or null, required): What the last check found; null before the first. mismatch is a record that points elsewhere, an A record included. proxied is a proxy in front of the name, such as Cloudflare’s, which must be switched off. dns_error is a resolver failure, not an absent record, and changes nothing.\n- `caa` (object, required): Whether CAA records let Let’s Encrypt issue the certificate.\n - `check` (\"ok\" | \"no_lets_encrypt\" | \"other_account\" | \"no_http_01\" | \"dns_error\" or null, required): What the last check of the CAA records on the host and its parents found; null before the first. dns_error is a resolver failure and changes nothing.\n - `record` (object or null, required): What lets the certificate be issued; null unless the check found a problem.\n - `type` (\"CAA\", required)\n - `host` (string, required): The name that holds the CAA records in the way.\n - `value` (string, required): The record to add beside them.\n\nExample:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": null\n },\n \"caa\": {\n \"check\": null,\n \"record\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `domain_reserved` | 400 | The name is Sendora’s own, or holds a domain’s return path. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `tracking_domain_exists` | 409 | The account already has that host, or a host on that sending domain. |\n| `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may have. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. A tracking domain is a subdomain of one. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of this account. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `tracking_domain_exists`\n\n- `trackingDomainId` (string (uuid), required): The id of the tracking domain the account already has.\n\nExample:\n\n```json\n{\n \"error\": \"tracking_domain_exists\",\n \"message\": \"The account already has that host, or a host on that sending domain.\",\n \"trackingDomainId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-tracking-domains-id","title":"GET /v1/tracking-domains/{id}","description":"Read a tracking domain","text":"# GET /v1/tracking-domains/{id}\n\nRead a tracking domain\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.get(trackingDomainId)`. Python: `account.tracking_domains.get(tracking_domain_id)`. PHP: `$account->trackingDomains->get($trackingDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The tracking domain id.\n\n## Responses\n\n### 200 A tracking domain, the record it needs and what the last check found.\n\n- `trackingDomainId` (string (uuid), required)\n- `host` (string, required): The host, lower-cased and IDNA-encoded.\n- `sendingDomainId` (string (uuid) or null, required): The sending domain whose new mail will use the host; null once that domain is deleted.\n- `state` (\"pending\" | \"waiting_for_certificate\" | \"ready\" | \"failing\" | \"stopped\" | \"sending_domain_lost\", required): pending: the records have not been right yet, and the host goes at expiresAt. waiting_for_certificate: the records are right. ready: new mail from the sending domain uses the host. failing: a check found the records wrong, and new mail keeps using a host that was ready until 24 hours after failingSince. stopped: those 24 hours have passed. sending_domain_lost: the sending domain is deleted or has lost its verification, so new mail does not use the host.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `certificateReadyAt` (string (date-time) or null, required): When the certificate was proved to work; null before that.\n- `failingSince` (string (date-time) or null, required): When a check first found the records wrong on a host whose records have been right. Null unless it is in that warning or past it.\n- `expiresAt` (string (date-time) or null, required): When a host whose records have never been right goes; null once they have been.\n- `cname` (object, required): The CNAME record to add.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `check` (\"ok\" | \"missing\" | \"mismatch\" | \"proxied\" | \"dns_error\" or null, required): What the last check found; null before the first. mismatch is a record that points elsewhere, an A record included. proxied is a proxy in front of the name, such as Cloudflare’s, which must be switched off. dns_error is a resolver failure, not an absent record, and changes nothing.\n- `caa` (object, required): Whether CAA records let Let’s Encrypt issue the certificate.\n - `check` (\"ok\" | \"no_lets_encrypt\" | \"other_account\" | \"no_http_01\" | \"dns_error\" or null, required): What the last check of the CAA records on the host and its parents found; null before the first. dns_error is a resolver failure and changes nothing.\n - `record` (object or null, required): What lets the certificate be issued; null unless the check found a problem.\n - `type` (\"CAA\", required)\n - `host` (string, required): The name that holds the CAA records in the way.\n - `value` (string, required): The record to add beside them.\n\nExample:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": null\n },\n \"caa\": {\n \"check\": null,\n \"record\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such tracking domain of this account. |"},{"uri":"sendora://reference/delete-v1-tracking-domains-id","title":"DELETE /v1/tracking-domains/{id}","description":"Remove a tracking domain","text":"# DELETE /v1/tracking-domains/{id}\n\nRemove a tracking domain\n\nNew mail stops using the host at once. Links in sent mail keep working while the CNAME record points at Sendora, so keep the record for as long as those links matter.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.delete(trackingDomainId)`. Python: `account.tracking_domains.delete(tracking_domain_id)`. PHP: `$account->trackingDomains->delete($trackingDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The tracking domain id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such tracking domain of this account. |"},{"uri":"sendora://reference/post-v1-tracking-domains-id-verify","title":"POST /v1/tracking-domains/{id}/verify","description":"Check the records now","text":"# POST /v1/tracking-domains/{id}/verify\n\nCheck the records now\n\nLooks the CNAME and the CAA records up at once and records the result. A lookup that fails at the resolver changes nothing. A check that begins a warning mails the account’s administrators, as Sendora’s own checks do.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.verify(trackingDomainId)`. Python: `account.tracking_domains.verify(tracking_domain_id)`. PHP: `$account->trackingDomains->verify($trackingDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The tracking domain id.\n\n## Responses\n\n### 200 A tracking domain, the record it needs and what the last check found.\n\n- `trackingDomainId` (string (uuid), required)\n- `host` (string, required): The host, lower-cased and IDNA-encoded.\n- `sendingDomainId` (string (uuid) or null, required): The sending domain whose new mail will use the host; null once that domain is deleted.\n- `state` (\"pending\" | \"waiting_for_certificate\" | \"ready\" | \"failing\" | \"stopped\" | \"sending_domain_lost\", required): pending: the records have not been right yet, and the host goes at expiresAt. waiting_for_certificate: the records are right. ready: new mail from the sending domain uses the host. failing: a check found the records wrong, and new mail keeps using a host that was ready until 24 hours after failingSince. stopped: those 24 hours have passed. sending_domain_lost: the sending domain is deleted or has lost its verification, so new mail does not use the host.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `certificateReadyAt` (string (date-time) or null, required): When the certificate was proved to work; null before that.\n- `failingSince` (string (date-time) or null, required): When a check first found the records wrong on a host whose records have been right. Null unless it is in that warning or past it.\n- `expiresAt` (string (date-time) or null, required): When a host whose records have never been right goes; null once they have been.\n- `cname` (object, required): The CNAME record to add.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `check` (\"ok\" | \"missing\" | \"mismatch\" | \"proxied\" | \"dns_error\" or null, required): What the last check found; null before the first. mismatch is a record that points elsewhere, an A record included. proxied is a proxy in front of the name, such as Cloudflare’s, which must be switched off. dns_error is a resolver failure, not an absent record, and changes nothing.\n- `caa` (object, required): Whether CAA records let Let’s Encrypt issue the certificate.\n - `check` (\"ok\" | \"no_lets_encrypt\" | \"other_account\" | \"no_http_01\" | \"dns_error\" or null, required): What the last check of the CAA records on the host and its parents found; null before the first. dns_error is a resolver failure and changes nothing.\n - `record` (object or null, required): What lets the certificate be issued; null unless the check found a problem.\n - `type` (\"CAA\", required)\n - `host` (string, required): The name that holds the CAA records in the way.\n - `value` (string, required): The record to add beside them.\n\nExample:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": \"2026-11-05T12:10:00.000Z\",\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": \"missing\"\n },\n \"caa\": {\n \"check\": \"ok\",\n \"record\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such tracking domain of this account. |"},{"uri":"sendora://reference/post-v1-erasures","title":"POST /v1/erasures","description":"Erase a person","text":"# POST /v1/erasures\n\nErase a person\n\nErases one person's data from every server of the account, as a request under the right to erasure asks. The content of every message to or from the address, sent and received, is deleted, and the address becomes a pseudonym in the message log. The answer is the pending erasure; Sendora carries it out within a minute, and reading it by its id shows the outcome. Erasing an address again changes nothing more. It blocks nothing: later mail to or from the address is sent and received as before.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.erasures.create({ address })`. Python: `account.erasures.create(address=...)`. PHP: `$account->erasures->create(address: $address)`.\n\n## Request body\n\n- `address` (string (email), required): The person's e-mail address.\n\nExample:\n\n```json\n{\n \"address\": \"anna@example.com\"\n}\n```\n\n## Responses\n\n### 202 A request to erase one person's data from the account.\n\n- `erasureId` (string (uuid), required)\n- `status` (\"pending\" | \"done\", required): pending until Sendora has erased the person, done after.\n- `createdAt` (string (date-time), required)\n- `completedAt` (string (date-time) or null, required): When the erasure was done; null while pending.\n- `pseudonym` (string or null, required): What now stands where the address was written in the message log; null while pending.\n- `messages` (integer or null, required): Sent messages to the address whose content was deleted; null while pending.\n- `receivedMessages` (integer or null, required): Received messages from, naming or to the address whose content was deleted; null while pending.\n\nExample:\n\n```json\n{\n \"erasureId\": \"6f1d2c3b-4a5e-4f60-8b7a-9c8d7e6f5a41\",\n \"status\": \"pending\",\n \"createdAt\": \"2026-10-04T12:00:00.000Z\",\n \"completedAt\": null,\n \"pseudonym\": null,\n \"messages\": null,\n \"receivedMessages\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-erasures-id","title":"GET /v1/erasures/{id}","description":"Read an erasure","text":"# GET /v1/erasures/{id}\n\nRead an erasure\n\nThe erasure and, once it is done, its outcome. A done erasure can be read for 30 days.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.erasures.get(erasureId)`. Python: `account.erasures.get(erasure_id)`. PHP: `$account->erasures->get($erasureId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The erasure id.\n\n## Responses\n\n### 200 A request to erase one person's data from the account.\n\n- `erasureId` (string (uuid), required)\n- `status` (\"pending\" | \"done\", required): pending until Sendora has erased the person, done after.\n- `createdAt` (string (date-time), required)\n- `completedAt` (string (date-time) or null, required): When the erasure was done; null while pending.\n- `pseudonym` (string or null, required): What now stands where the address was written in the message log; null while pending.\n- `messages` (integer or null, required): Sent messages to the address whose content was deleted; null while pending.\n- `receivedMessages` (integer or null, required): Received messages from, naming or to the address whose content was deleted; null while pending.\n\nExample:\n\n```json\n{\n \"erasureId\": \"6f1d2c3b-4a5e-4f60-8b7a-9c8d7e6f5a41\",\n \"status\": \"done\",\n \"createdAt\": \"2026-10-04T12:00:00.000Z\",\n \"completedAt\": \"2026-10-04T12:00:08.000Z\",\n \"pseudonym\": \"erased:3f7a9c1e5b2d8f4a6c0e9b7d5f3a1c8e6b4d2f0a9c7e5b3d1f8a6c4e2b0d9f7a\",\n \"messages\": 3,\n \"receivedMessages\": 1\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such erasure of this account. |"},{"uri":"sendora://reference/get-v1-suppressions","title":"GET /v1/suppressions","description":"List the suppressed addresses","text":"# GET /v1/suppressions\n\nList the suppressed addresses\n\nAddresses a stream of the server will not send to: hard bounces, spam complaints and entries added by hand, newest first, one page at a time. The default stream unless streamId names another; every stream has a list of its own. A send to any of them is refused with recipient_suppressed.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.suppressions.list(query)`. Python: `sendora.suppressions.list(stream_id=..., limit=..., after=...)`. PHP: `$sendora->suppressions->list(streamId: $streamId, limit: $limit, after: $after)`.\n\n## Parameters\n\n- `streamId` (query, string (uuid)): The stream whose list is meant; the default transactional stream when absent.\n- `limit` (query, integer (1 to 1000)): Page size, 1 to 1000.\n- `after` (query, string (uuid)): The `next` value of the previous page.\n\n## Responses\n\n### 200 A page of the stream's suppression list, newest first.\n\n- `suppressions` (array of object, required)\n - `streamId` (string (uuid), required): The stream whose list the address is on.\n - `address` (string, required): The recipient, lower-cased.\n - `reason` (\"hard_bounce\" | \"spam_complaint\" | \"manual\" | \"unsubscribe\", required): A hard bounce or a manual entry may be lifted here; a spam complaint or the recipient’s own unsubscribe only by Sendora support. manual is an address added by hand in the dashboard.\n - `messageId` (string (uuid) or null, required): The message whose bounce or complaint put it here.\n - `createdAt` (string (date-time), required)\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"suppressions\": [\n {\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"address\": \"anna@example.com\",\n \"reason\": \"hard_bounce\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-suppressions-delete","title":"POST /v1/suppressions/delete","description":"Lift a suppression","text":"# POST /v1/suppressions/delete\n\nLift a suppression\n\nRemoves a hard-bounced or manually added address from a stream’s list so the server may send to it again on that stream. A POST, because the address travels in the body and never in a URL. A spam complaint cannot be lifted this way.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.suppressions.delete({ address })`. Python: `sendora.suppressions.delete(address=...)`. PHP: `$sendora->suppressions->delete(address: $address)`.\n\n## Request body\n\n- `address` (string (email), required): The address to send to again.\n- `streamId` (string (uuid)): The stream whose list is meant; the default transactional stream when absent.\n\nExample:\n\n```json\n{\n \"address\": \"anna@example.com\"\n}\n```\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `spam_complaint_locked` | 403 | The recipient reported a message as spam; only Sendora support lifts that, at the recipient’s own request. |\n| `unsubscribe_locked` | 403 | The recipient unsubscribed themselves; only Sendora support lifts that, at the recipient’s own request. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such suppressed address of this server. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-webhooks","title":"GET /v1/webhooks","description":"List the webhooks","text":"# GET /v1/webhooks\n\nList the webhooks\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.list()`. Python: `sendora.webhooks.list()`. PHP: `$sendora->webhooks->list()`.\n\n## Responses\n\n### 200 Every webhook of the server.\n\n- `webhooks` (array of object, required)\n - `webhookId` (string (uuid), required)\n - `url` (string, required): Where the events are posted.\n - `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n - `streamId` (string (uuid) or null, required): The one stream whose message events it receives; null for every stream.\n - `enabled` (boolean, required): False while switched off in the dashboard; nothing is queued for it then.\n - `inboundContent` (\"full\" | \"reference\", required): How the inbound event carries a received message: the parsed message, or a reference.\n - `createdAt` (string (date-time), required)\n - `secrets` (array of object, required): The live secrets, oldest first; every delivery is signed with each of them, the newest first in Sendora-Signature.\n - `secretId` (string (uuid), required)\n - `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"webhooks\": [\n {\n \"webhookId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"spam_complaint\"\n ],\n \"streamId\": null,\n \"enabled\": true,\n \"inboundContent\": \"full\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"secrets\": [\n {\n \"secretId\": \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ]\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-webhooks","title":"POST /v1/webhooks","description":"Create a webhook","text":"# POST /v1/webhooks\n\nCreate a webhook\n\nRegisters a URL to receive events. The answer carries the signing secret once; every delivery is signed with it (HMAC-SHA256 over the body) so the receiver can verify Sendora sent it. A second secret can be created later to roll the first one.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.create({ url, events })`. Python: `sendora.webhooks.create(url=..., events=...)`. PHP: `$sendora->webhooks->create(url: $url, events: $events)`.\n\n## Request body\n\n- `url` (string, required): An https URL on a public host, without credentials or a fragment.\n- `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", at least 1): The events to deliver; every event when left out.\n- `streamId` (string (uuid)): Limits the message events to one stream of the server; every stream when left out. Cap events, on sending and on received mail, concern the server or the account and arrive either way.\n- `inboundContent` (\"full\" | \"reference\"): How the inbound event carries a received message: full sends the parsed message with the event; reference sends ids and sizes only, for you to fetch. full when left out.\n\nExample:\n\n```json\n{\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"spam_complaint\"\n ]\n}\n```\n\n## Responses\n\n### 201 The new webhook with its signing secret, shown this once.\n\n- `webhookId` (string (uuid), required)\n- `url` (string, required): Where the events are posted.\n- `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n- `streamId` (string (uuid) or null, required): The one stream whose message events it receives; null for every stream.\n- `enabled` (boolean, required): False while switched off in the dashboard; nothing is queued for it then.\n- `inboundContent` (\"full\" | \"reference\", required): How the inbound event carries a received message: the parsed message, or a reference.\n- `createdAt` (string (date-time), required)\n- `secrets` (array of object, required): The live secrets, oldest first; every delivery is signed with each of them, the newest first in Sendora-Signature.\n - `secretId` (string (uuid), required)\n - `createdAt` (string (date-time), required)\n- `secret` (string, required): The signing secret, shown this once.\n\nExample:\n\n```json\n{\n \"webhookId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"spam_complaint\"\n ],\n \"streamId\": null,\n \"enabled\": true,\n \"inboundContent\": \"full\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"secrets\": [\n {\n \"secretId\": \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ],\n \"secret\": \"whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `webhook_exists`\n\n- `webhookId` (string (uuid), required): The id of the webhook the server already has for that URL.\n\nExample:\n\n```json\n{\n \"error\": \"webhook_exists\",\n \"message\": \"This server already has a webhook for that URL.\",\n \"webhookId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-webhooks-id","title":"GET /v1/webhooks/{id}","description":"Read a webhook","text":"# GET /v1/webhooks/{id}\n\nRead a webhook\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.get(webhookId)`. Python: `sendora.webhooks.get(webhook_id)`. PHP: `$sendora->webhooks->get($webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 200 A webhook of this server.\n\n- `webhookId` (string (uuid), required)\n- `url` (string, required): Where the events are posted.\n- `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n- `streamId` (string (uuid) or null, required): The one stream whose message events it receives; null for every stream.\n- `enabled` (boolean, required): False while switched off in the dashboard; nothing is queued for it then.\n- `inboundContent` (\"full\" | \"reference\", required): How the inbound event carries a received message: the parsed message, or a reference.\n- `createdAt` (string (date-time), required)\n- `secrets` (array of object, required): The live secrets, oldest first; every delivery is signed with each of them, the newest first in Sendora-Signature.\n - `secretId` (string (uuid), required)\n - `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"webhookId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"spam_complaint\"\n ],\n \"streamId\": null,\n \"enabled\": true,\n \"inboundContent\": \"full\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"secrets\": [\n {\n \"secretId\": \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook of this server. |"},{"uri":"sendora://reference/delete-v1-webhooks-id","title":"DELETE /v1/webhooks/{id}","description":"Remove a webhook","text":"# DELETE /v1/webhooks/{id}\n\nRemove a webhook\n\nDeliveries still pending are dropped with it.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.delete(webhookId)`. Python: `sendora.webhooks.delete(webhook_id)`. PHP: `$sendora->webhooks->delete($webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook of this server. |"},{"uri":"sendora://reference/get-v1-webhooks-id-deliveries","title":"GET /v1/webhooks/{id}/deliveries","description":"List the deliveries of a webhook","text":"# GET /v1/webhooks/{id}/deliveries\n\nList the deliveries of a webhook\n\nEvery event handed to the webhook, newest first, one page at a time, with the outcome of the last attempt.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. 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)`.\n\n## Parameters\n\n- `limit` (query, integer (1 to 1000)): Page size, 1 to 1000.\n- `after` (query, string (uuid)): The `next` value of the previous page.\n- `status` (query, \"pending\" | \"delivered\" | \"dead\")\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 200 A page of the webhook's deliveries, newest first.\n\n- `deliveries` (array of object, required)\n - `deliveryId` (string (uuid), required)\n - `event` (\"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n - `messageId` (string (uuid) or null, required): Null for an event about usage rather than a message.\n - `inboundMessageId` (string (uuid) or null, required): The received message an inbound event is about; null for every other event.\n - `status` (\"pending\" | \"delivered\" | \"dead\", required): pending is waiting for its next attempt; dead gave up and can be replayed.\n - `attempts` (integer, required)\n - `nextAttemptAt` (string (date-time) or null, required): When the next attempt is due, while pending.\n - `lastStatusCode` (integer or null, required): The HTTP status the endpoint last answered.\n - `lastError` (string or null, required): Why the last attempt failed, when it did.\n - `deliveredAt` (string (date-time) or null, required)\n - `createdAt` (string (date-time), required)\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"deliveries\": [\n {\n \"deliveryId\": \"9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c60\",\n \"event\": \"delivered\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"inboundMessageId\": null,\n \"status\": \"delivered\",\n \"attempts\": 1,\n \"nextAttemptAt\": null,\n \"lastStatusCode\": 200,\n \"lastError\": null,\n \"deliveredAt\": \"2026-09-15T12:00:03.000Z\",\n \"createdAt\": \"2026-09-15T12:00:02.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook of this server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-webhooks-id-deliveries-deliveryid-replay","title":"POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay","description":"Replay a delivery","text":"# POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay\n\nReplay a delivery\n\nQueues the event for delivery again, whatever its state, with the same payload and a fresh attempt count.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.replay(webhookId, deliveryId)`. Python: `sendora.webhooks.replay(webhook_id, delivery_id)`. PHP: `$sendora->webhooks->replay($webhookId, $deliveryId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n- `deliveryId` (path, required, string (uuid)): The delivery id.\n\n## Responses\n\n### 202 One event on its way to the webhook, retried with backoff until delivered or dead.\n\n- `deliveryId` (string (uuid), required)\n- `event` (\"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n- `messageId` (string (uuid) or null, required): Null for an event about usage rather than a message.\n- `inboundMessageId` (string (uuid) or null, required): The received message an inbound event is about; null for every other event.\n- `status` (\"pending\" | \"delivered\" | \"dead\", required): pending is waiting for its next attempt; dead gave up and can be replayed.\n- `attempts` (integer, required)\n- `nextAttemptAt` (string (date-time) or null, required): When the next attempt is due, while pending.\n- `lastStatusCode` (integer or null, required): The HTTP status the endpoint last answered.\n- `lastError` (string or null, required): Why the last attempt failed, when it did.\n- `deliveredAt` (string (date-time) or null, required)\n- `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"deliveryId\": \"9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c60\",\n \"event\": \"delivered\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"inboundMessageId\": null,\n \"status\": \"pending\",\n \"attempts\": 0,\n \"nextAttemptAt\": \"2026-09-15T12:05:00.000Z\",\n \"lastStatusCode\": 200,\n \"lastError\": null,\n \"deliveredAt\": null,\n \"createdAt\": \"2026-09-15T12:00:02.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook or delivery of this server. |"},{"uri":"sendora://reference/post-v1-webhooks-id-secrets","title":"POST /v1/webhooks/{id}/secrets","description":"Create a signing secret","text":"# POST /v1/webhooks/{id}/secrets\n\nCreate a signing secret\n\nAdds a second live secret to the webhook and answers its value once; it is never shown again. From then on every delivery carries one signature per live secret, the newest first, so the receiver can switch to the new secret and the old one can be deleted. A webhook holds at most two live secrets.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.createSecret(webhookId)`. Python: `sendora.webhooks.create_secret(webhook_id)`. PHP: `$sendora->webhooks->createSecret($webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 201 The new secret, its value shown this once.\n\n- `secretId` (string (uuid), required)\n- `createdAt` (string (date-time), required)\n- `secret` (string, required): The secret itself, shown this once.\n\nExample:\n\n```json\n{\n \"secretId\": \"5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"secret\": \"whsec_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook of this server. |\n| `secret_limit` | 409 | The webhook already holds two live secrets: one in use and one to roll to. Delete one before creating another. |\n\n### `secret_limit`\n\n- `max` (integer, required): Live secrets a webhook may hold at once.\n\nExample:\n\n```json\n{\n \"error\": \"secret_limit\",\n \"message\": \"The webhook may hold at most 2 live secrets; delete one before creating another.\",\n \"max\": 2\n}\n```"},{"uri":"sendora://reference/delete-v1-webhooks-id-secrets-secretid","title":"DELETE /v1/webhooks/{id}/secrets/{secretId}","description":"Delete a signing secret","text":"# DELETE /v1/webhooks/{id}/secrets/{secretId}\n\nDelete a signing secret\n\nDeliveries are no longer signed with it from the next one on. The last live secret of a webhook cannot be deleted, so its deliveries always carry a signature.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.deleteSecret(webhookId, secretId)`. Python: `sendora.webhooks.delete_secret(webhook_id, secret_id)`. PHP: `$sendora->webhooks->deleteSecret($webhookId, $secretId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n- `secretId` (path, required, string (uuid)): The secret id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook or secret of this server. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted; create another first. |"},{"uri":"sendora://reference/get-v1-tokens","title":"GET /v1/tokens","description":"List the keys","text":"# GET /v1/tokens\n\nList the keys\n\nEvery key of the server, revoked ones included.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.tokens.list()`. Python: `sendora.tokens.list()`. PHP: `$sendora->tokens->list()`.\n\n## Responses\n\n### 200 Every key of the server, revoked ones included.\n\n- `tokens` (array of object, required)\n - `tokenId` (string (uuid), required)\n - `name` (string, required): The name given at creation, for people to tell keys apart.\n - `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n - `createdAt` (string (date-time), required)\n - `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokens\": [\n {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-tokens","title":"POST /v1/tokens","description":"Create a key","text":"# POST /v1/tokens\n\nCreate a key\n\nCreates another live key for this server and answers its value once; it is never shown again. Every key of a server has the same rights. A server holds at most two live keys: the one in use and the one being rotated in.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.tokens.create({ name })`. Python: `sendora.tokens.create(name=...)`. PHP: `$sendora->tokens->create(name: $name)`.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n\nExample:\n\n```json\n{\n \"name\": \"Invoicing system\"\n}\n```\n\n## Responses\n\n### 201 The new key, its value shown this once.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n- `token` (string, required): The key itself, shown this once.\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `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. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `token_limit`\n\n- `max` (integer, required): Live keys the owner may hold at once.\n\nExample:\n\n```json\n{\n \"error\": \"token_limit\",\n \"message\": \"The server may hold at most 2 live keys; revoke one before creating another.\",\n \"max\": 2\n}\n```"},{"uri":"sendora://reference/get-v1-tokens-id","title":"GET /v1/tokens/{id}","description":"Read a key","text":"# GET /v1/tokens/{id}\n\nRead a key\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.tokens.get(tokenId)`. Python: `sendora.tokens.get(token_id)`. PHP: `$sendora->tokens->get($tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 200 A key of this server; the value itself is shown only when it is created.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such key of this server. |"},{"uri":"sendora://reference/delete-v1-tokens-id","title":"DELETE /v1/tokens/{id}","description":"Revoke a key","text":"# DELETE /v1/tokens/{id}\n\nRevoke a key\n\nThe key stops working at once. The last live key of a server cannot be revoked, so a server is never locked out; revoking a key that is already revoked is fine.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.tokens.revoke(tokenId)`. Python: `sendora.tokens.revoke(token_id)`. PHP: `$sendora->tokens->revoke($tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such key of this server. |\n| `last_token` | 409 | This is the only live key of the server; create another before revoking it. |"},{"uri":"sendora://reference/webhook-events","title":"Webhook events","description":"Every event a webhook receives, with its fields and an example.","text":"# Webhook events\n\nSendora posts each event as JSON to the webhooks that take it. A delivery may arrive more than once, so key your handling on `id`, and verify `Sendora-Signature` before you trust the body. [Webhooks](sendora://docs/webhooks) shows how.\n\n## Headers\n\n- `Sendora-Signature` (string): `t=` the Unix time of sending, then `v1=` an HMAC-SHA256 over `<t>.<body>` for each live secret of the webhook, the newest first. Verify one before you trust the body.\n- `Sendora-Event` (string): The event, the same as `event` in the body.\n- `Sendora-Delivery-Id` (string): The delivery, the same as `id` in the body and on every retry of it.\n- `User-Agent` (string): `Sendora-Webhooks/1`.\n\nAcknowledges the delivery. Any other answer, or none within 10 seconds, is retried.\n\n## Message events\n\nEvery event in this group carries these fields:\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `messageId` (string, required): The message the event is about.\n- `recipient` (string, required): The recipient the event is about.\n- `occurredAt` (string (date-time), required): When it happened.\n- `serverId` (string, required): The server the message was sent from.\n- `streamId` (string, required): The stream the message went on.\n- `broadcastId` (string or null, required): The broadcast the message belongs to; null for a message sent on its own.\n- `tag` (string or null, required): The tag you sent with the message.\n- `metadata` (map of string to string, required): The metadata you sent with the message.\n- `test` (boolean, required): True for a test server's message: the event was simulated and nothing was sent.\n\n### `delivered`\n\nThe receiver accepted the message for the recipient.\n\n- `event` (\"delivered\", required)\n- `details` (object, required)\n - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.\n - `response` (string or null, required): The receiver's response text.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"delivered\",\n \"details\": {\n \"code\": 250,\n \"response\": \"2.0.0 OK\"\n }\n}\n```\n\n### `bounced`\n\nThe receiver refused the message for good, or Sendora gave up; the recipient is suppressed when `hard`.\n\n- `event` (\"bounced\", required)\n- `details` (object, required)\n - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.\n - `response` (string or null, required): The receiver's response text.\n - `classification` (string or null, required): How the bounce was classified, such as InvalidRecipient.\n - `hard` (boolean, required): True when the address is now on the suppression list.\n - `expired` (boolean, required): True when the message expired after repeated deferrals rather than being refused.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"bounced\",\n \"details\": {\n \"code\": 550,\n \"response\": \"5.1.1 The email account that you tried to reach does not exist.\",\n \"classification\": \"InvalidRecipient\",\n \"hard\": true,\n \"expired\": false\n }\n}\n```\n\n### `deferred`\n\nThe receiver asked Sendora to try later; several may follow.\n\n- `event` (\"deferred\", required)\n- `details` (object, required)\n - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.\n - `response` (string or null, required): The receiver's response text.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"deferred\",\n \"details\": {\n \"code\": 451,\n \"response\": \"4.7.1 Greylisted, try again later\"\n }\n}\n```\n\n### `spam_complaint`\n\nThe recipient reported the message as spam; only Sendora support lifts that suppression.\n\n- `event` (\"spam_complaint\", required)\n- `details` (object, required)\n - `feedbackType` (string or null, required): The feedback type the receiver reported, such as abuse.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"spam_complaint\",\n \"details\": {\n \"feedbackType\": \"abuse\"\n }\n}\n```\n\n### `unsubscribed`\n\nThe recipient unsubscribed through the link or their mail client's one-click button; only Sendora support lifts that suppression.\n\n- `event` (\"unsubscribed\", required)\n- `details` (object, required)\n - `source` (\"link\" | \"one_click\", required): `link` for the page's button, `one_click` for a mail client's request.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": \"2c4e6a8b-0d1f-4a3b-8c5d-7e9f1a3b5c7d\",\n \"tag\": \"newsletter\",\n \"metadata\": {},\n \"test\": false,\n \"event\": \"unsubscribed\",\n \"details\": {\n \"source\": \"one_click\"\n }\n}\n```\n\n## The monthly cap\n\nEvery event in this group carries these fields:\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `scope` (\"account\" | \"server\" | \"test\", required): Whose cap it is: `account` for the account's, `server` for a server's own, `test` for the cap the account's test servers share.\n- `serverId` (string or null, required): The server whose cap it is; null for the account's or the test servers' cap.\n- `cap` (integer, required): Emails the scope may send in the month.\n- `used` (integer, required): Emails sent in the month so far.\n- `periodStart` (string (date-time), required): When the month the counts are for began. A paying account's live servers count months from the day and time of its first payment, and the rest count calendar months in UTC.\n- `periodEnd` (string (date-time), required): When that month ends and the count starts again.\n- `occurredAt` (string (date-time), required): When it happened.\n\n### `cap_warning`\n\n80 percent of the monthly cap is used.\n\n- `event` (\"cap_warning\", required)\n\nExample:\n\n```json\n{\n \"id\": \"4d6f8a0b-2c4e-4f6a-8b0c-2d4e6f8a0b1c\",\n \"attempt\": 1,\n \"scope\": \"account\",\n \"serverId\": null,\n \"cap\": 50000,\n \"periodStart\": \"2026-09-01T00:00:00.000Z\",\n \"periodEnd\": \"2026-10-01T00:00:00.000Z\",\n \"used\": 40000,\n \"occurredAt\": \"2026-09-24T09:30:00.000Z\",\n \"event\": \"cap_warning\"\n}\n```\n\n### `cap_reached`\n\nA send was refused because the monthly cap is used up.\n\n- `event` (\"cap_reached\", required)\n\nExample:\n\n```json\n{\n \"id\": \"4d6f8a0b-2c4e-4f6a-8b0c-2d4e6f8a0b1c\",\n \"attempt\": 1,\n \"scope\": \"account\",\n \"serverId\": null,\n \"cap\": 50000,\n \"periodStart\": \"2026-09-01T00:00:00.000Z\",\n \"periodEnd\": \"2026-10-01T00:00:00.000Z\",\n \"used\": 50000,\n \"occurredAt\": \"2026-09-29T16:05:00.000Z\",\n \"event\": \"cap_reached\"\n}\n```\n\n## Received mail\n\n### `inbound`\n\nA message was received on an inbound stream: the reference, or the reference and the message itself, as the webhook asks.\n\nBoth forms carry these fields:\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `event` (\"inbound\", required)\n- `inboundMessageId` (string, required): The received message; GET /v1/inbound/{id} reads it.\n- `serverId` (string, required): The server whose stream received it.\n- `streamId` (string, required): The inbound stream that received it.\n- `receivedAt` (string (date-time), required): When the message was accepted from the sending server.\n- `envelopeRecipient` (string, required): The address of yours the message was sent to.\n- `mailboxHash` (string or null, required): The text after `+` in the local part of that address, when the sender used one.\n- `sizeBytes` (integer, required): The size of the message as received, in bytes.\n- `attachmentCount` (integer, required)\n- `hasText` (boolean, required)\n- `hasHtml` (boolean, required)\n- `authentication` (object, required): What Sendora found when it checked the message's authentication; `unchecked` until the checks have run.\n - `spf` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the address in MAIL FROM.\n - `spfHelo` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the name the sending server gave in HELO.\n - `dkim` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarc` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarcPolicy` (\"none\" | \"quarantine\" | \"reject\" or null, required): What the sender's domain asks for; told only when DMARC failed.\n - `arc` (\"none\" | \"pass\" | \"fail\" | \"unchecked\", required)\n - `checkedAt` (string (date-time) or null, required): When the checks ran; null until they have.\n- `parseIssue` (string or null, required): What the parser met; null when the message parsed clean. A hard issue empties the parsed parts, and the raw message stays: `no_headers`, `header_block_too_large`, `too_many_headers`, `too_many_parts`, `nesting_too_deep`, `missing_boundary`, `decoded_too_large`, `parse_timeout`, `parser_error`. A soft issue keeps them and says what was changed or dropped: `truncated_multipart`, `too_many_attachments`, `unknown_transfer_encoding`, `undecodable_container`, `filename_sanitised`, `control_chars_stripped`, `multiple_from`, `duplicate_header`, `malformed_header_dropped`, `address_list_truncated`, `header_value_truncated`. A message with several names the hard one, or the first soft one in this order.\n\nForm 1: The reference to the received message, for a webhook with `inboundContent` reference.\n\nExample:\n\n```json\n{\n \"id\": \"6e8a0c2e-4a6c-4e8a-8c0e-2a4c6e8a0c2e\",\n \"attempt\": 1,\n \"event\": \"inbound\",\n \"inboundMessageId\": \"1b3d5f7a-9c1e-4b3d-8f7a-9c1e3b5d7f9a\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"8a0c2e4a-6c8e-4a0c-8e4a-6c8e0a2c4e6a\",\n \"receivedAt\": \"2026-09-15T12:00:00.000Z\",\n \"envelopeRecipient\": \"support@example.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 18432,\n \"attachmentCount\": 1,\n \"hasText\": true,\n \"hasHtml\": true,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-15T12:00:00.000Z\"\n },\n \"parseIssue\": null\n}\n```\n\nForm 2: The reference and the message itself, for a webhook with `inboundContent` full.\n\n- `envelope` (object or null, required): The envelope sender; null for a bounce.\n - `sender` (string or null, required)\n- `from` (object or null, required)\n - `address` (string, required)\n - `name` (string or null, required)\n- `replyTo` (array of object, required)\n - `address` (string, required)\n - `name` (string or null, required)\n- `to` (array of object, required): Up to 100 entries; `toCount` is the whole number.\n - `address` (string, required)\n - `name` (string or null, required)\n- `toCount` (integer, required)\n- `cc` (array of object, required): Up to 100 entries; `ccCount` is the whole number.\n - `address` (string, required)\n - `name` (string or null, required)\n- `ccCount` (integer, required)\n- `subject` (string or null, required)\n- `date` (string (date-time) or null, required): The sender's Date header, when it was a real moment.\n- `messageIdHeader` (string or null, required)\n- `inReplyTo` (string or null, required)\n- `references` (array of string, required)\n- `headers` (array of object, required)\n - `name` (string, required)\n - `value` (string, required)\n- `text` (string or null, required): The plain-text body; null when there is none, or over 1 MiB.\n- `html` (string or null, required): The HTML body; null when there is none, or over 2 MiB.\n- `attachments` (array of object, required): Each attachment, without its bytes.\n - `position` (integer, required): Its place among the message's attachments, from 0.\n - `name` (string, required)\n - `contentType` (string, required)\n - `contentId` (string or null, required): The Content-ID an HTML body refers to with `cid:`, when it has one.\n - `size` (integer, required)\n - `inline` (boolean, required): True for a part shown in the body rather than offered as a file.\n- `contentOmitted` (\"too_large\" | \"expired\" or null, required): `too_large` when text or HTML was left out for size; `expired` when the content window has passed and the reference is all that remains.\n\nExample:\n\n```json\n{\n \"id\": \"6e8a0c2e-4a6c-4e8a-8c0e-2a4c6e8a0c2e\",\n \"attempt\": 1,\n \"event\": \"inbound\",\n \"inboundMessageId\": \"1b3d5f7a-9c1e-4b3d-8f7a-9c1e3b5d7f9a\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"8a0c2e4a-6c8e-4a0c-8e4a-6c8e0a2c4e6a\",\n \"receivedAt\": \"2026-09-15T12:00:00.000Z\",\n \"envelopeRecipient\": \"support@example.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 18432,\n \"attachmentCount\": 1,\n \"hasText\": true,\n \"hasHtml\": true,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-15T12:00:00.000Z\"\n },\n \"parseIssue\": null,\n \"envelope\": {\n \"sender\": \"anna@example.com\"\n },\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"replyTo\": [],\n \"to\": [\n {\n \"address\": \"support@example.se\",\n \"name\": null\n }\n ],\n \"toCount\": 1,\n \"cc\": [],\n \"ccCount\": 0,\n \"subject\": \"A question about the invoice\",\n \"date\": \"2026-09-15T11:59:58.000Z\",\n \"messageIdHeader\": \"<20260915115958.a1b2@example.com>\",\n \"inReplyTo\": null,\n \"references\": [],\n \"headers\": [\n {\n \"name\": \"From\",\n \"value\": \"Anna Andersson <anna@example.com>\"\n },\n {\n \"name\": \"To\",\n \"value\": \"support@example.se\"\n },\n {\n \"name\": \"Subject\",\n \"value\": \"A question about the invoice\"\n }\n ],\n \"text\": \"Hi! I have a question about the invoice for September.\",\n \"html\": \"<p>Hi! I have a question about the invoice for September.</p>\",\n \"attachments\": [\n {\n \"position\": 0,\n \"name\": \"invoice.pdf\",\n \"contentType\": \"application/pdf\",\n \"contentId\": null,\n \"size\": 15360,\n \"inline\": false\n }\n ],\n \"contentOmitted\": null\n}\n```\n\n### `inbound_cap_warning`\n\n80 percent of the monthly cap on received mail is used.\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `cap` (integer, required): Emails the account may receive in the month.\n- `used` (integer, required): Emails received in the month so far.\n- `periodStart` (string (date-time), required): When the month the counts are for began. A paying account counts months from the day and time of its first payment, and any other account calendar months in UTC.\n- `periodEnd` (string (date-time), required): When that month ends and received mail is counted from 0 again.\n- `occurredAt` (string (date-time), required): When it happened.\n- `event` (\"inbound_cap_warning\", required)\n\nExample:\n\n```json\n{\n \"id\": \"6f8a0b2c-4e6f-4a8b-9c0d-2e4f6a8b0c1d\",\n \"attempt\": 1,\n \"cap\": 50000,\n \"periodStart\": \"2026-09-01T00:00:00.000Z\",\n \"periodEnd\": \"2026-10-01T00:00:00.000Z\",\n \"used\": 40000,\n \"occurredAt\": \"2026-09-24T09:30:00.000Z\",\n \"event\": \"inbound_cap_warning\"\n}\n```\n\n### `inbound_cap_reached`\n\nThe monthly cap on received mail is used up. Mail to the account is refused for now, and senders retry, until the month ends or the cap is raised.\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `cap` (integer, required): Emails the account may receive in the month.\n- `used` (integer, required): Emails received in the month so far.\n- `periodStart` (string (date-time), required): When the month the counts are for began. A paying account counts months from the day and time of its first payment, and any other account calendar months in UTC.\n- `periodEnd` (string (date-time), required): When that month ends and received mail is counted from 0 again.\n- `occurredAt` (string (date-time), required): When it happened.\n- `event` (\"inbound_cap_reached\", required)\n\nExample:\n\n```json\n{\n \"id\": \"6f8a0b2c-4e6f-4a8b-9c0d-2e4f6a8b0c1d\",\n \"attempt\": 1,\n \"cap\": 50000,\n \"periodStart\": \"2026-09-01T00:00:00.000Z\",\n \"periodEnd\": \"2026-10-01T00:00:00.000Z\",\n \"used\": 50000,\n \"occurredAt\": \"2026-09-29T16:05:00.000Z\",\n \"event\": \"inbound_cap_reached\"\n}\n```"}]}
1
+ {"documents":[{"uri":"sendora://docs/quickstart","title":"Quickstart","description":"Send your first message and read it in the log.","text":"# Quickstart\n\nSend your first message and read it in the log.\n\nThree steps take you from an approved account to a delivered message.\n\n## Before you start\n\n- 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](sendora://docs/errors#when-an-account-cannot-send).\n- The domain you send from is verified, as [Domains](sendora://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.\n- To try this without sending real mail, use a [test server](sendora://docs/test-servers) and its key. A test server works before your account is approved and before payment.\n\n## 1. Get a server key\n\nSign 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](sendora://docs/keys) explains the two kinds of key.\n\nOver HTTP:\n\nSend the key as a bearer token on every request:\n\n```bash\nAuthorization: Bearer sk_…\n```\n\nWith the TypeScript SDK:\n\nInstall the SDK. It has no dependencies, is ESM, and runs on Node 20.19 or newer, Bun, Deno and Cloudflare Workers:\n\n```sh\nnpm install @sendora/sdk\n```\n\nKeep 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.\n\nWith the Python SDK:\n\nInstall the SDK. It runs on Python 3.11 or newer and has one dependency, httpx2:\n\n```sh\npip install sendora\n```\n\nPin the major version you test with, such as `sendora~=2.0` in your requirements. Only a new major version may break your code.\n\nKeep 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.\n\nWith the PHP SDK:\n\nInstall the SDK. It runs on PHP 8.2 or newer, with ext-curl and ext-json and no other package:\n\n```sh\ncomposer require sendora/sendora-php\n```\n\nComposer 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.\n\nKeep 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.\n\n## 2. Send a message\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your account is ready\",\n \"text\": \"Hi Anna, your account at Example is ready. Sign in at https://example.se/login.\",\n \"tag\": \"welcome\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { messageId } = await sendora.email.send({\n from: {\n email: 'no-reply@example.se',\n name: 'Example AB',\n },\n to: ['anna@example.com'],\n subject: 'Your account is ready',\n text: 'Hi Anna, your account at Example is ready. Sign in at https://example.se/login.',\n tag: 'welcome',\n});\n\nconsole.log(messageId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\"anna@example.com\"],\n subject=\"Your account is ready\",\n text=(\n \"Hi Anna, your account at Example is ready. \"\n \"Sign in at https://example.se/login.\"\n ),\n tag=\"welcome\",\n)\n\nprint(accepted.message_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'no-reply@example.se',\n 'name' => 'Example AB',\n ],\n to: ['anna@example.com'],\n subject: 'Your account is ready',\n text: 'Hi Anna, your account at Example is ready. '\n . 'Sign in at https://example.se/login.',\n tag: 'welcome',\n);\n\necho $accepted->messageId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false\n}\n```\n\nThe `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`.\n\nThe 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.\n\nIf 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](sendora://docs/sending#idempotency) explains keys and batches.\n\n## 3. Read the message\n\nThe message log keeps every message with its recipients and its timeline, for the time on [Limits](sendora://docs/limits#retention). Read one by its id:\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/messages/{messageId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst message = await sendora.messages.get(\n '5f432ffd-c005-499b-aa3a-5b8a088ea20e',\n);\n\nfor (const recipient of message.recipients) {\n console.log(recipient.address, recipient.status);\n}\nfor (const event of message.events) {\n console.log(\n event.at,\n event.type,\n event.code,\n event.details,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nmessage = sendora.messages.get(\n \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\"\n)\n\nfor recipient in message.recipients:\n print(recipient.address, recipient.status)\nfor event in message.events:\n print(event.at, event.type, event.code, event.details)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$message = $sendora->messages->get(\n '5f432ffd-c005-499b-aa3a-5b8a088ea20e',\n);\n\nforeach ($message->recipients as $recipient) {\n printf(\n \"%s %s\\n\",\n $recipient->address,\n $recipient->status,\n );\n}\nforeach ($message->events as $event) {\n printf(\n \"%s %s %s %s\\n\",\n $event->at->format('c'),\n $event->type,\n $event->code,\n $event->details,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your account is ready\",\n \"tag\": \"welcome\",\n \"metadata\": {},\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": false,\n \"opens\": 0,\n \"clicks\": 0,\n \"firstOpenAt\": null,\n \"firstHumanOpenAt\": null\n }\n ],\n \"tracking\": {\n \"recordingOpens\": false,\n \"recordingClicks\": false,\n \"skipped\": null\n },\n \"attachments\": [],\n \"events\": [\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Reception\",\n \"at\": \"2026-09-15T12:00:00.000Z\",\n \"code\": 250,\n \"details\": null,\n \"classification\": null,\n \"tracking\": null\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Delivery\",\n \"at\": \"2026-09-15T12:00:02.000Z\",\n \"code\": 250,\n \"details\": \"2.0.0 OK\",\n \"classification\": null,\n \"tracking\": null\n }\n ]\n}\n```\n\nThe 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.\n\nThe server's **Messages** tab in the dashboard shows the same log, searchable by recipient, tag, status and time.\n\n## What next\n\n- Register a [webhook](sendora://docs/webhooks) to get `delivered`, `bounced`, `deferred` and `spam_complaint` events as they happen.\n- Send from a system that only speaks SMTP, as [SMTP submission](sendora://docs/smtp) shows.\n- Look up any route, with its fields and error codes, in the [API reference](sendora://index)."},{"uri":"sendora://docs/sending","title":"Sending","description":"Single sends, batches, attachments, headers and idempotency.","text":"# Sending\n\nSingle sends, batches, attachments, headers and idempotency.\n\nSend messages one at a time or in a batch. Both take the same message and return the same result for each message. The [reference](sendora://reference/post-v1-email) lists every field, and [Limits](sendora://docs/limits#messages) gives every size and count.\n\n## The message\n\n- `from` must be an address on a [verified sending domain](sendora://docs/domains) of your account.\n- `to`, `cc` and `bcc` take plain addresses or objects with `email` and a display `name`. The recipients of all three count together.\n- `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.\n- `streamId` sets the [stream](sendora://docs/streams) the message goes on. Without it, the message goes on the server's default transactional stream.\n\n## Attachments, headers, tags and metadata\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n {\n \"email\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n }\n ],\n \"cc\": [\n \"ekonomi@example.se\"\n ],\n \"subject\": \"Invoice 2026-0912\",\n \"text\": \"Hi Anna, invoice 2026-0912 is attached. It is due on 2026-10-15.\",\n \"html\": \"<p>Hi Anna,</p><p>Invoice <strong>2026-0912</strong> is attached. It is due on 2026-10-15.</p>\",\n \"attachments\": [\n {\n \"name\": \"invoice-2026-0912.pdf\",\n \"content\": \"JVBERi0xLjcKJcOkw7zDtsOfCg==\",\n \"contentType\": \"application/pdf\"\n }\n ],\n \"headers\": {\n \"Reply-To\": \"ekonomi@example.se\",\n \"X-Invoice-Id\": \"2026-0912\"\n },\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\",\n \"customerId\": \"4711\"\n }\n}'\n```\n\nTypeScript:\n\n```ts\nimport { readFile } from 'node:fs/promises';\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst invoice = await readFile(\n new URL('./invoice-2026-0912.pdf', import.meta.url),\n);\n\nconst { messageId } = await sendora.email.send({\n from: {\n email: 'billing@example.se',\n name: 'Example AB',\n },\n to: [\n { email: 'anna@example.com', name: 'Anna Andersson' },\n ],\n cc: ['ekonomi@example.se'],\n subject: 'Invoice 2026-0912',\n text: 'Hi Anna, invoice 2026-0912 is attached. It is due on 2026-10-15.',\n html: '<p>Hi Anna,</p><p>Invoice <strong>2026-0912</strong> is attached. It is due on 2026-10-15.</p>',\n attachments: [\n {\n name: 'invoice-2026-0912.pdf',\n content: invoice,\n contentType: 'application/pdf',\n },\n ],\n headers: {\n 'Reply-To': 'ekonomi@example.se',\n 'X-Invoice-Id': '2026-0912',\n },\n tag: 'invoice',\n metadata: {\n invoiceId: '2026-0912',\n customerId: '4711',\n },\n});\n\nconsole.log(messageId);\n```\n\nPython:\n\n```python\nimport os\nfrom pathlib import Path\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\ninvoice = (\n Path(__file__)\n .with_name(\"invoice-2026-0912.pdf\")\n .read_bytes()\n)\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\n {\n \"email\": \"anna@example.com\",\n \"name\": \"Anna Andersson\",\n }\n ],\n cc=[\"ekonomi@example.se\"],\n subject=\"Invoice 2026-0912\",\n text=(\n \"Hi Anna, invoice 2026-0912 is attached. \"\n \"It is due on 2026-10-15.\"\n ),\n html=(\n \"<p>Hi Anna,</p>\"\n \"<p>Invoice <strong>2026-0912</strong> \"\n \"is attached. It is due on 2026-10-15.</p>\"\n ),\n attachments=[\n {\n \"name\": \"invoice-2026-0912.pdf\",\n \"content\": invoice,\n \"content_type\": \"application/pdf\",\n }\n ],\n headers={\n \"Reply-To\": \"ekonomi@example.se\",\n \"X-Invoice-Id\": \"2026-0912\",\n },\n tag=\"invoice\",\n metadata={\n \"invoiceId\": \"2026-0912\",\n \"customerId\": \"4711\",\n },\n)\n\nprint(accepted.message_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Attachment;\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'billing@example.se',\n 'name' => 'Example AB',\n ],\n to: [\n [\n 'email' => 'anna@example.com',\n 'name' => 'Anna Andersson',\n ],\n ],\n cc: ['ekonomi@example.se'],\n subject: 'Invoice 2026-0912',\n text: 'Hi Anna, invoice 2026-0912 is attached. '\n . 'It is due on 2026-10-15.',\n html: '<p>Hi Anna,</p>'\n . '<p>Invoice <strong>2026-0912</strong> '\n . 'is attached. It is due on 2026-10-15.</p>',\n attachments: [\n Attachment::fromFile(\n __DIR__ . '/invoice-2026-0912.pdf',\n contentType: 'application/pdf',\n ),\n ],\n headers: [\n 'Reply-To' => 'ekonomi@example.se',\n 'X-Invoice-Id' => '2026-0912',\n ],\n tag: 'invoice',\n metadata: [\n 'invoiceId' => '2026-0912',\n 'customerId' => '4711',\n ],\n);\n\necho $accepted->messageId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"4e1c3f2a-9b6d-4e5c-9a0f-3d4e5f6a7b82\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false\n}\n```\n\n- `attachments`: send the content base64-encoded, without line breaks. `contentType` defaults to `application/octet-stream`. Set `contentId` to use the attachment as an inline image, with `<img src=\"cid:…\">` in the HTML. Sendora deletes the content once its mail server has the message, and keeps the name, type and size.\n- `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](sendora://reference/post-v1-email) lists every one.\n- `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.\n- `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.\n\nWith the TypeScript SDK:\n\nThe SDK takes attachment content as bytes, a `Uint8Array` or a Node `Buffer`, and encodes it. It passes a base64 string through as it is.\n\nWith the Python SDK:\n\nThe SDK takes attachment content as `bytes` and encodes it. It passes a base64 string through as it is. The content id is `content_id`.\n\nWith the PHP SDK:\n\n`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`.\n\n## Idempotency\n\nA 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](sendora://docs/limits#requests) gives the key's length and how long Sendora remembers it.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Idempotency-Key: order-4711-confirmation\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"order@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Order 4711 confirmed\",\n \"text\": \"Hi Anna, we have received order 4711 and will ship it tomorrow.\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { messageId } = await sendora.email.send(\n {\n from: {\n email: 'order@example.se',\n name: 'Example AB',\n },\n to: ['anna@example.com'],\n subject: 'Order 4711 confirmed',\n text: 'Hi Anna, we have received order 4711 and will ship it tomorrow.',\n },\n { idempotencyKey: 'order-4711-confirmation' },\n);\n\nconsole.log(messageId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"order@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\"anna@example.com\"],\n subject=\"Order 4711 confirmed\",\n text=(\n \"Hi Anna, we have received order 4711 \"\n \"and will ship it tomorrow.\"\n ),\n idempotency_key=\"order-4711-confirmation\",\n)\n\nprint(accepted.message_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'order@example.se',\n 'name' => 'Example AB',\n ],\n to: ['anna@example.com'],\n subject: 'Order 4711 confirmed',\n text: 'Hi Anna, we have received order 4711 '\n . 'and will ship it tomorrow.',\n idempotencyKey: 'order-4711-confirmation',\n);\n\necho $accepted->messageId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f2d4a3b-0c7e-4f6d-8b1a-4e5f6a7b8c93\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false\n}\n```\n\nOver HTTP:\n\nSend the key in the `Idempotency-Key` header. A replayed answer has the header `Idempotent-Replayed: true`.\n\nWith the TypeScript SDK:\n\nThe 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.\n\nWith the Python SDK:\n\nThe 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.\n\nWith the PHP SDK:\n\nThe 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.\n\nKeys are per server. The same key with a different message fails with `idempotency_key_mismatch`, since a changed request is a new request.\n\n## Batches\n\nA batch sends a list of messages in one request, up to the number on [Limits](sendora://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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email/batch \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Idempotency-Key: invoices-2026-09-15-run-1\" \\\n -H \"Content-Type: application/json\" \\\n -d '[\n {\n \"from\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Invoice 2026-0912\",\n \"text\": \"Hi Anna, invoice 2026-0912 is due on 2026-10-15.\",\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n }\n },\n {\n \"from\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"bo@example.com\"\n ],\n \"subject\": \"Invoice 2026-0913\",\n \"text\": \"Hi Bo, invoice 2026-0913 is due on 2026-10-15.\",\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0913\"\n }\n }\n]'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { results } = await sendora.email.sendBatch(\n [\n {\n from: {\n email: 'billing@example.se',\n name: 'Example AB',\n },\n to: ['anna@example.com'],\n subject: 'Invoice 2026-0912',\n text: 'Hi Anna, invoice 2026-0912 is due on 2026-10-15.',\n tag: 'invoice',\n metadata: { invoiceId: '2026-0912' },\n },\n {\n from: {\n email: 'billing@example.se',\n name: 'Example AB',\n },\n to: ['bo@example.com'],\n subject: 'Invoice 2026-0913',\n text: 'Hi Bo, invoice 2026-0913 is due on 2026-10-15.',\n tag: 'invoice',\n metadata: { invoiceId: '2026-0913' },\n },\n ],\n { idempotencyKey: 'invoices-2026-09-15-run-1' },\n);\n\nfor (const result of results) {\n if (result.status === 'accepted') {\n console.log(\n result.index,\n result.messageId,\n result.replayed ? 'sent earlier' : 'sent',\n );\n } else {\n console.log(\n result.index,\n result.error,\n result.message,\n );\n }\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nbatch = sendora.email.send_batch(\n [\n {\n \"from_\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\",\n },\n \"to\": [\"anna@example.com\"],\n \"subject\": \"Invoice 2026-0912\",\n \"text\": (\n \"Hi Anna, invoice 2026-0912 \"\n \"is due on 2026-10-15.\"\n ),\n \"tag\": \"invoice\",\n \"metadata\": {\"invoiceId\": \"2026-0912\"},\n },\n {\n \"from_\": {\n \"email\": \"billing@example.se\",\n \"name\": \"Example AB\",\n },\n \"to\": [\"bo@example.com\"],\n \"subject\": \"Invoice 2026-0913\",\n \"text\": (\n \"Hi Bo, invoice 2026-0913 \"\n \"is due on 2026-10-15.\"\n ),\n \"tag\": \"invoice\",\n \"metadata\": {\"invoiceId\": \"2026-0913\"},\n },\n ],\n idempotency_key=\"invoices-2026-09-15-run-1\",\n)\n\nfor result in batch.results:\n if result.status == \"accepted\":\n print(\n result.index,\n result.message_id,\n \"sent earlier\" if result.replayed else \"sent\",\n )\n else:\n print(result.index, result.code, result.message)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Types\\AcceptedBatchItem;\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$batch = $sendora->email->sendBatch(\n [\n [\n 'from' => [\n 'email' => 'billing@example.se',\n 'name' => 'Example AB',\n ],\n 'to' => ['anna@example.com'],\n 'subject' => 'Invoice 2026-0912',\n 'text' => 'Hi Anna, invoice 2026-0912 '\n . 'is due on 2026-10-15.',\n 'tag' => 'invoice',\n 'metadata' => ['invoiceId' => '2026-0912'],\n ],\n [\n 'from' => [\n 'email' => 'billing@example.se',\n 'name' => 'Example AB',\n ],\n 'to' => ['bo@example.com'],\n 'subject' => 'Invoice 2026-0913',\n 'text' => 'Hi Bo, invoice 2026-0913 '\n . 'is due on 2026-10-15.',\n 'tag' => 'invoice',\n 'metadata' => ['invoiceId' => '2026-0913'],\n ],\n ],\n idempotencyKey: 'invoices-2026-09-15-run-1',\n);\n\nforeach ($batch->results as $result) {\n if ($result instanceof AcceptedBatchItem) {\n printf(\n \"%d %s %s\\n\",\n $result->index,\n $result->messageId,\n $result->replayed ? 'sent earlier' : 'sent',\n );\n } else {\n printf(\n \"%d %s %s\\n\",\n $result->index,\n $result->errorCode,\n $result->message,\n );\n }\n}\n```\n\nResponse:\n\n```json\n{\n \"results\": [\n {\n \"index\": 0,\n \"messageId\": \"2c9a1d0e-7f4b-4c3a-9e8d-1b2c3d4e5f60\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"replayed\": false\n },\n {\n \"index\": 1,\n \"messageId\": \"3d0b2e1f-8a5c-4d4b-8f9e-2c3d4e5f6a71\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"replayed\": false\n }\n ]\n}\n```\n\nSendora 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.\n\n- 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`.\n- 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.\n- 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.\n\n## Send from Laravel\n\nThe 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:\n\n```sh\ncomposer require sendora/sendora-laravel\n```\n\nThen set the server key and the mailer in `.env`:\n\n```bash\nSENDORA_API_TOKEN=sk_…\nMAIL_MAILER=sendora\nMAIL_FROM_ADDRESS=no-reply@example.se\n```\n\nEvery 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.\n\n- A mailable's attachments, Cc, Bcc and Reply-To go through as Laravel sets them.\n- 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.\n- `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.\n\nA failed send throws Symfony's `TransportException`, with the SDK's exception as its previous one.\n\n## What happens next\n\nSendora 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](sendora://docs/messages) shows. To be told instead of polling, register a [webhook](sendora://docs/webhooks)."},{"uri":"sendora://docs/streams","title":"Streams","description":"Keep kinds of mail apart, each with its own suppression list.","text":"# Streams\n\nKeep kinds of mail apart, each with its own suppression list.\n\nEvery 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.\n\nEvery server starts with a default transactional stream. A message that names no stream goes there, so you can send without creating one.\n\n## Three kinds\n\n- `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.\n- `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](sendora://docs/broadcasts#rules-of-a-broadcast-stream), the unsubscribe link among them.\n- `inbound`: mail the server receives, as [Inbound](sendora://docs/inbound) explains. It sends nothing, and it has no suppression list.\n\nAny account can create transactional and broadcast streams. An inbound stream needs an approved account.\n\nAdd 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.\n\n## Create a stream\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/streams \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"kind\": \"transactional\",\n \"name\": \"Notices\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst stream = await sendora.streams.create({\n kind: 'transactional',\n name: 'Notices',\n});\n\nconsole.log(stream.streamId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nstream = sendora.streams.create(\n kind=\"transactional\", name=\"Notices\"\n)\n\nprint(stream.stream_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$stream = $sendora->streams->create(\n kind: 'transactional',\n name: 'Notices',\n);\n\necho $stream->streamId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\nThe 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](sendora://docs/suppressions) is empty, and you can limit a [webhook](sendora://docs/webhooks) to the stream.\n\n## Send on a stream\n\nSet `streamId` on the message. Without it, the message goes on the default stream. Over [SMTP](sendora://docs/smtp), set the header `X-Sendora-Stream` to the id instead.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your case has a new message\",\n \"text\": \"Hi Anna, there is a new message in your case. Sign in to read it.\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { messageId } = await sendora.email.send({\n from: {\n email: 'no-reply@example.se',\n name: 'Example AB',\n },\n streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n to: ['anna@example.com'],\n subject: 'Your case has a new message',\n text: 'Hi Anna, there is a new message in your case. Sign in to read it.',\n});\n\nconsole.log(messageId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\",\n },\n stream_id=\"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n to=[\"anna@example.com\"],\n subject=\"Your case has a new message\",\n text=(\n \"Hi Anna, there is a new message in your case. \"\n \"Sign in to read it.\"\n ),\n)\n\nprint(accepted.message_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'no-reply@example.se',\n 'name' => 'Example AB',\n ],\n streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n to: ['anna@example.com'],\n subject: 'Your case has a new message',\n text: 'Hi Anna, there is a new message in your case. '\n . 'Sign in to read it.',\n);\n\necho $accepted->messageId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"9a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-16T12:00:00.000Z\",\n \"test\": false\n}\n```\n\nA send fails when the stream cannot take it:\n\n| Code | When |\n| --------------------------------- | ------------------------------------------------------------------------------------------------------------ |\n| `stream_not_found` | The stream is not one of this server's. |\n| `stream_archived` | The stream is archived. |\n| `stream_not_sendable` | The stream is an inbound stream. A batch item or an SMTP submission that names it fails the same way. |\n| `stream_paused` | Sendora paused the broadcast stream after too many complaints. It shows `pausedAt` until support resumes it. |\n| `unsubscribe_placeholder_missing` | The stream is a broadcast stream, and a part of the message lacks `{{ unsubscribe_url }}`. |\n| `list_unsubscribe_reserved` | The stream is a broadcast stream, and the message sets its own `List-Unsubscribe` header. |\n\nThe last three follow from the [rules of a broadcast stream](sendora://docs/broadcasts#rules-of-a-broadcast-stream).\n\n## List, rename, archive\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/streams \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { streams } = await sendora.streams.list();\n\nfor (const stream of streams) {\n console.log(\n stream.name,\n stream.kind,\n stream.isDefault ? 'default' : '',\n stream.archivedAt ?? '',\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nlisted = sendora.streams.list()\n\nfor stream in listed.streams:\n print(\n stream.name,\n stream.kind,\n \"default\" if stream.is_default else \"\",\n stream.archived_at or \"\",\n )\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$listed = $sendora->streams->list();\n\nforeach ($listed->streams as $stream) {\n printf(\n \"%s %s %s %s\\n\",\n $stream->name,\n $stream->kind,\n $stream->isDefault ? 'default' : '',\n $stream->archivedAt?->format('c'),\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"streams\": [\n {\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"kind\": \"transactional\",\n \"name\": \"transactional\",\n \"isDefault\": true,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n },\n {\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n }\n ]\n}\n```\n\nEvery message in the [log](sendora://docs/messages) carries its `streamId`, and the search filters by it.\n\n- [Update a stream](sendora://reference/patch-v1-streams-id) to rename it. Its id stays the same. The same call sets an inbound stream's `contentRetentionDays`.\n- [Archive a stream](sendora://reference/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.\n- Archiving the default stream fails with `default_stream`.\n\nIn 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.\n\nWith the TypeScript SDK:\n\n`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.\n\nWith the Python SDK:\n\n`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.\n\nWith the PHP SDK:\n\n`$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."},{"uri":"sendora://docs/broadcasts","title":"Broadcasts","description":"One message to many recipients, each with their own unsubscribe link.","text":"# Broadcasts\n\nOne message to many recipients, each with their own unsubscribe link.\n\nA 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.\n\nA broadcast goes on a broadcast stream. Create one with `kind: \"broadcast\"` before your first broadcast, as [Streams](sendora://docs/streams#create-a-stream) shows.\n\n## Send a broadcast\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Idempotency-Key: newsletter-2026-10\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"from\": {\n \"email\": \"news@example.se\",\n \"name\": \"Example AB\"\n },\n \"subject\": \"News from Example in October\",\n \"text\": \"Hi!\\n\\nHere is what happened in October.\\n\\nNo more of these? {{ unsubscribe_url }}\",\n \"html\": \"<p>Hi!</p><p>Here is what happened in October.</p><p><a href=\\\"{{ unsubscribe_url }}\\\">Unsubscribe</a></p>\",\n \"tag\": \"newsletter-2026-10\",\n \"messages\": [\n {\n \"to\": [\n \"anna@example.com\"\n ],\n \"metadata\": {\n \"customer\": \"1042\"\n }\n },\n {\n \"to\": [\n {\n \"email\": \"bo@example.com\",\n \"name\": \"Bo Berg\"\n }\n ]\n }\n ]\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { broadcastId, total } =\n await sendora.broadcasts.send(\n {\n streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',\n from: {\n email: 'news@example.se',\n name: 'Example AB',\n },\n subject: 'News from Example in October',\n text: 'Hi!\\n\\nHere is what happened in October.\\n\\nNo more of these? {{ unsubscribe_url }}',\n html: '<p>Hi!</p><p>Here is what happened in October.</p><p><a href=\"{{ unsubscribe_url }}\">Unsubscribe</a></p>',\n tag: 'newsletter-2026-10',\n messages: [\n {\n to: ['anna@example.com'],\n metadata: { customer: '1042' },\n },\n {\n to: [\n { email: 'bo@example.com', name: 'Bo Berg' },\n ],\n },\n ],\n },\n { idempotencyKey: 'newsletter-2026-10' },\n );\n\nconsole.log(broadcastId, total);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.broadcasts.send(\n stream_id=\"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n from_={\n \"email\": \"news@example.se\",\n \"name\": \"Example AB\",\n },\n subject=\"News from Example in October\",\n text=(\n \"Hi!\\n\\n\"\n \"Here is what happened in October.\\n\\n\"\n \"No more of these? {{ unsubscribe_url }}\"\n ),\n html=(\n \"<p>Hi!</p>\"\n \"<p>Here is what happened in October.</p>\"\n '<p><a href=\"{{ unsubscribe_url }}\">'\n \"Unsubscribe</a></p>\"\n ),\n tag=\"newsletter-2026-10\",\n messages=[\n {\n \"to\": [\"anna@example.com\"],\n \"metadata\": {\"customer\": \"1042\"},\n },\n {\n \"to\": [\n {\n \"email\": \"bo@example.com\",\n \"name\": \"Bo Berg\",\n }\n ]\n },\n ],\n idempotency_key=\"newsletter-2026-10\",\n)\n\nprint(accepted.broadcast_id, accepted.total)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->broadcasts->send(\n streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',\n from: [\n 'email' => 'news@example.se',\n 'name' => 'Example AB',\n ],\n subject: 'News from Example in October',\n text: \"Hi!\\n\\n\"\n . \"Here is what happened in October.\\n\\n\"\n . 'No more of these? {{ unsubscribe_url }}',\n html: '<p>Hi!</p>'\n . '<p>Here is what happened in October.</p>'\n . '<p><a href=\"{{ unsubscribe_url }}\">'\n . 'Unsubscribe</a></p>',\n tag: 'newsletter-2026-10',\n messages: [\n [\n 'to' => ['anna@example.com'],\n 'metadata' => ['customer' => '1042'],\n ],\n [\n 'to' => [\n [\n 'email' => 'bo@example.com',\n 'name' => 'Bo Berg',\n ],\n ],\n ],\n ],\n idempotencyKey: 'newsletter-2026-10',\n);\n\necho $accepted->broadcastId, ' ', $accepted->total, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"status\": \"accepted\",\n \"total\": 2,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"test\": false\n}\n```\n\nGive 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.\n\n[Limits](sendora://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.\n\nThe `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.\n\nWith the TypeScript SDK:\n\nPass 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.\n\nWith the Python SDK:\n\nPass 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.\n\nWith the PHP SDK:\n\nPass 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`.\n\n## Rules of a broadcast stream\n\nThese rules apply to every message on a broadcast stream, whether it goes in a broadcast or alone.\n\n- Send only to people who asked for the mail, as the [acceptable use rules](https://sendora.se/acceptable-use) say.\n- 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`.\n- 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`.\n- A recipient who unsubscribes goes on the stream's [suppression list](sendora://docs/suppressions), and your [webhook](sendora://docs/webhooks) gets the event `unsubscribed`.\n\nIf complaints pass the rate on [Limits](sendora://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.\n\n## What is dropped and what is refused\n\n- 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`.\n- 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.\n- A broadcast on a transactional or an inbound stream fails with `stream_not_broadcast`, and on an archived one with `stream_archived`.\n\n## Personalise\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Idempotency-Key: welcome-2026-10\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"from\": {\n \"email\": \"news@example.se\",\n \"name\": \"Example AB\"\n },\n \"subject\": \"{{ first_name }}, your October news\",\n \"text\": \"Hi {{ first_name }}!\\n\\nYou have {{ points }} points to spend before the end of the year.\\n\\nNo more of these? {{ unsubscribe_url }}\",\n \"messages\": [\n {\n \"to\": [\n \"anna@example.com\"\n ],\n \"substitutions\": {\n \"first_name\": \"Anna\",\n \"points\": \"120\"\n }\n },\n {\n \"to\": [\n \"bo@example.com\"\n ],\n \"substitutions\": {\n \"first_name\": \"Bo\",\n \"points\": \"35\"\n }\n }\n ]\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { broadcastId } = await sendora.broadcasts.send(\n {\n streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',\n from: {\n email: 'news@example.se',\n name: 'Example AB',\n },\n subject: '{{ first_name }}, your October news',\n text: 'Hi {{ first_name }}!\\n\\nYou have {{ points }} points to spend before the end of the year.\\n\\nNo more of these? {{ unsubscribe_url }}',\n messages: [\n {\n to: ['anna@example.com'],\n substitutions: {\n first_name: 'Anna',\n points: '120',\n },\n },\n {\n to: ['bo@example.com'],\n substitutions: { first_name: 'Bo', points: '35' },\n },\n ],\n },\n { idempotencyKey: 'welcome-2026-10' },\n);\n\nconsole.log(broadcastId);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.broadcasts.send(\n stream_id=\"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n from_={\n \"email\": \"news@example.se\",\n \"name\": \"Example AB\",\n },\n subject=\"{{ first_name }}, your October news\",\n text=(\n \"Hi {{ first_name }}!\\n\\n\"\n \"You have {{ points }} points to spend \"\n \"before the end of the year.\\n\\n\"\n \"No more of these? {{ unsubscribe_url }}\"\n ),\n messages=[\n {\n \"to\": [\"anna@example.com\"],\n \"substitutions\": {\n \"first_name\": \"Anna\",\n \"points\": \"120\",\n },\n },\n {\n \"to\": [\"bo@example.com\"],\n \"substitutions\": {\n \"first_name\": \"Bo\",\n \"points\": \"35\",\n },\n },\n ],\n idempotency_key=\"welcome-2026-10\",\n)\n\nprint(accepted.broadcast_id)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->broadcasts->send(\n streamId: 'a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',\n from: [\n 'email' => 'news@example.se',\n 'name' => 'Example AB',\n ],\n subject: '{{ first_name }}, your October news',\n text: \"Hi {{ first_name }}!\\n\\n\"\n . 'You have {{ points }} points to spend '\n . \"before the end of the year.\\n\\n\"\n . 'No more of these? {{ unsubscribe_url }}',\n messages: [\n [\n 'to' => ['anna@example.com'],\n 'substitutions' => [\n 'first_name' => 'Anna',\n 'points' => '120',\n ],\n ],\n [\n 'to' => ['bo@example.com'],\n 'substitutions' => [\n 'first_name' => 'Bo',\n 'points' => '35',\n ],\n ],\n ],\n idempotencyKey: 'welcome-2026-10',\n);\n\necho $accepted->broadcastId, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"broadcastId\": \"9b7e5d3c-1a2f-4e6b-8c0d-2f4a6b8c0d1e\",\n \"status\": \"accepted\",\n \"total\": 2,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:10:00.000Z\",\n \"test\": false\n}\n```\n\nWrite `{{ 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](sendora://docs/limits#broadcasts) gives how many values a message may have, and how long each may be.\n\nSendora 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.\n\nNothing else is rendered: there are no conditions, loops or filters.\n\n## Follow a broadcast\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/broadcasts/{broadcastId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst broadcast = await sendora.broadcasts.get(\n '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',\n);\n\nconsole.log(\n broadcast.status,\n broadcast.released,\n broadcast.total,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nbroadcast = sendora.broadcasts.get(\n \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\"\n)\n\nprint(\n broadcast.status, broadcast.released, broadcast.total\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$broadcast = $sendora->broadcasts->get(\n '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',\n);\n\nprintf(\n \"%s %d %d\\n\",\n $broadcast->status,\n $broadcast->released,\n $broadcast->total,\n);\n```\n\nResponse:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"status\": \"processing\",\n \"from\": \"news@example.se\",\n \"subject\": \"News from Example in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 0,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": null,\n \"opened\": 0,\n \"openedByPeople\": 0,\n \"clicked\": 0,\n \"clickedByPeople\": 0,\n \"links\": []\n}\n```\n\n- `total`: the messages stored.\n- `released`: the messages handed to the mail server so far. Sendora releases them at the pace on [Limits](sendora://docs/limits#broadcasts). Divide the messages by that pace for the minutes a broadcast takes.\n- `failed`: the messages that never reached the mail server, cancelled ones included. A bounce is not counted here. It shows on its message.\n- `status`: `accepted`, then `processing`, then `completed` once nothing is left to send. A cancelled broadcast is `cancelled`. `completedAt` is set when it ends.\n- `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.\n- `openedByPeople` and `clickedByPeople`: those of them likely to be people.\n- `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](sendora://docs/limits#messages) says, so a recipient past those can be missing from a link.\n\nTracking is not open to accounts yet, so until it is, these counts are 0 and `links` is empty. Only this read has them, not the list or a cancel. [Messages and events](sendora://docs/messages#opens-and-clicks) says how the labels work.\n\nEach message of a broadcast is a message in the [log](sendora://docs/messages), with its own timeline and the `broadcastId`. The search filters by `broadcastId`, and every [webhook](sendora://docs/webhooks) event about such a message includes it. Follow deliveries, bounces, complaints and unsubscribes there, as for any other message.\n\n[List the broadcasts](sendora://reference/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.\n\n## Cancel a broadcast\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts/{broadcastId}/cancel \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst cancelled = await sendora.broadcasts.cancel(\n '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',\n);\n\nconsole.log(cancelled.status, cancelled.failed);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\ncancelled = sendora.broadcasts.cancel(\n \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\"\n)\n\nprint(cancelled.status, cancelled.failed)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$cancelled = $sendora->broadcasts->cancel(\n '2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01',\n);\n\necho $cancelled->status, ' ', $cancelled->failed, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"a3f1c2d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"status\": \"cancelled\",\n \"from\": \"news@example.se\",\n \"subject\": \"News from Example in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 1,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": \"2026-10-01T08:05:00.000Z\"\n}\n```\n\nA 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`."},{"uri":"sendora://docs/domains","title":"Domains","description":"Verify a sending domain with two DNS records.","text":"# Domains\n\nVerify a sending domain with two DNS records.\n\nYour 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.\n\n## Before you start\n\nDomains 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](sendora://docs/keys) explains.\n\n## Add the domain\n\nThe 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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/domains \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"domain\": \"mail.example.se\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst domain = await account.domains.create({\n domain: 'mail.example.se',\n});\n\nconsole.log(\n `${domain.returnPath.type} ${domain.returnPath.host} -> ${domain.returnPath.value}`,\n);\nconsole.log(\n `${domain.dkim.type} ${domain.dkim.host} -> ${domain.dkim.value}`,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\ndomain = account.domains.create(domain=\"mail.example.se\")\n\nfor record in (domain.return_path, domain.dkim):\n print(record.type, record.host, \"->\", record.value)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$domain = $account->domains->create(\n domain: 'mail.example.se',\n);\n\n$records = [$domain->returnPath, $domain->dkim];\nforeach ($records as $record) {\n printf(\n \"%s %s -> %s\\n\",\n $record->type,\n $record->host,\n $record->value,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"mail.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.mail.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"sendora-a1b2c3d4._domainkey.mail.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\nAdd both records at your DNS provider:\n\n- `returnPath`: a CNAME from `sendora-bounces.<your domain>` to `bounces.sendora.se`. Bounce reports and feedback-loop reports for your mail arrive there and become events on your messages.\n- `dkim`: a TXT record under `<selector>._domainkey.<your domain>` 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.\n\nThe 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`.\n\nAdding a domain the account already has fails with `domain_exists`, which gives the id of the existing one.\n\n## Check the records\n\nSendora 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:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/domains/{domainId}/verify \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst domain = await account.domains.verify(\n '0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01',\n);\n\nconsole.log(\n domain.verified,\n domain.check.returnPath,\n domain.check.dkim,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\ndomain = account.domains.verify(\n \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\"\n)\n\nprint(\n domain.verified,\n domain.check.return_path,\n domain.check.dkim,\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$domain = $account->domains->verify(\n '0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01',\n);\n\nprintf(\n \"%s %s %s\\n\",\n $domain->verified ? 'verified' : 'not verified',\n $domain->check->returnPath,\n $domain->check->dkim,\n);\n```\n\nResponse:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"mail.example.se\",\n \"verified\": true,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": \"2026-09-15T12:10:00.000Z\",\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.mail.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-15T12:10:00.000Z\"\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"sendora-a1b2c3d4._domainkey.mail.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-15T12:10:00.000Z\"\n },\n \"check\": {\n \"returnPath\": \"ok\",\n \"dkim\": \"ok\"\n }\n}\n```\n\n`check` says what each lookup found:\n\n| `check` | Meaning |\n| ----------- | ------------------------------------------------------------------------------------- |\n| `ok` | The record is there with the right value. |\n| `missing` | There is no record. |\n| `mismatch` | A record is there with another value. |\n| `dns_error` | The resolver got no trustworthy answer, for example because DNSSEC validation failed. |\n\nThe domain is `verified` once both records have been seen.\n\n## When a record goes missing\n\nA verified domain rides out DNS trouble rather than stopping at the first failed lookup:\n\n- A `dns_error` changes nothing. The resolver could not answer, which says nothing about your records, so the domain stays as it was.\n- 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.\n- Any check that finds both records ends the warning.\n- 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.\n\nA DNS move is the usual cause. Publish both records at the new provider before you switch, and the domain never enters its warning.\n\n## SPF, DKIM and DMARC\n\n- SPF is satisfied by the return-path domain, `sendora-bounces.<your domain>`, whose records Sendora keeps. It aligns with your domain under DMARC's relaxed mode.\n- DKIM signs with your domain, through the key above, so it aligns strictly.\n- DMARC is your own record, `_dmarc.<your domain>`. 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.\n\n## List and remove domains\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/domains \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst { domains } = await account.domains.list();\n\nfor (const domain of domains) {\n console.log(\n domain.domain,\n domain.verified ? 'verified' : 'waiting for DNS',\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nlisted = account.domains.list()\n\nfor domain in listed.domains:\n if domain.verified:\n print(domain.domain, \"verified\")\n else:\n print(domain.domain, \"waiting for DNS\")\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$listed = $account->domains->list();\n\nforeach ($listed->domains as $domain) {\n if ($domain->verified) {\n echo $domain->domain, \" verified\\n\";\n } else {\n echo $domain->domain, \" waiting for DNS\\n\";\n }\n}\n```\n\nResponse:\n\n```json\n{\n \"domains\": [\n {\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"mail.example.se\",\n \"verified\": true,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": \"2026-09-15T12:10:00.000Z\",\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.mail.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-15T12:10:00.000Z\"\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"sendora-a1b2c3d4._domainkey.mail.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA…\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-15T12:10:00.000Z\"\n }\n }\n ]\n}\n```\n\n[Removing a domain](sendora://reference/delete-v1-domains-id) refuses mail from it at once and discards its key. Adding it again creates a new key and new records.\n\n## Tracking domains\n\nA 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. Tracking is not open to accounts yet, so a host carries no mail until it is.\n\nAdd 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](sendora://docs/limits#account) says how many an account may have.\n\n### Add a tracking domain\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/tracking-domains \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"host\": \"links.example.se\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst domain = await account.trackingDomains.create({\n host: 'links.example.se',\n});\n\nconsole.log(domain.cname.host, domain.cname.value);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\ndomain = account.tracking_domains.create(\n host=\"links.example.se\"\n)\n\nprint(domain.cname.host, domain.cname.value)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$domain = $account->trackingDomains->create(\n host: 'links.example.se',\n);\n\nprintf(\n \"%s %s\\n\",\n $domain->cname->host,\n $domain->cname->value,\n);\n```\n\nResponse:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": null\n },\n \"caa\": {\n \"check\": null,\n \"record\": null\n }\n}\n```\n\nAdd 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](sendora://docs/limits#account) says.\n\nA claim can fail with these codes:\n\n| Code | Status | Meaning |\n| --------------------------------- | ------ | --------------------------------------------------------------------------------------------- |\n| `domain_reserved` | 400 | The name is Sendora's own, or holds a domain's return path. |\n| `tracking_domain_exists` | 409 | The account has that host, or a host on that sending domain. `trackingDomainId` gives its id. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |\n| `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. Choose a subdomain of it. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |\n\nIf 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.\n\n### Check the records\n\nSendora 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:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/tracking-domains/{trackingDomainId}/verify \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst domain = await account.trackingDomains.verify(\n '3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09',\n);\n\nconsole.log(\n domain.state,\n domain.cname.check,\n domain.caa.check,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\ndomain = account.tracking_domains.verify(\n \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\"\n)\n\nprint(\n domain.state,\n domain.cname.check,\n domain.caa.check,\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$domain = $account->trackingDomains->verify(\n '3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09',\n);\n\nprintf(\n \"%s %s %s\\n\",\n $domain->state,\n $domain->cname->check ?? 'not checked',\n $domain->caa->check ?? 'not checked',\n);\n```\n\nResponse:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"waiting_for_certificate\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": \"2026-11-05T12:10:00.000Z\",\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": null,\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": \"ok\"\n },\n \"caa\": {\n \"check\": \"ok\",\n \"record\": null\n }\n}\n```\n\n`cname.check` says what the lookup of the CNAME found:\n\n| `check` | Meaning |\n| ----------- | ----------------------------------------------------------------------------------------------------------- |\n| `ok` | The chain of CNAME records ends at `track.sendora.se`, and the host has the same addresses. |\n| `missing` | The name answers nothing. |\n| `mismatch` | The name points elsewhere, through another CNAME or an A record. An A record fails even if it is Sendora's. |\n| `proxied` | A proxy answers in front of the name. Switch it off for this record. |\n| `dns_error` | The resolver got no trustworthy answer. |\n\n`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:\n\n| `check` | Meaning |\n| ----------------- | --------------------------------------------------------- |\n| `ok` | There are no such records, or they allow it. |\n| `no_lets_encrypt` | The records do not allow Let's Encrypt. |\n| `other_account` | The records allow only another account at Let's Encrypt. |\n| `no_http_01` | The records do not allow the validation method `http-01`. |\n| `dns_error` | The resolver got no trustworthy answer. |\n\n`state` says where the host stands:\n\n| `state` | Meaning |\n| ------------------------- | -------------------------------------------------------------------------------------------------------- |\n| `pending` | The records have not been right yet. `expiresAt` says when the host goes. |\n| `waiting_for_certificate` | The records are right, and the certificate is not working yet. |\n| `ready` | New mail from the sending domain uses the host. |\n| `failing` | A check found the records wrong. A host that was ready stays in use until 24 hours after `failingSince`. |\n| `stopped` | Those 24 hours have passed, and new mail no longer uses the host. |\n| `sending_domain_lost` | The sending domain is deleted or has lost its verification, so new mail does not use the host. |\n\n### When a tracking domain's records go wrong\n\n- A `dns_error` changes nothing. The resolver could not answer, which says nothing about your records.\n- A check that finds the records wrong after they have been right starts a 24-hour warning, and `failingSince` says when it began.\n- If the records are still wrong an hour later, your account's administrators are mailed. A new record can look missing for that long.\n- Any check that finds the records right ends the warning.\n\n### Remove a tracking domain\n\n[Removing a tracking domain](sendora://reference/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."},{"uri":"sendora://docs/keys","title":"Keys","description":"The two kinds of API key, and how to rotate one.","text":"# Keys\n\nThe two kinds of API key, and how to rotate one.\n\nEvery request carries an API key as a bearer token. There are two kinds, and an operation takes only one of them.\n\n## Two kinds of key\n\n| | Server key | Account key |\n| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| Starts with | `sk_`, or `sk_test_` on a [test server](sendora://docs/test-servers) | `ak_` |\n| Belongs to | One server | The account |\n| 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. |\n| Never | Manages the account's servers or domains | Sends or reads mail |\n| 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. |\n\nYour 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.\n\nWith the TypeScript SDK:\n\nThe 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.\n\nWith the Python SDK:\n\nThe 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.\n\nWith the PHP SDK:\n\nThe 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.\n\n## A server comes with its first key\n\nA 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:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/servers \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Invoicing\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst server = await account.servers.create({\n name: 'Invoicing',\n});\n\nconsole.log(server.serverId);\nconsole.log(server.token.token);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nserver = account.servers.create(name=\"Invoicing\")\n\nprint(server.server_id)\nprint(server.token.token)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$server = $account->servers->create(name: 'Invoicing');\n\necho $server->serverId, \"\\n\";\necho $server->token->token, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"token\": {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n }\n}\n```\n\nStore `token.token` in your secret store. It is never shown again.\n\n## Create and list keys\n\nA 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.\n\nCreate a key:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/servers/{serverId}/tokens \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"Invoicing system\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst key = await account.servers.tokens.create(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n { name: 'Invoicing system' },\n);\n\nconsole.log(key.token);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nkey = account.servers.tokens.create(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n name=\"Invoicing system\",\n)\n\nprint(key.token)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$key = $account->servers->tokens->create(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n name: 'Invoicing system',\n);\n\necho $key->token, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"tokenId\": \"9f2d7e6c-5b40-4e8b-8a1c-3b0c9e2f6d4a\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_c3d4e5f6g7\",\n \"createdAt\": \"2026-09-20T12:05:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x\"\n}\n```\n\nList a server's keys:\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/servers/{serverId}/tokens \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst { tokens } = await account.servers.tokens.list(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n\nfor (const key of tokens) {\n console.log(\n key.name,\n key.prefix,\n key.revokedAt ? 'revoked' : 'live',\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nlisted = account.servers.tokens.list(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\"\n)\n\nfor key in listed.tokens:\n print(\n key.name,\n key.prefix,\n \"revoked\" if key.revoked_at else \"live\",\n )\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$listed = $account->servers->tokens->list(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n\nforeach ($listed->tokens as $key) {\n printf(\n \"%s %s %s\\n\",\n $key->name,\n $key->prefix,\n $key->revokedAt === null ? 'live' : 'revoked',\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"tokens\": [\n {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null\n },\n {\n \"tokenId\": \"9f2d7e6c-5b40-4e8b-8a1c-3b0c9e2f6d4a\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_c3d4e5f6g7\",\n \"createdAt\": \"2026-09-20T12:05:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\nThese samples use the account key. A server key manages its own server's keys the same way:\n\n| Key | Manages the keys of | Path |\n| ----------- | ------------------------- | ------------------------------- |\n| Server key | Its own server | `/v1/tokens` |\n| Account key | Any server of the account | `/v1/servers/{serverId}/tokens` |\n\nWith the TypeScript SDK:\n\nWith a server key, `sendora.tokens.create({ name })`, `sendora.tokens.list()` and `sendora.tokens.revoke(tokenId)` manage the server's own keys.\n\nWith the Python SDK:\n\nWith a server key, `sendora.tokens.create(name=...)`, `sendora.tokens.list()` and `sendora.tokens.revoke(token_id)` manage the server's own keys.\n\nWith the PHP SDK:\n\nWith a server key, `$sendora->tokens->create(name: ...)`, `$sendora->tokens->list()` and `$sendora->tokens->revoke($tokenId)` manage the server's own keys.\n\n## Rotation is create, switch, revoke\n\n1. Create the new key.\n2. Move your systems to it; both keys work meanwhile.\n3. Revoke the old one. It stops working at once.\n\ncURL:\n\n```bash\ncurl -X DELETE https://api.sendora.se/v1/servers/{serverId}/tokens/{tokenId} \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nawait account.servers.tokens.revoke(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n '3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40',\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\naccount.servers.tokens.revoke(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$account->servers->tokens->revoke(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n '3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40',\n);\n```\n\nThe 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.\n\nIf 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."},{"uri":"sendora://docs/test-servers","title":"Test servers","description":"Send from staging and CI without delivering anything, and act out bounces and complaints.","text":"# Test servers\n\nSend from staging and CI without delivering anything, and act out bounces and complaints.\n\nA 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.\n\nA 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.\n\n## Create a test server\n\nIn 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\"`:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/servers \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"CI\",\n \"mode\": \"test\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst server = await account.servers.create({\n name: 'CI',\n mode: 'test',\n});\n\nconsole.log(server.token.token);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nserver = account.servers.create(name=\"CI\", mode=\"test\")\n\nprint(server.token.token)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$server = $account->servers->create(\n name: 'CI',\n mode: 'test',\n);\n\necho $server->token->token, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"serverId\": \"e4a7c2b9-3d1f-4b6e-9a8c-2f5d7b1e0c63\",\n \"name\": \"CI\",\n \"mode\": \"test\",\n \"createdAt\": \"2026-09-23T09:00:00.000Z\",\n \"token\": {\n \"tokenId\": \"c8e1f3a5-7b9d-4c2e-8f60-1a3b5c7d9e2f\",\n \"name\": \"default\",\n \"prefix\": \"sk_test_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-23T09:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n }\n}\n```\n\nA 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.\n\nA server's mode is fixed when you create it. An update that sets `mode` fails with `400 mode_immutable`.\n\n## Send with it\n\nSend 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`:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"hardbounce+signup-test@simulator.sendora.se\"\n ],\n \"subject\": \"Confirm your address\",\n \"text\": \"Confirm at https://example.se/confirm/abc123.\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { messageId, test } = await sendora.email.send({\n from: {\n email: 'no-reply@example.se',\n name: 'Example AB',\n },\n to: ['hardbounce+signup-test@simulator.sendora.se'],\n subject: 'Confirm your address',\n text: 'Confirm at https://example.se/confirm/abc123.',\n});\n\nconsole.log(messageId, test);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\naccepted = sendora.email.send(\n from_={\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\"hardbounce+signup-test@simulator.sendora.se\"],\n subject=\"Confirm your address\",\n text=\"Confirm at https://example.se/confirm/abc123.\",\n)\n\nprint(accepted.message_id, accepted.test)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$accepted = $sendora->email->send(\n from: [\n 'email' => 'no-reply@example.se',\n 'name' => 'Example AB',\n ],\n to: ['hardbounce+signup-test@simulator.sendora.se'],\n subject: 'Confirm your address',\n text: 'Confirm at https://example.se/confirm/abc123.',\n);\n\nprintf(\n \"%s %s\\n\",\n $accepted->messageId,\n $accepted->test ? 'test' : 'live',\n);\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"0b6f2d8e-4a1c-4e7b-9d3f-5c8a2e6b1f40\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-23T09:01:00.000Z\",\n \"test\": true\n}\n```\n\n[SMTP submission](sendora://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.\n\n## The simulator's addresses\n\nA test server reports every recipient as delivered, without sending anything, except at `simulator.sendora.se`. There, the part before the `@` decides what happens:\n\n| Address | What happens |\n| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| `hardbounce@simulator.sendora.se` | A hard bounce: the `bounced` webhook with `hard: true`, and the address is suppressed on that [stream](sendora://docs/streams) |\n| `softbounce@simulator.sendora.se` | A soft bounce, a full mailbox: the `bounced` webhook with `hard: false`, and no suppression |\n| `deferred@simulator.sendora.se` | The `deferred` webhook, then delivered a minute later |\n| `complaint@simulator.sendora.se` | Delivered, then a spam complaint: the `spam_complaint` webhook, and the address is suppressed |\n| any other address | Delivered |\n\nCase 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.\n\nA 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`:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"hardbounce+signup-test@simulator.sendora.se\"\n ],\n \"subject\": \"Confirm your address\",\n \"text\": \"Confirm at https://example.se/confirm/abc123.\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora, SendoraError } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\ntry {\n await sendora.email.send({\n from: {\n email: 'no-reply@example.se',\n name: 'Example AB',\n },\n to: ['hardbounce+signup-test@simulator.sendora.se'],\n subject: 'Confirm your address',\n text: 'Confirm at https://example.se/confirm/abc123.',\n });\n} catch (error) {\n if (\n error instanceof SendoraError &&\n error.code === 'test_address_on_live_server'\n ) {\n console.log(error.addresses);\n } else {\n throw error;\n }\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora, SendoraError\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\ntry:\n sendora.email.send(\n from_={\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\",\n },\n to=[\n \"hardbounce+signup-test@simulator.sendora.se\"\n ],\n subject=\"Confirm your address\",\n text=(\n \"Confirm at \"\n \"https://example.se/confirm/abc123.\"\n ),\n )\nexcept SendoraError as error:\n if error.code == \"test_address_on_live_server\":\n print(error.addresses)\n else:\n raise\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Exception\\SendoraException;\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\ntry {\n $sendora->email->send(\n from: [\n 'email' => 'no-reply@example.se',\n 'name' => 'Example AB',\n ],\n to: [\n 'hardbounce+signup-test@simulator.sendora.se',\n ],\n subject: 'Confirm your address',\n text: 'Confirm at '\n . 'https://example.se/confirm/abc123.',\n );\n} catch (SendoraException $error) {\n $code = 'test_address_on_live_server';\n if ($error->errorCode !== $code) {\n throw $error;\n }\n echo implode(', ', $error->addresses), \"\\n\";\n}\n```\n\nResponse:\n\n```json\n{\n \"error\": \"test_address_on_live_server\",\n \"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.\",\n \"addresses\": [\n \"hardbounce+signup-test@simulator.sendora.se\"\n ]\n}\n```\n\n## Limits and cost\n\nTest 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](sendora://docs/limits#test-servers). A refusal under them has the scope `test`. Write to support if your CI needs more.\n\nA 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`.\n\nA test server keeps messages for less time than a live server, as [Limits](sendora://docs/limits#retention) gives. The content of attachments is deleted as soon as the simulator has the message.\n\n## Going live\n\nA 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:\n\nOver HTTP:\n\nA test key starts with `sk_test_`, and a live server's key with `sk_` alone. Check for `sk_test_` when your application starts.\n\nWith the TypeScript SDK:\n\n`isTestKey` tells the two kinds of key apart:\n\n```ts\nimport { isTestKey, Sendora } from '@sendora/sdk';\n\nconst token = process.env.SENDORA_API_TOKEN ?? '';\nif (process.env.NODE_ENV === 'production' && isTestKey(token)) {\n throw new Error('SENDORA_API_TOKEN is a test key; nothing would be delivered');\n}\nconst sendora = new Sendora({ token });\n```\n\nWith the Python SDK:\n\n`is_test_key` tells the two kinds of key apart:\n\n```python\nimport os\n\nfrom sendora import Sendora, is_test_key\n\ntoken = os.environ[\"SENDORA_API_TOKEN\"]\nproduction = os.environ.get(\"APP_ENV\") == \"production\"\nif production and is_test_key(token):\n message = (\n \"SENDORA_API_TOKEN is a test key; \"\n \"nothing would be delivered\"\n )\n raise RuntimeError(message)\nsendora = Sendora(token)\n```\n\nWith the PHP SDK:\n\n`Keys::isTestKey()` tells the two kinds of key apart:\n\n```php\n<?php\n\nuse Sendora\\Keys;\nuse Sendora\\Sendora;\n\n$token = (string) getenv('SENDORA_API_TOKEN');\n$production = getenv('APP_ENV') === 'production';\nif ($production && Keys::isTestKey($token)) {\n throw new RuntimeException(\n 'SENDORA_API_TOKEN is a test key; '\n . 'nothing would be delivered',\n );\n}\n$sendora = new Sendora($token);\n```\n\nTo ask the API instead, read the key's server. The answer gives the server's `mode` and the sending domains a From address may use. It also lists each limit a send meets, with how much this minute and this month have used:\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/server \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { mode, limits } = await sendora.server.get();\nif (\n process.env.NODE_ENV === 'production' &&\n mode === 'test'\n) {\n throw new Error('This key belongs to a test server');\n}\n\nconsole.log(limits);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nserver = sendora.server.get()\nif (\n os.environ.get(\"APP_ENV\") == \"production\"\n and server.mode == \"test\"\n):\n message = \"This key belongs to a test server\"\n raise RuntimeError(message)\n\nprint(server.limits)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$server = $sendora->server->get();\nif (\n getenv('APP_ENV') === 'production'\n && $server->mode === 'test'\n) {\n throw new RuntimeException(\n 'This key belongs to a test server',\n );\n}\n\nforeach ($server->limits as $limit) {\n printf(\n \"%s %s %d of %d\\n\",\n $limit->scope,\n $limit->period,\n $limit->used,\n $limit->limit,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"serverId\": \"e4a7c2b9-3d1f-4b6e-9a8c-2f5d7b1e0c63\",\n \"name\": \"CI\",\n \"mode\": \"test\",\n \"createdAt\": \"2026-09-23T09:00:00.000Z\",\n \"limits\": [\n {\n \"scope\": \"test\",\n \"period\": \"minute\",\n \"limit\": 300,\n \"used\": 1,\n \"resetsAt\": \"2026-09-23T09:02:00.000Z\"\n },\n {\n \"scope\": \"test\",\n \"period\": \"month\",\n \"limit\": 10000,\n \"used\": 1,\n \"resetsAt\": \"2026-10-01T00:00:00.000Z\"\n }\n ],\n \"inboundCap\": null,\n \"sendingDomains\": [\n {\n \"domain\": \"example.se\",\n \"failingSince\": null\n }\n ]\n}\n```"},{"uri":"sendora://docs/messages","title":"Messages and events","description":"Statuses, timelines and searching the log.","text":"# Messages and events\n\nStatuses, timelines and searching the log.\n\nEvery 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.\n\n## Statuses\n\nA 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.\n\nEach 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](sendora://docs/webhooks) event:\n\n| What happened | Status | Timeline event | Webhook event |\n| -------------------------------------------------- | ----------- | ------------------ | ------------------------------- |\n| Sendora's mail server took the message | `queued` | `Reception` | none |\n| The receiver accepted it | `delivered` | `Delivery` | `delivered` |\n| The receiver asked Sendora to try later | `deferred` | `TransientFailure` | `deferred` |\n| The receiver refused it | `bounced` | `Bounce` | `bounced` |\n| A bounce report arrived later by mail | `bounced` | `OOB` | `bounced` |\n| The receiver was still deferring after three days | `expired` | `Expiration` | `bounced`, with `expired: true` |\n| The recipient reported it as spam | unchanged | `Feedback` | `spam_complaint` |\n| The recipient unsubscribed from a broadcast stream | unchanged | none | `unsubscribed` |\n| A tracked copy's pixel loaded | unchanged | `Open` | `opened` |\n| A tracked link was followed | unchanged | `Click` | `clicked` |\n\nA 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`.\n\nA 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.\n\nRead a message with its timeline by its id:\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/messages/{messageId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst message = await sendora.messages.get(\n '5f432ffd-c005-499b-aa3a-5b8a088ea20e',\n);\n\nfor (const recipient of message.recipients) {\n console.log(recipient.address, recipient.status);\n}\nfor (const event of message.events) {\n console.log(\n event.at,\n event.type,\n event.code,\n event.details,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nmessage = sendora.messages.get(\n \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\"\n)\n\nfor recipient in message.recipients:\n print(recipient.address, recipient.status)\nfor event in message.events:\n print(event.at, event.type, event.code, event.details)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$message = $sendora->messages->get(\n '5f432ffd-c005-499b-aa3a-5b8a088ea20e',\n);\n\nforeach ($message->recipients as $recipient) {\n printf(\n \"%s %s\\n\",\n $recipient->address,\n $recipient->status,\n );\n}\nforeach ($message->events as $event) {\n printf(\n \"%s %s %s %s\\n\",\n $event->at->format('c'),\n $event->type,\n $event->code,\n $event->details,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your account is ready\",\n \"tag\": \"welcome\",\n \"metadata\": {},\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": false,\n \"opens\": 0,\n \"clicks\": 0,\n \"firstOpenAt\": null,\n \"firstHumanOpenAt\": null\n }\n ],\n \"tracking\": {\n \"recordingOpens\": false,\n \"recordingClicks\": false,\n \"skipped\": null\n },\n \"attachments\": [],\n \"events\": [\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Reception\",\n \"at\": \"2026-09-15T12:00:00.000Z\",\n \"code\": 250,\n \"details\": null,\n \"classification\": null,\n \"tracking\": null\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Delivery\",\n \"at\": \"2026-09-15T12:00:02.000Z\",\n \"code\": 250,\n \"details\": \"2.0.0 OK\",\n \"classification\": null,\n \"tracking\": null\n }\n ]\n}\n```\n\n## Opens and clicks\n\nThe log shows the opens and clicks of mail that is tracked. Tracking is not open to accounts yet, so until it is, `tracked` is `false` and the counts are 0.\n\nEach recipient has these fields, in a message you read by its id and in a search:\n\n| Field | What it says |\n| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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 |\n| `opens`, `clicks` | How many were recorded, machines' included |\n| `firstOpenAt` | The first open, or the first click that was likely a person's; `null` before that |\n| `firstHumanOpenAt` | The same, counting only what was likely a person; `null` before that |\n\nA message you read by its id also has `tracking`:\n\n| Field | What it says |\n| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `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 |\n| `recordingClicks` | The same for clicks on its links |\n| `skipped` | Why tracking stayed out of at least one copy, or `null`. `no_tracking_domain`: the from domain had no working [tracking domain](sendora://docs/domains#tracking-domains). `gmail_clipping`: see below |\n\nTracked 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.\n\nAn `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:\n\n| Field | What it says |\n| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `automated` | Whether a machine rather than a person did it: `likely`, `unlikely`, or `unknown` where nobody can tell |\n| `agent` | The proxy or machine, where Sendora knows it: `apple_privacy`, `gmail_prefetch`, `gmail_proxy`, `yahoo_proxy`, `outlook_prefetch`, `proton_proxy`, `scanner` or `crawler` |\n| `reasons` | Why it was labelled a machine's: `privacy_proxy`, `prefetch_signature`, `delivery_timing`, `link_burst`, `scanner_network`, `bot_user_agent` or `many_networks` |\n| `client` | The mail program, where Sendora knows it: `apple_mail`, `gmail`, `outlook`, `yahoo`, `thunderbird`, `proton`, `browser` or `other` |\n| `device` | The kind of device, where Sendora knows it: `desktop`, `mobile` or `tablet` |\n| `first` | The recipient's first event of this kind |\n| `firstHuman` | The recipient's first event of this kind that was likely a person's |\n| `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 |\n\nA 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`.\n\nThe timeline may not hold every open and click, while `opens` and `clicks` count them all. [Limits](sendora://docs/limits#messages) gives how many a recipient keeps and how many a read returns.\n\n## Search the log\n\nEvery filter is optional:\n\n| Filter | Matches |\n| ------------- | ------------------------------------------------------------------------------------ |\n| `recipient` | Messages to this address, or with this part of one, in `to`, `cc` or `bcc` |\n| `streamId` | Messages on this [stream](sendora://docs/streams) |\n| `broadcastId` | Messages of this [broadcast](sendora://docs/broadcasts) |\n| `tag` | Messages with this tag |\n| `status` | Messages with at least one recipient in this status, or in any of a list of statuses |\n| `from` | Messages submitted at or after this time |\n| `to` | Messages submitted before this time |\n\nA whole address in `recipient` matches exactly. Three characters or more of one match anywhere in an address, such as `kommun.se`.\n\n`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](sendora://docs/limits#page-sizes). Pass `next` as `after` for the next page.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/messages/search \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"tag\": \"welcome\",\n \"from\": \"2026-09-01T00:00:00Z\",\n \"limit\": 20\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst page = await sendora.messages.search({\n tag: 'welcome',\n from: '2026-09-01T00:00:00Z',\n limit: 20,\n});\n\nfor (const message of page.messages) {\n console.log(\n message.submittedAt,\n message.subject,\n message.recipients[0]?.status,\n );\n}\nconsole.log(\n page.next === null\n ? 'last page'\n : `next page after ${page.next}`,\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.messages.search(\n tag=\"welcome\", from_=\"2026-09-01T00:00:00Z\", limit=20\n)\n\nfor message in page.messages:\n print(\n message.submitted_at,\n message.subject,\n message.recipients[0].status,\n )\nif page.next is None:\n print(\"last page\")\nelse:\n print(f\"next page after {page.next}\")\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->messages->search(\n tag: 'welcome',\n from: '2026-09-01T00:00:00Z',\n limit: 20,\n);\n\nforeach ($page->messages as $message) {\n printf(\n \"%s %s %s\\n\",\n $message->submittedAt->format('c'),\n $message->subject,\n $message->recipients[0]->status ?? '',\n );\n}\nif ($page->next === null) {\n echo \"last page\\n\";\n} else {\n echo \"next page after {$page->next}\\n\";\n}\n```\n\nResponse:\n\n```json\n{\n \"messages\": [\n {\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your account is ready\",\n \"tag\": \"welcome\",\n \"metadata\": {},\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": false,\n \"opens\": 0,\n \"clicks\": 0,\n \"firstOpenAt\": null,\n \"firstHumanOpenAt\": null\n }\n ]\n }\n ],\n \"next\": null\n}\n```\n\nWith the TypeScript SDK:\n\n`messages.searchAll` takes the same filters and walks every page for you, for `for await`. Its `from` and `to` also take a `Date`.\n\nWith the Python SDK:\n\n`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`.\n\nWith the PHP SDK:\n\n`$sendora->messages->searchAll()` takes the same filters and walks every page for you, as a generator. Its `from` and `to` also take a `DateTimeInterface`.\n\n## How long a message is kept\n\nSendora 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](sendora://docs/limits#retention) gives each period, and a [test server](sendora://docs/test-servers) keeps less.\n\nWhen a recipient asks you to erase their data, use an [erasure request](sendora://docs/suppressions#erasure-requests)."},{"uri":"sendora://docs/inbound","title":"Inbound","description":"Receive mail on an inbound stream or on your own domain, and read it.","text":"# Inbound\n\nReceive mail on an inbound stream or on your own domain, and read it.\n\nAn inbound [stream](sendora://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](sendora://docs/webhooks).\n\nSendora 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.\n\n## Before you start\n\n- An approved account. Until it is approved, creating an inbound stream fails.\n- A live server and its key. A [test server](sendora://docs/test-servers) has no inbound stream.\n\n## Receive your first message\n\n1. Create an inbound stream.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/streams \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"kind\": \"inbound\",\n \"name\": \"Support\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst stream = await sendora.streams.create({\n kind: 'inbound',\n name: 'Support',\n});\n\nconsole.log(stream.inboundAddress);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nstream = sendora.streams.create(\n kind=\"inbound\", name=\"Support\"\n)\n\nprint(stream.inbound_address)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$stream = $sendora->streams->create(\n kind: 'inbound',\n name: 'Support',\n);\n\necho $stream->inboundAddress, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"kind\": \"inbound\",\n \"name\": \"Support\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-19T07:55:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\",\n \"contentRetentionDays\": null\n}\n```\n\n2. Note the stream's `inboundAddress` from the response. It is the stream's id without its dashes, at `inbound.sendora.se`.\n3. [Register a webhook](sendora://docs/webhooks#register) that takes the `inbound` event. A webhook registered without `events` takes `inbound` too.\n4. Send a message to the address from any mail account.\n5. Your webhook receives the `inbound` event. You can also find the message by [searching received mail](#search-received-mail).\n\nSendora accepts a message up to the size on [Limits](sendora://docs/limits#inbound-mail).\n\nPast the account's [cap on received mail](sendora://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](sendora://docs/limits#sending-rates-and-the-monthly-cap) says.\n\nA 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.\n\n## Route with a plus address\n\nAnything 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.\n\n## Receive on your own domain\n\nA 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`.\n\nThe 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.\n\n1. Claim the domain for the stream, with the stream's id.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/domains \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { mx, txt } = await sendora.inboundDomains.create({\n streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',\n domain: 'post.example.se',\n});\n\nconsole.log(`${mx.type} ${mx.host} -> ${mx.value}`);\nconsole.log(`${txt.type} ${txt.host} -> ${txt.value}`);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\ndomain = sendora.inbound_domains.create(\n stream_id=\"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n domain=\"post.example.se\",\n)\n\nfor record in (domain.mx, domain.txt):\n print(record.type, record.host, \"->\", record.value)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$domain = $sendora->inboundDomains->create(\n streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',\n domain: 'post.example.se',\n);\n\nforeach ([$domain->mx, $domain->txt] as $record) {\n printf(\n \"%s %s -> %s\\n\",\n $record->type,\n $record->host,\n $record->value,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n2. 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.\n3. Wait for Sendora's check, which runs every few minutes until the domain is verified, or ask for one:\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/domains/{inboundDomainId}/verify \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst checked = await sendora.inboundDomains.verify(\n '6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60',\n);\nconst { verified, check } = checked;\n\nconsole.log(verified, check.mx, check.txt);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nchecked = sendora.inbound_domains.verify(\n \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\"\n)\n\nprint(\n checked.verified, checked.check.mx, checked.check.txt\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$checked = $sendora->inboundDomains->verify(\n '6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60',\n);\n\nprintf(\n \"%s %s %s\\n\",\n $checked->verified ? 'verified' : 'not verified',\n $checked->check->mx,\n $checked->check->txt,\n);\n```\n\nResponse:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": true,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": \"2026-09-19T09:10:00.000Z\",\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-19T09:10:00.000Z\"\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": true,\n \"verifiedAt\": \"2026-09-19T09:10:00.000Z\"\n },\n \"check\": {\n \"mx\": \"ok\",\n \"txt\": \"ok\"\n }\n}\n```\n\n`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.\n\n- A stream has one domain.\n- A domain of Sendora's own fails with `domain_reserved`.\n- The first account to verify a domain keeps it. A claim to a domain that another account has verified fails with `inbound_domain_exists`.\n- Removing the domain refuses mail to it at once, and frees the domain for another claim.\n\n### If the records disappear\n\nSendora 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:\n\n- The domain keeps receiving through two such checks.\n- 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.\n- After the grace period on [Limits](sendora://docs/limits#inbound-mail), mail to the domain is refused until a check finds both records again.\n\nA sending domain follows another rule, which [Domains](sendora://docs/domains#when-a-record-goes-missing) explains.\n\n## The inbound event\n\nA 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](sendora://docs/webhooks#register) decides what else comes:\n\n- `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.\n- `reference` adds nothing. Read the rest through the API.\n\nWith `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](sendora://reference/webhook-events#event-inbound) lists every field.\n\n## Search received mail\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/search \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"limit\": 20\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst page = await sendora.inbound.search({\n streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',\n limit: 20,\n});\n\nfor (const message of page.messages) {\n console.log(\n message.receivedAt,\n message.from?.address,\n message.subject,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.inbound.search(\n stream_id=\"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n limit=20,\n)\n\nfor message in page.messages:\n print(\n message.received_at,\n message.from_.address if message.from_ else None,\n message.subject,\n )\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->inbound->search(\n streamId: 'b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f',\n limit: 20,\n);\n\nforeach ($page->messages as $message) {\n printf(\n \"%s %s %s\\n\",\n $message->receivedAt->format('c'),\n $message->from?->address,\n $message->subject,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"messages\": [\n {\n \"inboundMessageId\": \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"receivedAt\": \"2026-09-19T08:00:02.000Z\",\n \"envelopeRecipient\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 412,\n \"attachmentCount\": 0,\n \"hasText\": true,\n \"hasHtml\": false,\n \"parseIssue\": null,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-19T08:00:03.000Z\"\n },\n \"contentAvailable\": true,\n \"contentExpiresAt\": \"2026-10-19T08:00:02.000Z\",\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"subject\": \"A question about my order\",\n \"date\": \"2026-09-19T08:00:00.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\nEvery 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`.\n\n## Read a message\n\ncURL:\n\n```bash\ncurl https://api.sendora.se/v1/inbound/4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31 \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst message = await sendora.inbound.get(\n '4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31',\n);\n\nconsole.log(message.from?.address, message.subject);\nconsole.log(message.authentication.dmarc);\nconsole.log(message.text);\nfor (const attachment of message.attachments) {\n console.log(attachment.name, attachment.size);\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nmessage = sendora.inbound.get(\n \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\"\n)\n\nprint(\n message.from_.address if message.from_ else None,\n message.subject,\n)\nprint(message.authentication.dmarc)\nprint(message.text)\nfor attachment in message.attachments:\n print(attachment.name, attachment.size)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$message = $sendora->inbound->get(\n '4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31',\n);\n\nprintf(\n \"%s %s\\n\",\n $message->from?->address,\n $message->subject,\n);\necho $message->authentication->dmarc, \"\\n\";\necho $message->text, \"\\n\";\nforeach ($message->attachments as $attachment) {\n echo $attachment->name, ' ', $attachment->size, \"\\n\";\n}\n```\n\nResponse:\n\n```json\n{\n \"inboundMessageId\": \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"receivedAt\": \"2026-09-19T08:00:02.000Z\",\n \"envelopeRecipient\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 412,\n \"attachmentCount\": 0,\n \"hasText\": true,\n \"hasHtml\": false,\n \"parseIssue\": null,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-19T08:00:03.000Z\"\n },\n \"contentAvailable\": true,\n \"contentExpiresAt\": \"2026-10-19T08:00:02.000Z\",\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"subject\": \"A question about my order\",\n \"date\": \"2026-09-19T08:00:00.000Z\",\n \"envelope\": {\n \"sender\": \"anna@example.com\"\n },\n \"replyTo\": [],\n \"to\": [\n {\n \"address\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\",\n \"name\": null\n }\n ],\n \"toCount\": 1,\n \"cc\": [],\n \"ccCount\": 0,\n \"messageIdHeader\": \"<question-1@example.com>\",\n \"inReplyTo\": null,\n \"references\": [],\n \"headers\": [\n {\n \"name\": \"From\",\n \"value\": \"Anna Andersson <anna@example.com>\"\n },\n {\n \"name\": \"To\",\n \"value\": \"b7d2e4f61a3c4d5e9f802c4e6a8b0d1f@inbound.sendora.se\"\n },\n {\n \"name\": \"Subject\",\n \"value\": \"A question about my order\"\n },\n {\n \"name\": \"Message-ID\",\n \"value\": \"<question-1@example.com>\"\n },\n {\n \"name\": \"Date\",\n \"value\": \"Fri, 19 Sep 2026 10:00:00 +0200\"\n }\n ],\n \"text\": \"Hi!\\n\",\n \"html\": null,\n \"attachments\": []\n}\n```\n\n- `to` and `cc` hold as many entries as [Limits](sendora://docs/limits#inbound-mail) allows. `toCount` and `ccCount` give the whole number.\n- `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.\n- `dmarcPolicy` is set only when DMARC failed. `checkedAt` says when the checks ran.\n- `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.\n- `contentExpiresAt` says when the content goes. After that, `contentAvailable` is false and the content fields are null.\n\n## Download the raw message and the attachments\n\n`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`.\n\nOver HTTP:\n\n```\ncurl https://api.sendora.se/v1/inbound/4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31/raw \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -o message.eml\n```\n\nWith the TypeScript SDK:\n\n`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.\n\nWith the Python SDK:\n\n`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.\n\nWith the PHP SDK:\n\n`$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.\n\n## What Sendora keeps\n\nSendora 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](sendora://reference/patch-v1-streams-id), within the range on [Limits](sendora://docs/limits#inbound-mail). Null means the default that Limits gives. After that, only the reference remains, for the time on [Limits](sendora://docs/limits#retention).\n\nTo erase a person from received mail, use an [erasure request](sendora://docs/suppressions#erasure-requests). It covers their sent and received mail alike.\n\nThe 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."},{"uri":"sendora://docs/webhooks","title":"Webhooks","description":"Events pushed to your endpoint, how to verify them, and retries and replays.","text":"# Webhooks\n\nEvents pushed to your endpoint, how to verify them, and retries and replays.\n\nA 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.\n\n## Rules for your endpoint\n\n- Verify the signature against the raw body before you parse it. The steps are below.\n- 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](sendora://docs/limits#webhooks).\n- Expect the same delivery more than once, and key your handling on its `id`.\n- 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`.\n\n## Register\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/webhooks \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"deferred\",\n \"spam_complaint\"\n ]\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { webhookId, secret } =\n await sendora.webhooks.create({\n url: 'https://example.se/hooks/sendora',\n events: [\n 'delivered',\n 'bounced',\n 'deferred',\n 'spam_complaint',\n ],\n });\n\nconsole.log(webhookId);\nconsole.log(secret); // shown this once: store it beside the token\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nwebhook = sendora.webhooks.create(\n url=\"https://example.se/hooks/sendora\",\n events=[\n \"delivered\",\n \"bounced\",\n \"deferred\",\n \"spam_complaint\",\n ],\n)\n\nprint(webhook.webhook_id)\n# Shown this once: store it beside the token.\nprint(webhook.secret)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$webhook = $sendora->webhooks->create(\n url: 'https://example.se/hooks/sendora',\n events: [\n 'delivered',\n 'bounced',\n 'deferred',\n 'spam_complaint',\n ],\n);\n\necho $webhook->webhookId, \"\\n\";\n// Shown this once: store it beside the token.\necho $webhook->secret, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"webhookId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"deferred\",\n \"spam_complaint\"\n ],\n \"streamId\": null,\n \"enabled\": true,\n \"inboundContent\": \"full\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"secrets\": [\n {\n \"secretId\": \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ],\n \"secret\": \"whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a\"\n}\n```\n\nThe response includes `secret` once: the webhook's first signing secret. Store it with your other secrets.\n\n- 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.\n- A server has one webhook per URL. Registering a URL again fails with `webhook_exists`, which includes the existing webhook's id.\n- Leave out `events` to receive every event except `opened` and `clicked`. Name those two to receive them, since one recipient can cause many of them.\n- 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.\n- With `streamId`, the webhook receives the message events of that one [stream](sendora://docs/streams). Cap events belong to no stream, so they reach the webhook either way.\n- `inboundContent` decides how much of a received message the `inbound` event carries, as [Inbound](sendora://docs/inbound#the-inbound-event) explains.\n\nYou 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.\n\n## Events\n\nEach 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](sendora://reference/webhook-events) lists every field of every event, with an example of each. Each event's name below links to its fields.\n\n| Event | Sent when |\n| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [`delivered`](sendora://reference/webhook-events#event-delivered) | The receiving server accepted the message for the recipient. |\n| [`bounced`](sendora://reference/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](sendora://docs/suppressions). |\n| [`deferred`](sendora://reference/webhook-events#event-deferred) | The receiving server asked Sendora to try later. Sendora keeps trying, so several may follow. |\n| [`spam_complaint`](sendora://reference/webhook-events#event-spam_complaint) | The recipient reported the message as spam. The recipient is suppressed. |\n| [`unsubscribed`](sendora://reference/webhook-events#event-unsubscribed) | The recipient unsubscribed from a [broadcast stream](sendora://docs/broadcasts) and is suppressed on it. The event comes a minute after the click, since the recipient can undo it for that long. |\n| [`opened`](sendora://reference/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. |\n| [`clicked`](sendora://reference/webhook-events#event-clicked) | A tracked link was followed, for a person or a machine. Only a webhook that names this event gets it. |\n| [`cap_warning`](sendora://reference/webhook-events#event-cap_warning) | The server or the account has used most of its monthly cap, at the share on [Limits](sendora://docs/limits#sending-rates-and-the-monthly-cap). |\n| [`cap_reached`](sendora://reference/webhook-events#event-cap_reached) | A send was refused because the monthly cap is used up. |\n| [`inbound`](sendora://reference/webhook-events#event-inbound) | A message arrived on an [inbound stream](sendora://docs/inbound). |\n| [`inbound_cap_warning`](sendora://reference/webhook-events#event-inbound_cap_warning) | The account has received most of its monthly cap on received mail, at the share on [Limits](sendora://docs/limits#the-cap-on-received-mail). |\n| [`inbound_cap_reached`](sendora://reference/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. |\n\nOnly Sendora support can lift a spam complaint or an unsubscribe, as [Suppressions](sendora://docs/suppressions#lift-a-suppression) explains. When you get `unsubscribed`, take the recipient off your own list too.\n\nA message event has `test` set to true when the message was on a [test server](sendora://docs/test-servers). Its events were simulated, and nothing was sent.\n\nTracking is not open to accounts yet, so `opened` and `clicked` are not sent until it is. Each one carries the labels on [Messages and events](sendora://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](sendora://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.\n\n## Verify the signature\n\nEvery request carries `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`. To verify it:\n\n1. Read `t` and every `v1` from the header. There is one `v1` for each live secret of the webhook, the newest first.\n2. Compute an HMAC-SHA256 of `<t>.<raw body>` in hex. Its key is your whole secret, `whsec_` included.\n3. Compare it with each `v1` in constant time. One match is enough.\n4. Refuse the request if `t` is more than five minutes from your clock, so a captured request cannot be replayed.\n\nOnly 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.\n\nThe sample shows the steps in shell, and the one call that does all four in each SDK:\n\nThe recipe, in shell:\n\n```bash\n# Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]\n# One v1 per live secret of the webhook, the newest first; each is\n# HMAC-SHA256 with that secret over \"<t>.<raw body>\".\nexpected=$(printf '%s.%s' \"$T\" \"$RAW_BODY\" | openssl dgst -sha256 -hmac \"$SENDORA_WEBHOOK_SECRET\" | sed 's/^.* //')\n\n# Compare in constant time in your language, refuse a t more than five minutes from your clock, then parse the body.\n# One matching v1 is enough; $V1S holds every v1 of the header, one per line.\nprintf '%s\\n' \"$V1S\" | grep -qx \"$expected\" && echo \"signed by Sendora\"\n```\n\nTypeScript:\n\n```ts\nimport { verifyWebhook } from '@sendora/sdk';\n\n/*\n * A route handler for any framework that hands you a Fetch Request:\n * Next.js, Hono, SvelteKit, Cloudflare Workers. With Express or Fastify,\n * keep the raw body and pass it and the header the same way.\n */\nexport async function POST(\n request: Request,\n): Promise<Response> {\n const event = await verifyWebhook({\n secret: process.env.SENDORA_WEBHOOK_SECRET,\n signature: request.headers.get('sendora-signature'),\n body: await request.text(),\n });\n\n if (event.event === 'bounced' && event.details.hard) {\n console.log(\n `${event.recipient} is undeliverable: ${event.details.classification ?? 'unknown'}`,\n );\n }\n\n return new Response(null, { status: 204 });\n}\n```\n\nPython:\n\n```python\nimport os\nfrom collections.abc import Mapping\n\nfrom sendora import (\n BouncedEvent,\n WebhookVerificationError,\n verify_webhook,\n)\n\n# A handler for any framework: give it the raw body\n# exactly as it arrived and the request's headers.\ndef receive(\n body: bytes, headers: Mapping[str, str]\n) -> int:\n # Header names ignore case, and a plain dict does not.\n lowered = {\n name.lower(): value\n for name, value in headers.items()\n }\n try:\n event = verify_webhook(\n body,\n signature=lowered.get(\"sendora-signature\"),\n secret=os.environ[\"SENDORA_WEBHOOK_SECRET\"],\n )\n except WebhookVerificationError:\n return 400\n\n if (\n isinstance(event, BouncedEvent)\n and event.details.hard\n ):\n print(\n f\"{event.recipient} is undeliverable: \"\n f\"{event.details.classification or 'unknown'}\"\n )\n\n return 204\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Events\\BouncedEvent;\nuse Sendora\\Exception\\WebhookVerificationException;\nuse Sendora\\WebhookVerifier;\n\n// A receiver in plain PHP. In Symfony or Laravel, pass\n// $request->getContent() and the header the same way.\n$header = $_SERVER['HTTP_SENDORA_SIGNATURE'] ?? null;\n\ntry {\n $event = WebhookVerifier::verify(\n (string) file_get_contents('php://input'),\n signature: is_string($header) ? $header : null,\n secret: (string) getenv('SENDORA_WEBHOOK_SECRET'),\n );\n} catch (WebhookVerificationException) {\n http_response_code(400);\n exit;\n}\n\nif (\n $event instanceof BouncedEvent\n && $event->details->hard\n) {\n error_log(sprintf(\n '%s is undeliverable: %s',\n $event->recipient,\n $event->details->classification ?? 'unknown',\n ));\n}\n\nhttp_response_code(204);\n```\n\nWith the TypeScript SDK:\n\n`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.\n\nWith the Python SDK:\n\n`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.\n\nThe 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](sendora://docs/limits#inbound-mail) shows.\n\nPass 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.\n\nWith the PHP SDK:\n\n`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.\n\nThe 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](sendora://docs/limits#inbound-mail) shows, so raise it for the webhook's route.\n\nIn 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.\n\n## Receive in Laravel\n\nThe 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.\n\n1. Register the webhook with the address of the package's route, such as `https://example.se/sendora/webhook`.\n2. 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.\n3. Listen for the events you want, with a listener in `app/Listeners` or with `Event::listen()`.\n\nThe 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](sendora://docs/limits#webhooks) says.\n\n| Event | Laravel event in `Sendora\\Laravel\\Events` |\n| --------------------- | ----------------------------------------- |\n| `delivered` | `MessageDelivered` |\n| `bounced` | `MessageBounced` |\n| `deferred` | `MessageDeferred` |\n| `spam_complaint` | `SpamComplaintReceived` |\n| `unsubscribed` | `RecipientUnsubscribed` |\n| `opened` | `MessageOpened` |\n| `clicked` | `LinkClicked` |\n| `cap_warning` | `CapWarning` |\n| `cap_reached` | `CapReached` |\n| `inbound` | `InboundEmailReceived` |\n| `inbound_cap_warning` | `InboundCapWarning` |\n| `inbound_cap_reached` | `InboundCapReached` |\n| any other | `UnknownWebhookReceived` |\n\nEach 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.\n\n## Roll the secret\n\nA webhook can have two live secrets at once, so you can replace one without a gap:\n\n1. Create a new secret. From then on, every delivery is signed with both.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/webhooks/{webhookId}/secrets \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst rolled = await sendora.webhooks.createSecret(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n\nconsole.log(rolled.secretId, rolled.secret);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nrolled = sendora.webhooks.create_secret(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\"\n)\n\nprint(rolled.secret_id, rolled.secret)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$rolled = $sendora->webhooks->createSecret(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n\necho $rolled->secretId, ' ', $rolled->secret, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"secretId\": \"5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"secret\": \"whsec_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f\"\n}\n```\n\n2. Move your receiver to the new secret. Deliveries verify with either one meanwhile.\n3. Delete the old secret by its id, which the webhook's `secrets` lists.\n\ncURL:\n\n```bash\ncurl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId}/secrets/{secretId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nawait sendora.webhooks.deleteSecret(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n '4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d',\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nsendora.webhooks.delete_secret(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$sendora->webhooks->deleteSecret(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n '4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d',\n);\n```\n\nA 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.\n\n## Retries, dead letters and replay\n\nSendora retries a failed attempt with growing delays, for about a day, on the schedule on [Limits](sendora://docs/limits#webhooks). After the last retry, the delivery is `dead`. It stays listed until you replay it.\n\ncURL:\n\n```bash\ncurl 'https://api.sendora.se/v1/webhooks/{webhookId}/deliveries?status=dead&limit=50' \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { deliveries } = await sendora.webhooks.deliveries(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n {\n status: 'dead',\n limit: 50,\n },\n);\n\nfor (const delivery of deliveries) {\n console.log(\n delivery.deliveryId,\n delivery.event,\n delivery.attempts,\n delivery.lastError,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.webhooks.deliveries(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n status=\"dead\",\n limit=50,\n)\n\nfor delivery in page.deliveries:\n print(\n delivery.delivery_id,\n delivery.event,\n delivery.attempts,\n delivery.last_error,\n )\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->webhooks->deliveries(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n status: 'dead',\n limit: 50,\n);\n\nforeach ($page->deliveries as $delivery) {\n printf(\n \"%s %s %d %s\\n\",\n $delivery->deliveryId,\n $delivery->event,\n $delivery->attempts,\n $delivery->lastError,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"deliveries\": [\n {\n \"deliveryId\": \"9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c60\",\n \"event\": \"bounced\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"inboundMessageId\": null,\n \"status\": \"dead\",\n \"attempts\": 9,\n \"nextAttemptAt\": null,\n \"lastStatusCode\": 503,\n \"lastError\": \"http_503\",\n \"deliveredAt\": null,\n \"createdAt\": \"2026-09-14T12:00:02.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\nA replay queues a delivery again, whatever its state, with the same `id` and event. Its `attempt` keeps counting. The [reference](sendora://reference/post-v1-webhooks-id-deliveries-deliveryid-replay) has the call.\n\n## Remove\n\ncURL:\n\n```bash\ncurl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId} \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nawait sendora.webhooks.delete(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nsendora.webhooks.delete(\n \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\"\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$sendora->webhooks->delete(\n '7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70',\n);\n```\n\nDeliveries still pending for the webhook are dropped with it."},{"uri":"sendora://docs/suppressions","title":"Suppressions","description":"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.","text":"# Suppressions\n\nAddresses a stream no longer sends to, why they are there, and how to lift one, and the list of addresses a server never tracks.\n\nEach [stream](sendora://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](sendora://docs/broadcasts) instead drops suppressed addresses and sends to the rest.\n\nThe suppression calls take `streamId`, and without it they mean the server's default transactional stream.\n\n## How an address gets there\n\n| Reason | Added when | Who can lift it |\n| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- |\n| `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 |\n| `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 |\n| `unsubscribe` | The recipient used the unsubscribe link of a message on a [broadcast stream](sendora://docs/broadcasts), or their mail client's one-click button. The `unsubscribed` [webhook](sendora://docs/webhooks) tells you. | Sendora support only |\n| `manual` | You added the address in the dashboard. The API cannot add one. | You |\n\nAfter 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.\n\n## Read the list\n\ncURL:\n\n```bash\ncurl 'https://api.sendora.se/v1/suppressions?limit=100' \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { suppressions } = await sendora.suppressions.list({\n limit: 100,\n});\n\nfor (const entry of suppressions) {\n console.log(\n entry.address,\n entry.reason,\n entry.createdAt,\n );\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.suppressions.list(limit=100)\n\nfor entry in page.suppressions:\n print(entry.address, entry.reason, entry.created_at)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->suppressions->list(limit: 100);\n\nforeach ($page->suppressions as $entry) {\n printf(\n \"%s %s %s\\n\",\n $entry->address,\n $entry->reason,\n $entry->createdAt->format('c'),\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"suppressions\": [\n {\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"address\": \"bounced@example.com\",\n \"reason\": \"hard_bounce\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"createdAt\": \"2026-09-14T12:00:05.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\nThe list is newest first, in the page sizes on [Limits](sendora://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`.\n\nEach 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.\n\n## Lift a suppression\n\nLift 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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/suppressions/delete \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"address\": \"bounced@example.com\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nawait sendora.suppressions.delete({\n address: 'bounced@example.com',\n});\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nsendora.suppressions.delete(address=\"bounced@example.com\")\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$sendora->suppressions->delete(\n address: 'bounced@example.com',\n);\n```\n\nLifting 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.\n\n## The not-tracked list\n\nEach 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.\n\nTracking is not open to accounts yet, so the list changes nothing until it is. You can still fill it now, so that an address is listed before your first tracked mail.\n\n| Source | Added when | Who can remove it |\n| ----------- | -------------------------------------- | ---------------------- |\n| `manual` | You added the address. | You |\n| `recipient` | The recipient asked not to be tracked. | You, after new consent |\n\nAdd 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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/tracking-opt-outs \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"address\": \"anna@example.com\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst entry = await sendora.trackingOptOuts.add({\n address: 'anna@example.com',\n});\n\nconsole.log(entry.source, entry.createdAt);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nentry = sendora.tracking_opt_outs.add(\n address=\"anna@example.com\",\n)\n\nprint(entry.source, entry.created_at)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$entry = $sendora->trackingOptOuts->add(\n address: 'anna@example.com',\n);\n\nprintf(\n \"%s %s\\n\",\n $entry->source,\n $entry->createdAt->format('c'),\n);\n```\n\nResponse:\n\n```json\n{\n \"address\": \"anna@example.com\",\n \"source\": \"manual\",\n \"messageId\": null,\n \"createdAt\": \"2026-11-06T12:00:00.000Z\"\n}\n```\n\nRead the list, newest first, in the page sizes on [Limits](sendora://docs/limits#page-sizes). Pass `next` as `after` for the next page.\n\ncURL:\n\n```bash\ncurl 'https://api.sendora.se/v1/tracking-opt-outs?limit=100' \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\"\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nconst { trackingOptOuts } =\n await sendora.trackingOptOuts.list({ limit: 100 });\n\nfor (const entry of trackingOptOuts) {\n console.log(entry.address, entry.source);\n}\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\npage = sendora.tracking_opt_outs.list(limit=100)\n\nfor entry in page.tracking_opt_outs:\n print(entry.address, entry.source)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$page = $sendora->trackingOptOuts->list(limit: 100);\n\nforeach ($page->trackingOptOuts as $entry) {\n printf(\n \"%s %s\\n\",\n $entry->address ?? 'erased',\n $entry->source,\n );\n}\n```\n\nResponse:\n\n```json\n{\n \"trackingOptOuts\": [\n {\n \"address\": \"anna@example.com\",\n \"source\": \"manual\",\n \"messageId\": null,\n \"createdAt\": \"2026-11-06T12:00:00.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\nRemove 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`.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/tracking-opt-outs/delete \\\n -H \"Authorization: Bearer $SENDORA_API_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"address\": \"anna@example.com\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({\n token: process.env.SENDORA_API_TOKEN,\n});\n\nawait sendora.trackingOptOuts.delete({\n address: 'anna@example.com',\n});\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import Sendora\n\nsendora = Sendora(os.environ[\"SENDORA_API_TOKEN\"])\n\nsendora.tracking_opt_outs.delete(\n address=\"anna@example.com\",\n)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\Sendora;\n\n// The key comes from SENDORA_API_TOKEN.\n$sendora = new Sendora();\n\n$sendora->trackingOptOuts->delete(\n address: 'anna@example.com',\n);\n```\n\nEach server's **Not tracked** tab, in [Servers](https://app.sendora.se/servers), shows the list. There you add, find and remove addresses too.\n\n## Erasure requests\n\nWhen 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.\n\ncURL:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/erasures \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"address\": \"anna@example.com\"\n}'\n```\n\nTypeScript:\n\n```ts\nimport { SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({\n token: process.env.SENDORA_ACCOUNT_TOKEN,\n});\n\nconst erasure = await account.erasures.create({\n address: 'anna@example.com',\n});\n\nconsole.log(erasure.erasureId, erasure.status);\n```\n\nPython:\n\n```python\nimport os\n\nfrom sendora import SendoraAccount\n\naccount = SendoraAccount(\n os.environ[\"SENDORA_ACCOUNT_TOKEN\"]\n)\n\nerasure = account.erasures.create(\n address=\"anna@example.com\"\n)\nprint(erasure.erasure_id, erasure.status)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Sendora\\SendoraAccount;\n\n// The key comes from SENDORA_ACCOUNT_TOKEN.\n$account = new SendoraAccount();\n\n$erasure = $account->erasures->create(\n address: 'anna@example.com',\n);\n\necho $erasure->erasureId, ' ', $erasure->status, \"\\n\";\n```\n\nResponse:\n\n```json\n{\n \"erasureId\": \"6f1d2c3b-4a5e-4f60-8b7a-9c8d7e6f5a41\",\n \"status\": \"pending\",\n \"createdAt\": \"2026-10-04T12:00:00.000Z\",\n \"completedAt\": null,\n \"pseudonym\": null,\n \"messages\": null,\n \"receivedMessages\": null\n}\n```\n\nThe erasure returns `pending`, and Sendora carries it out within a minute. [Read the erasure](sendora://reference/get-v1-erasures-id) to see it `done`, with the pseudonym and how many messages lost their content.\n\nOn 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.\n\nOn 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 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."},{"uri":"sendora://docs/smtp","title":"SMTP submission","description":"Send from a system that only speaks SMTP, with the same key and rules.","text":"# SMTP submission\n\nSend from a system that only speaks SMTP, with the same key and rules.\n\nUse 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.\n\nTo move a system that signs in to a Microsoft 365 mailbox with a password, follow [Move from Microsoft 365](sendora://docs/microsoft-365).\n\n## Connect\n\n| Setting | Value |\n| ---------------------- | ---------------------------------------------------------------------------------- |\n| Host | `smtp.sendora.se` |\n| Port | 465 with TLS from the start, or 587 with STARTTLS |\n| User name and password | A server key, `sk_…` or a [test server](sendora://docs/test-servers)'s `sk_test_…`, as both |\n| Authentication | `PLAIN` or `LOGIN`, once TLS is on |\n\nThe `From` address must be on one of the server's verified domains. The [message limits](sendora://docs/limits#messages) apply as they do over the API, with `To`, `Cc` and `Bcc` counted together.\n\nTypeScript:\n\n```ts\nimport nodemailer from 'nodemailer';\n\n/** Sends one message over SMTP with a server's API token as both user name and password. */\nexport async function sendOverSmtp(\n token: string,\n): Promise<string> {\n const transport = nodemailer.createTransport({\n host: 'smtp.sendora.se',\n port: 587,\n secure: false,\n requireTLS: true,\n auth: { user: token, pass: token },\n });\n const info = await transport.sendMail({\n from: '\"Example AB\" <no-reply@example.se>',\n to: 'anna@example.com',\n subject: 'Your account is ready',\n text: 'Hi Anna, your account at Example is ready.',\n headers: {\n 'X-Sendora-Tag': 'welcome',\n 'X-Sendora-Metadata-Customer': '4711',\n },\n });\n return info.response; // \"250 2.0.0 OK: queued as <messageId>\"\n}\n```\n\nPython:\n\n```python\nimport smtplib\nimport ssl\nfrom email.message import EmailMessage\n\n# Sends one message over SMTP with a server's API\n# token as both user name and password.\ndef send_over_smtp(token: str) -> None:\n message = EmailMessage()\n message[\"From\"] = \"Example AB <no-reply@example.se>\"\n message[\"To\"] = \"anna@example.com\"\n message[\"Subject\"] = \"Your account is ready\"\n message[\"X-Sendora-Tag\"] = \"welcome\"\n message[\"X-Sendora-Metadata-Customer\"] = \"4711\"\n message.set_content(\n \"Hi Anna, your account at Example is ready.\"\n )\n\n # Without a context of its own, starttls() would\n # accept any certificate, and without a timeout\n # smtplib waits forever on a stalled connection.\n context = ssl.create_default_context()\n with smtplib.SMTP(\n \"smtp.sendora.se\", 587, timeout=30\n ) as smtp:\n smtp.starttls(context=context)\n smtp.login(token, token)\n smtp.send_message(message)\n```\n\nPHP:\n\n```php\n<?php\n\nuse Symfony\\Component\\Mailer\\Header\\MetadataHeader;\nuse Symfony\\Component\\Mailer\\Header\\TagHeader;\nuse Symfony\\Component\\Mailer\\MailerInterface;\nuse Symfony\\Component\\Mailer\\Transport;\nuse Symfony\\Component\\Mailer\\Transport\\TransportInterface;\nuse Symfony\\Component\\Mime\\Email;\n\n// Sendora's SMTP submission, with a server's API token\n// as both user name and password. smtps:// starts TLS\n// before the token goes over.\nfunction sendoraSmtp(string $token): TransportInterface\n{\n return Transport::fromDsn(\n \"smtps://{$token}:{$token}@smtp.sendora.se:465\",\n );\n}\n\n// Give it new Mailer(sendoraSmtp($token)), or the\n// MailerInterface that Symfony's framework injects.\nfunction sendWelcome(MailerInterface $mailer): void\n{\n $email = (new Email())\n ->from('Example AB <no-reply@example.se>')\n ->to('anna@example.com')\n ->subject('Your account is ready')\n ->text(\n 'Hi Anna, your account at Example is ready.',\n );\n $email->getHeaders()\n ->add(new TagHeader('welcome'))\n ->add(new MetadataHeader('Customer', '4711'));\n\n $mailer->send($email);\n}\n```\n\nSendora 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.\n\n## Send from Django\n\nDjango 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.\n\n```python\nimport os\n\n# In settings.py, for Django's SMTP backend, its\n# default: the server key as user name and password.\nEMAIL_HOST = \"smtp.sendora.se\"\nEMAIL_PORT = 587\nEMAIL_USE_TLS = True\nEMAIL_HOST_USER = os.environ[\"SENDORA_API_TOKEN\"]\nEMAIL_HOST_PASSWORD = os.environ[\"SENDORA_API_TOKEN\"]\nEMAIL_TIMEOUT = 30\nDEFAULT_FROM_EMAIL = \"Example AB <no-reply@example.se>\"\nSERVER_EMAIL = DEFAULT_FROM_EMAIL\n```\n\nDjango 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.\n\n## Send from Laravel or Symfony\n\nLaravel 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.\n\nIn Laravel, set the `smtp` mailer in `.env`:\n\n```bash\nMAIL_MAILER=smtp\nMAIL_SCHEME=smtps\nMAIL_HOST=smtp.sendora.se\nMAIL_PORT=465\nMAIL_USERNAME=\"${SENDORA_API_TOKEN}\"\nMAIL_PASSWORD=\"${SENDORA_API_TOKEN}\"\nMAIL_FROM_ADDRESS=no-reply@example.se\n```\n\nA 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.\n\nTo send through the API instead, with each message's id in hand, use the Laravel package, as [Send from Laravel](sendora://docs/sending#send-from-laravel) shows.\n\nIn Symfony, set `MAILER_DSN` in `.env`:\n\n```bash\nMAILER_DSN=smtps://${SENDORA_API_TOKEN}:${SENDORA_API_TOKEN}@smtp.sendora.se:465\n```\n\nAdd the tag with Symfony's `TagHeader` and each metadata entry with its `MetadataHeader`.\n\n## Set the stream, tag and metadata\n\n| Header | Sets |\n| -------------------------- | ------------------------------------------------------------------------------------- |\n| `X-Sendora-Stream` | The [stream](sendora://docs/streams) the message goes on, by its id |\n| `X-Sendora-Tag` | The message's tag |\n| `X-Sendora-Metadata-<key>` | One metadata entry: `X-Sendora-Metadata-Customer: 4711` sets `{ \"customer\": \"4711\" }` |\n\nWithout `X-Sendora-Stream`, the message goes on the server's default transactional stream.\n\nSendora also reads `X-Tag` and `X-Metadata-<key>`, which Laravel's and Symfony's mailers write for a message's tags and metadata. `X-Sendora-Tag` and `X-Sendora-Metadata-<key>` 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.\n\nAttachments 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 tag and metadata headers, 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.\n\n## SMTP replies\n\nSendora 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.\n\n| Reply | When |\n| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `250 2.0.0 OK: queued as <messageId>` | Accepted. The id is the one the log and the webhooks use. |\n| `530 5.7.0` | The client sent a message without authenticating. |\n| `535 5.7.8` | The key is wrong, revoked or an [account key](sendora://docs/keys), or the user name and password differ. |\n| `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](sendora://docs/errors#when-an-account-cannot-send) describes. It also covers a stream in `X-Sendora-Stream` that is unknown, archived, paused or inbound. |\n| `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. |\n| `550 5.6.0` | The API would refuse the message as invalid, or it breaks the [rules of a broadcast stream](sendora://docs/broadcasts#rules-of-a-broadcast-stream). The text gives the reasons. |\n| `552 5.3.4` | The message is over the [size limit](sendora://docs/limits#messages). |\n| `452 4.5.3` | The message has more recipients than [a message may have](sendora://docs/limits#messages). Split them over several messages. |\n| `451 4.7.0` | The per-minute limit or the monthly cap is reached. The text says when to try again. |\n| `451 4.3.2` | Sending is disabled for everyone for the moment. |\n| `451 4.3.0` | A temporary failure on Sendora's side. |"},{"uri":"sendora://docs/errors","title":"Errors","description":"The error body, every error code, and what stops an account from sending.","text":"# Errors\n\nThe error body, every error code, and what stops an account from sending.\n\nEvery 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:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [{ \"path\": \"to.0\", \"message\": \"Invalid email address\" }]\n}\n```\n\nSome errors add fields, such as the suppressed recipients or when a limit resets. Each operation's page in the [reference](sendora://index) lists them.\n\nTwo 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](sendora://docs/limits#requests-per-second) explains. A body over the size limit meets the proxy first, so it gets the plain-text 413.\n\n## Retry or fix\n\n- `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.\n- `sending_disabled` (503): sending is disabled for everyone for the moment. Retry after the seconds in the `Retry-After` header.\n- `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](sendora://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.\n- A 5xx status: retry with a growing delay, under the same idempotency key so the message cannot go twice.\n- 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.\n\nOn `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](sendora://docs/webhooks) can warn you before the cap is used up.\n\nWith the TypeScript SDK:\n\nThe 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.\n\nThe 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.\n\nWith the Python SDK:\n\nThe 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.\n\nThe 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.\n\nWith the PHP SDK:\n\nThe 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.\n\nThe 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.\n\n## When an account cannot send\n\n- `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](sendora://docs/test-servers) works meanwhile.\n- `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](sendora://docs/test-servers) works before that.\n- `account_paused` (403): Sendora has paused the account. Support tells you why.\n\nOver SMTP, the same refusals arrive as SMTP replies, which [SMTP submission](sendora://docs/smtp#smtp-replies) lists.\n\n## Error codes\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled already; nothing is left to cancel. |\n| `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |\n| `default_stream` | 409 | The default stream of a server cannot be archived. |\n| `domain_exists` | 409 | The account already has that domain. |\n| `domain_reserved` | 400 | The name is Sendora’s own, or a return-path host; no account can receive on it. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |\n| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |\n| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |\n| `inbound_domain_exists` | 409 | The stream already has a domain, or another account holds the name verified. |\n| `inbound_stream_exists` | 409 | A server has one inbound stream at a time; archive it to make another. |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted; create another first. |\n| `last_token` | 409 | This is the only live key of the server; create another before revoking it. |\n| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |\n| `mode_immutable` | 400 | A server’s mode is fixed when it is created; create a new server for the other mode. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `not_found` | 404 | No such broadcast of this server. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `rate_limited` | 429 | More was sent within a minute than the limit allows. |\n| `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. |\n| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `secret_limit` | 409 | The webhook already holds two live secrets: one in use and one to roll to. Delete one before creating another. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; try again once they have left. |\n| `server_limit` | 409 | The account has as many servers as it may; Sendora raises the cap on request. |\n| `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. |\n| `spam_complaint_locked` | 403 | The recipient reported a message as spam; only Sendora support lifts that, at the recipient’s own request. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_exists` | 409 | The server already has a stream with this name, archived or not. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; the streamId names a transactional one. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |\n| `substitution_missing` | 422 | The subject, text or HTML names a {{ key }} that a message does not give. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `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. |\n| `tracking_domain_exists` | 409 | The account already has that host, or a host on that sending domain. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may have. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. A tracking domain is a subdomain of one. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of this account. |\n| `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `unsubscribe_locked` | 403 | The recipient unsubscribed themselves; only Sendora support lifts that, at the recipient’s own request. |\n| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL. |\n| `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. |"},{"uri":"sendora://docs/limits","title":"Limits","description":"Every limit an account meets, and its number.","text":"# Limits\n\nEvery limit an account meets, and its number.\n\nEvery limit an account meets, with its number. When a send meets one, [Errors](sendora://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.\n\n## Sending rates and the monthly cap\n\nEmails are counted one per recipient, across `to`, `cc` and `bcc`.\n\nAn 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.\n\n| Limit | Value |\n| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |\n| Per minute, per account | 300 by default |\n| 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 |\n| Highest monthly cap | 10 times the cap approval set, unless Sendora sets another |\n| Warning | A `cap_warning` webhook at 80 percent of the cap, once a month |\n| After a cancellation | The 10,000 emails that the monthly fee includes, sent and received together |\n\n- 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.\n- 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.\n- 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](sendora://docs/limits#the-cap-on-received-mail). No `inbound_cap_reached` webhook goes.\n- An account approved without an application starts at the monthly cap of the smallest volume.\n- Sendora can give a server a per-minute limit and a monthly cap of its own, only ever tighter than the account's.\n- A broadcast counts in full against the monthly cap as soon as Sendora accepts it. The per-minute limit does not apply to broadcasts.\n\n## Test servers\n\n| Limit | Value |\n| ------------------------------------------ | ----------------- |\n| Per minute, all the account's test servers | 300 by default |\n| Per month, all the account's test servers | 10,000 by default |\n\nTest 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`.\n\n## Account\n\n| Object | Limit |\n| ------------------------------- | ------------------------------------------ |\n| Servers | 10 by default, more on request |\n| Keys | 2 live per server, and 2 live account keys |\n| Webhook secrets | 2 live per webhook |\n| Inbound streams | One live per server |\n| Tracking domains | 10 per account, one per sending domain |\n| A tracking domain's first check | Its records must be right within 7 days |\n| Names of servers, streams, keys | Up to 100 characters |\n\nSending domains, webhooks and transactional and broadcast streams have no limit of their own.\n\n## Requests\n\n| Limit | Value |\n| ----------------- | ------------------------------------------------------- |\n| Request body | 10 MB, and 64 MB for `POST /v1/broadcasts` |\n| `Idempotency-Key` | Up to 255 printable characters, remembered for 24 hours |\n| Batch | Up to 100 messages |\n\n## Messages\n\n| Limit | Value |\n| --------------------- | --------------------------------------------------------------- |\n| Recipients | 50, across `to`, `cc` and `bcc` |\n| Size | 10 MB as built, with attachments counted after base64 encoding |\n| Subject | 998 characters |\n| Attachments | 20 |\n| Custom headers | 20 |\n| Metadata | 20 entries, keys of up to 40 characters, values of up to 500 |\n| Tag | Up to 100 characters |\n| Opens and clicks kept | 100 of each per recipient; `opens` and `clicks` go on counting |\n| Opens and clicks read | 500 per message; each recipient's first and earliest come first |\n\n## Broadcasts\n\n| Limit | Value |\n| ------------- | ------------------------------------------------------------------------------------------------------------------------ |\n| Messages | 50,000 per broadcast |\n| Size | 50 MB for the whole broadcast as built, and 10 MB for each message in it |\n| Substitutions | 20 per message, each up to 500 characters |\n| Pace | 600 messages a minute per broadcast stream, unless Sendora sets another |\n| 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 |\n\n## SMTP submission\n\n| Limit | Value |\n| ----------- | ---------------------------------------- |\n| Connections | 200 at once on each listener |\n| Idle | A connection is closed after 60 seconds |\n| Message | 10 MB and 50 recipients, as over the API |\n\n## Inbound mail\n\n| Limit | Value |\n| ------------ | ------------------------------------------------------------------------------------------- |\n| Size | 35 MB per message |\n| Rate | 300 recipients a minute per account, unless Sendora sets another |\n| Recipients | 50 per delivery from a sending server |\n| Connections | 300 a minute from one sending server |\n| Content kept | The stream's `contentRetentionDays`, 1 to 30 days, 30 by default |\n| Addresses | `to` and `cc` hold up to 100 entries each, with the whole number in `toCount` and `ccCount` |\n| Domain grace | Mail to a verified domain whose records went missing is deferred for 72 hours, then refused |\n\nIf 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:\n\n| Parser limit | Value |\n| --------------- | ------------------------------------------- |\n| Header block | 256 KB and 1,000 lines |\n| Parts | 500 MIME parts, nested up to 50 deep |\n| Text and HTML | 1 MB of text and 2 MB of HTML once decoded |\n| Decoded content | 4 times the size of the message as received |\n| Time | 20 seconds to parse |\n| Attachments | The first 100 are kept |\n| Header values | The first 2,000 characters are kept |\n| Address lists | The first 1,000 addresses are kept |\n\nIf 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.\n\n## The cap on received mail\n\n| Limit | Value |\n| ----------- | ---------------------------------------------------------------------------------------------------- |\n| Monthly cap | The account's monthly cap on sending, until an administrator moves it, up to the highest monthly cap |\n| Warning | An `inbound_cap_warning` webhook at 80 percent of the cap, once a month |\n\n- Received mail counts toward this cap alone. It never counts toward the monthly cap on sending, and sent mail never counts toward this one.\n- The cap counts the same month as sending does.\n- An administrator moves the cap under [Account › Usage](https://app.sendora.se/account/usage). Until then, it follows the monthly cap on sending.\n- 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.\n\n## Retention\n\n| What | Kept for |\n| -------------------------------------------------------------------------- | ------------------------------------------------------------- |\n| A message's text and HTML | 30 days |\n| Its attachments' content | Until Sendora's mail server has the message, or it has failed |\n| The message, its recipients and events, and a received message's reference | 13 months |\n| On a test server | 7 days for the text and HTML, 30 days for the rest |\n\n## Webhooks\n\n| Limit | Value |\n| ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |\n| Timeout | 10 seconds for an answer; a redirect is not followed and counts as a failure |\n| 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 |\n\n## Page sizes\n\n| Lists | Page size |\n| -------------------------------------------------- | --------------------------- |\n| Messages, received messages, broadcasts | Up to 100, 50 by default |\n| Suppressions, not-tracked list, webhook deliveries | Up to 1,000, 100 by default |\n\n## Sign-in\n\n| Limit | Per 15 minutes |\n| ------------- | ----------------------------- |\n| Sign-in tries | 10 per address, 30 per client |\n| Mailed links | 3 per address, 10 per client |\n| Invitations | 3 per address, 20 per client |\n| SSO sign-ins | 30 per client |\n\n## SSO and SCIM\n\n| Limit | Value |\n| --------------- | ----------------------------- |\n| SSO connections | 5 per account |\n| SSO domains | 20 per account |\n| Sign-in tests | 20 per account per 15 minutes |\n| Links to IT | 5 per account per 15 minutes |\n| SCIM tokens | 2 live per account |\n| SCIM requests | 1,200 per token in 1 minute |\n| SCIM lists | Up to 100 users a page |\n\n## Requests per second\n\nBefore 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`."},{"uri":"sendora://docs/mcp","title":"MCP server","description":"Connect claude.ai, ChatGPT, Claude Code, Cursor, VS Code, Codex or Claude Desktop to Sendora's docs and tools.","text":"# MCP server\n\nConnect claude.ai, ChatGPT, Claude Code, Cursor, VS Code, Codex or Claude Desktop to Sendora's docs and tools.\n\nSendora'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.\n\nIt runs two ways, with the same tools:\n\n- Locally, as `npx -y @sendora/mcp`, over stdio. The documentation is built into the package, so it answers offline.\n- 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.\n\n## Before you start\n\n- The local server needs Node 20.19 or newer. The hosted server and the Claude Desktop bundle need nothing installed.\n- 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.\n- 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.\n\n## Connect with OAuth\n\nWith 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.\n\nUse 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).\n\nOpen your app's steps:\n\n### claude.ai and Claude Desktop\n\nOn 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.\n\n1. Open **Customize › Connectors** and select **Add custom connector**.\n2. Name it Sendora and enter `https://mcp.sendora.se/mcp` as the server URL. Leave the other settings as they are, and select **Add**.\n3. Select **Connect** beside Sendora. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n4. In a chat, select **+**, then **Connectors**, and turn Sendora on.\n\nThe connector also works in Claude's mobile apps.\n\n### ChatGPT\n\n1. In ChatGPT on the web, open **Plugins**, select **Add**, then **Add custom MCP server**.\n2. Name it Sendora. Under **Connection**, enter `https://mcp.sendora.se/mcp`.\n3. Choose OAuth as the authentication, with no client ID or secret. Select **I understand and want to continue**, then **Create as a plugin**.\n4. When ChatGPT asks, select **Continue to Sendora**. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n5. In a chat, type `@` and choose Sendora.\n\n### Claude Code\n\n1. 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.\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.sendora.se/mcp\",\n \"timeout\": 90000\n }\n }\n}\n```\n\n2. In Claude Code, run `/mcp`, select sendora and authenticate. Claude Code opens your browser.\n3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n\n### VS Code\n\n1. Add Sendora to `.vscode/mcp.json` in your project, or to the file that **MCP: Open User Configuration** opens:\n\n```json\n{\n \"servers\": {\n \"sendora\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.sendora.se/mcp\"\n }\n }\n}\n```\n\n2. 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.\n3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n\n### Cursor\n\n1. Add Sendora to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in your project:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"url\": \"https://mcp.sendora.se/mcp\"\n }\n }\n}\n```\n\n2. Open **Customize** in Cursor's sidebar, and connect Sendora's MCP server there. Cursor opens your browser.\n3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n\n### Codex\n\n1. Add Sendora to `~/.codex/config.toml`. The timeout lets `send_email` wait its full minute.\n\n```toml\n[mcp_servers.sendora]\nurl = \"https://mcp.sendora.se/mcp\"\ntool_timeout_sec = 90\n```\n\n2. Run `codex mcp login sendora`. Codex opens your browser.\n3. Sign in to Sendora and approve the app, as [Approve the app](#approve-the-app) says.\n\n### Approve the app\n\nSendora'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.\n\nNothing is ticked when the page opens. Tick at least one server or the account:\n\n- The page lists each server you may open yourself, marked live or test. Tick those the app may reach.\n- 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.\n- 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.\n\nThen approve, and your browser returns to the app. If you cancel instead, the app is told that you refused.\n\nOn 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.\n\nThe 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.\n\n### End a connection\n\nYour 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.\n\nA 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.\n\nAn 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.\n\n### If the app cannot connect\n\n| What you see | Why | What to do |\n| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |\n| 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 |\n| 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 |\n\n## Choose a key\n\nThe MCP server lists only the tools your key allows, so the agent cannot call any other:\n\n- 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](sendora://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 and complaints.\n- 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.\n- 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.\n- With no key, the agent reads the documentation alone.\n\n| What the tool does | No key | Test key | Live key | Account key |\n| --- | --- | --- | --- | --- |\n| Reads the documentation | yes | yes | yes | yes |\n| Reads | no | yes | yes | yes |\n| Changes | no | yes | with changes on | with changes on |\n| Shows a new key or signing secret | no | yes | no | no |\n| Cannot be undone | no | yes | no | with changes on |\n\n**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](sendora://docs/keys) says where each key is made.\n\n## Install\n\nSet 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.\n\nOpen your client's setup:\n\n### Claude Code\n\nAdd 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:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@sendora/mcp\"],\n \"env\": { \"SENDORA_API_TOKEN\": \"${SENDORA_API_TOKEN}\" }\n }\n }\n}\n```\n\nOr hosted, with a timeout that lets `send_email` wait up to a minute to learn whether the message was delivered:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.sendora.se/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${SENDORA_API_TOKEN}\" },\n \"timeout\": 90000\n }\n }\n}\n```\n\nDon'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:\n\n```bash\nclaude mcp add --transport http sendora-docs https://mcp.sendora.se/docs\n```\n\n### Cursor\n\n[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`.\n\nBy 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:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@sendora/mcp\"],\n \"env\": { \"SENDORA_API_TOKEN\": \"${env:SENDORA_API_TOKEN}\" }\n }\n }\n}\n```\n\nOr hosted:\n\n```json\n{\n \"mcpServers\": {\n \"sendora\": {\n \"url\": \"https://mcp.sendora.se/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${env:SENDORA_API_TOKEN}\" }\n }\n }\n}\n```\n\n### VS Code\n\n[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.\n\nBy 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:\n\n```json\n{\n \"inputs\": [\n {\n \"type\": \"promptString\",\n \"id\": \"sendora-key\",\n \"description\": \"Sendora key\",\n \"password\": true\n }\n ],\n \"servers\": {\n \"sendora\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@sendora/mcp\"],\n \"env\": { \"SENDORA_API_TOKEN\": \"${input:sendora-key}\" }\n }\n }\n}\n```\n\nOr hosted:\n\n```json\n{\n \"inputs\": [\n {\n \"type\": \"promptString\",\n \"id\": \"sendora-key\",\n \"description\": \"Sendora key\",\n \"password\": true\n }\n ],\n \"servers\": {\n \"sendora\": {\n \"type\": \"http\",\n \"url\": \"https://mcp.sendora.se/mcp\",\n \"headers\": { \"Authorization\": \"Bearer ${input:sendora-key}\" }\n }\n }\n}\n```\n\n### Codex\n\nEdit `~/.codex/config.toml`. Don't use `codex mcp add --env`, which writes the key's value into the file.\n\nCodex 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:\n\n```toml\n[mcp_servers.sendora]\ncommand = \"npx\"\nargs = [\"-y\", \"@sendora/mcp\"]\nenv_vars = [\"SENDORA_API_TOKEN\"]\ntool_timeout_sec = 90\n```\n\nOr hosted:\n\n```toml\n[mcp_servers.sendora]\nurl = \"https://mcp.sendora.se/mcp\"\nbearer_token_env_var = \"SENDORA_API_TOKEN\"\ntool_timeout_sec = 90\n```\n\n### Claude Desktop\n\n[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.\n\n1. Paste your key into **Sendora key**, which Claude Desktop keeps in your operating system's secure storage. Leave it empty for the documentation alone.\n2. Tick **Allow changes** only when you want changes on.\n\nEach 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.\n\n## Check the connection\n\nAsk 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`.\n\n| What you see | Why | What to do |\n| ----------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| 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 |\n| 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 |\n\nThen give the agent a task:\n\n- With a test server's key, ask: \"Send a test mail to any address you like, and tell me whether it was delivered.\"\n- With a live server's key, ask: \"Why did the last message to `anna@example.com` bounce?\"\n- With no key, ask: \"Add a password-reset mail to this app with `@sendora/sdk`.\"\n\n## Turn on changes\n\nA live server's key and the account key list only the reading tools until you turn on changes for the connection:\n\n- Locally, add `--allow-writes` after `@sendora/mcp` in the arguments, or set `SENDORA_ALLOW_WRITES=true` in the server's environment.\n- Hosted, send the header `Sendora-Allow-Writes: true` beside the key.\n- In Claude Desktop, tick **Allow changes**.\n- Through OAuth, tick the box for changes on live servers when you approve the app. The account stays read only.\n\nAny value but `true` leaves changes off. A test server's key needs no switch.\n\nWith 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.\n\nThe 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.\n\n## Received mail\n\nA 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:\n\n- `search_inbound` returns only two of the addresses the sender chose: the From address and the address the mail was sent to.\n- `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.\n\nThis 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.\n\n- Read what the agent proposes before you approve it.\n- Keep changes off unless the task needs them.\n- Give an agent that reads received mail no more tools than the task needs.\n- If you think a key reached someone it should not have, revoke it in the dashboard.\n\n## Tools\n\nThe 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.\n\n| Tool | What it does | Keys |\n| --- | --- | --- |\n| `search_docs` | Search the Sendora docs | any, or none |\n| `read_doc` | Read a Sendora document | any, or none |\n| `get_server` | Read the server this key belongs to | test, live |\n| `send_email` | Send one email | test, live with changes on |\n| `search_messages` | Search the message log | test, live |\n| `get_message` | Read one message and its timeline | test, live |\n| `list_suppressions` | List a stream's suppression list | test, live |\n| `remove_suppression` | Lift a suppression | test, live with changes on |\n| `list_tracking_opt_outs` | List the not-tracked addresses | test, live |\n| `add_tracking_opt_out` | Add a not-tracked address | test, live with changes on |\n| `remove_tracking_opt_out` | Remove a not-tracked address | test, live with changes on |\n| `create_stream` | Add a stream | test, live with changes on |\n| `archive_stream` | Archive a stream | test |\n| `send_broadcast` | Send a test broadcast | test |\n| `get_broadcast` | Read a broadcast | test, live |\n| `cancel_broadcast` | Cancel a broadcast | test |\n| `list_webhooks` | List the server's webhooks | test, live |\n| `list_webhook_deliveries` | List a webhook's deliveries | test, live |\n| `replay_webhook_delivery` | Replay a webhook delivery | test, live with changes on |\n| `create_webhook` | Add a webhook | test |\n| `delete_webhook` | Delete a webhook | test |\n| `list_keys` | List the server's keys | test, live |\n| `create_key` | Create a key | test |\n| `revoke_key` | Revoke a key | test |\n| `search_inbound` | Search received mail | live |\n| `read_inbound_message` | Read a received message | live |\n| `list_inbound_domains` | List the domains for receiving | live |\n| `check_inbound_domain` | Check a domain for receiving | live |\n| `add_inbound_domain` | Add a domain for receiving | live with changes on |\n| `list_servers` | List the account's servers | account |\n| `list_domains` | List the sending domains | account |\n| `check_domain` | Check a sending domain | account |\n| `add_domain` | Add a sending domain | account with changes on |\n| `list_tracking_domains` | List the tracking domains | account |\n| `check_tracking_domain` | Check a tracking domain | account |\n| `add_tracking_domain` | Add a tracking domain | account with changes on |\n| `delete_test_server` | Delete a test server | account with changes on |\n\n## Screens\n\nIn 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:\n\n- `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.\n- `add_domain` and `check_domain` show the domain's two DNS records, whether each checks out, and a button that checks them again.\n\nEvery other client gets the same answer as text. A screen loads nothing from anywhere else.\n\n## Documents and prompts\n\nThe documentation is built into the MCP server at each release, from the same sources as these pages:\n\n- the guides, as `sendora://docs/…`\n- the API reference, as `sendora://reference/…`\n- the error codes, as `sendora://errors`\n- the TypeScript SDK's README and rules for agents, as `sendora://sdk/readme` and `sendora://sdk/skill`\n- the Python SDK's, as `sendora://sdk/python/readme` and `sendora://sdk/python/skill`\n- the PHP SDK's, as `sendora://sdk/php/readme` and `sendora://sdk/php/skill`\n- the Laravel package's README, as `sendora://sdk/laravel/readme`\n\n`sendora://index` lists them all. `search_docs` searches every document but the rules, which an agent reads whole. `read_doc` reads one document whole.\n\nThe server has three prompts, which a client offers as commands:\n\n- `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.\n- `why_not_delivered`: read a message's timeline, the suppression list and the error codes, and explain what happened.\n- `verify_webhooks`: implement signature verification for the webhook endpoint and test it.\n\n## The official server\n\nSendora'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."},{"uri":"sendora://docs/changelog","title":"Changelog","description":"What each release of the SDKs changed, and what to change in your code when you upgrade.","text":"# Changelog\n\nWhat each release of the SDKs changed, and what to change in your code when you upgrade.\n\nWhat 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.\n\n## TypeScript SDK\n\nThe package is `@sendora/sdk` on npm.\n\n### 5.4.0\n\n`sendora.trackingOptOuts` lists, adds and removes the addresses on a server's [not-tracked list](sendora://docs/suppressions#the-not-tracked-list): `list()`, `listAll()`, `add()` and `delete()`. No call changes.\n\n### 5.3.0\n\nTwo 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](sendora://docs/webhooks#events).\n\n### 5.2.0\n\nEach 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](sendora://docs/messages#opens-and-clicks). A `switch` over `MessageEventType` needs the two new values. No call changes.\n\n### 5.1.0\n\n`account.trackingDomains` adds, lists, reads, checks and removes [tracking domains](sendora://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.\n\n### 5.0.2\n\nThe 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.\n\n### 5.0.1\n\nThe 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.\n\n### 5.0.0\n\nThe 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:\n\n- `SendoraErrorCode` and a batch item's `error` no longer include `unpaid_limit_reached`.\n- `SendoraError`, its `toJSON()` and a batch item's result no longer have `unpaidOre`, `limitOre` or `priceOre`.\n\nA 402 from a send, a batch or a broadcast is now always `payment_required`, which is not `retryable`. No call changes.\n\n### Upgrade from 4.x\n\n1. Install the new version with `npm install @sendora/sdk@5`.\n2. Remove each comparison with `unpaid_limit_reached` and each read of `unpaidOre`, `limitOre` or `priceOre`. TypeScript reports every one.\n\n### 4.4.1\n\nA 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.\n\n### 4.4.0\n\n`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.\n\n### 4.3.0\n\nA 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.\n\n### 4.2.0\n\nThe 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](sendora://docs/limits#the-cap-on-received-mail) says what the cap does.\n\n### 4.1.1\n\nThe 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.\n\n### 4.1.0\n\nThree 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.\n\n### 4.0.0\n\nTwo changes break code written for 3.x:\n\n- Values that said tenant now say account. The [error codes](sendora://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.\n- `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](sendora://docs/webhooks#verify-the-signature) shows the verifier.\n\n### Upgrade from 3.x\n\n1. Install the new version with `npm install @sendora/sdk@4`.\n2. Where you compare `error.code` with `tenant_paused` or `tenant_not_active`, compare it with `account_paused` or `account_not_active` instead.\n3. Where you compare a `scope` with `tenant`, compare it with `account`.\n4. If your code checks that a `switch` over `event.event` handles every event, add a case for `unknown`.\n\n## Python SDK\n\nThe package is `sendora` on PyPI.\n\n### 2.4.0\n\n`sendora.tracking_opt_outs` lists, adds and removes the addresses on a server's [not-tracked list](sendora://docs/suppressions#the-not-tracked-list): `list()`, `list_all()`, `add()` and `delete()`. No call changes.\n\n### 2.3.0\n\nTwo 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](sendora://docs/webhooks#events).\n\n### 2.2.0\n\nEach `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](sendora://docs/messages#opens-and-clicks). No call changes.\n\n### 2.1.0\n\n`account.tracking_domains` adds, lists, reads, checks and removes [tracking domains](sendora://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.\n\n### 2.0.2\n\nThe 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.\n\n### 2.0.1\n\nThe 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.\n\n### 2.0.0\n\nThe 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:\n\n- `SendoraErrorCode` and a batch item's `code` no longer include `unpaid_limit_reached`.\n- `SendoraError` and `RefusedBatchItem` no longer have `unpaid_ore`, `limit_ore` or `price_ore`, so reading one raises `AttributeError`.\n\nA 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.\n\n### Upgrade from 1.x\n\n1. Install the new version with `pip install \"sendora~=2.0\"`.\n2. Remove each comparison with `unpaid_limit_reached` and each read of `unpaid_ore`, `limit_ore` or `price_ore`.\n\n### 1.4.1\n\nA 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.\n\n### 1.4.0\n\n`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.\n\n### 1.3.0\n\nA 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.\n\n### 1.2.0\n\nThe 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](sendora://docs/limits#the-cap-on-received-mail) says what the cap does.\n\n### 1.1.1\n\nThe 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.\n\n### 1.1.0\n\nThree 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.\n\n### 1.0.0\n\nThe 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.\n\n### Upgrade from 0.x\n\n1. Install the new version with `pip install \"sendora~=1.0\"`.\n2. 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`.\n3. If you wrote for a version before 0.9.0, compare a `scope` with `account` where you compared it with `tenant`.\n\n## PHP SDK\n\nThe package is `sendora/sendora-php` on Packagist.\n\n### 1.4.0\n\n`$sendora->trackingOptOuts` lists, adds and removes the addresses on a server's [not-tracked list](sendora://docs/suppressions#the-not-tracked-list): `list()`, `listAll()`, `add()` and `delete()`. No call changes.\n\n### 1.3.0\n\nTwo 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](sendora://docs/webhooks#events).\n\n### 1.2.0\n\nEach `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](sendora://docs/messages#opens-and-clicks). No call changes.\n\n### 1.1.0\n\n`$account->trackingDomains` adds, lists, reads, checks and removes [tracking domains](sendora://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.\n\n### 1.0.3\n\nThe 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.\n\n### 1.0.2\n\nThe 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.\n\n### 1.0.1\n\nThe 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.\n\n### 1.0.0\n\nThe 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.\n\n### Upgrade from 0.x\n\n1. Install the new version with `composer require sendora/sendora-php:^1.0`.\n2. 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.\n\n## Laravel package\n\nThe package is `sendora/sendora-laravel` on Packagist, and it installs the PHP SDK.\n\n### 1.3.0\n\nThe container holds `TrackingOptOuts`, and the facade has `trackingOptOuts()`, for a server's [not-tracked list](sendora://docs/suppressions#the-not-tracked-list). The package requires `sendora/sendora-php` 1.4.0 or a later 1.x release. No call changes.\n\n### 1.2.0\n\nTwo Laravel events, `MessageOpened` and `LinkClicked`, for the [`opened` and `clicked` webhooks](sendora://docs/webhooks#events). The package requires `sendora/sendora-php` 1.3.0 or a later 1.x release.\n\n### 1.1.0\n\nThe container holds `TrackingDomains`, the account client's resource for [tracking domains](sendora://docs/domains#tracking-domains). The package requires `sendora/sendora-php` 1.1.0 or a later 1.x release.\n\n### 1.0.0\n\nThe 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.\n\nTo 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."},{"uri":"sendora://docs/sso","title":"SSO and SCIM","description":"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.","text":"# SSO and SCIM\n\nSign 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.\n\nYour 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.\n\nThis 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.\n\n## Before you start\n\n- 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.\n- You need administrator rights in your identity provider.\n- You need to publish a DNS record at your organisation's email domain.\n\n## How the setup goes\n\n1. Prove that the account controls your email domain, with one DNS record.\n2. 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.\n3. Test the sign-in. The test signs nobody in to Sendora.\n4. An administrator switches the connection on. The sign-in screen then shows a button under the address field for each address at your domain.\n5. If you want, require SSO, and connect SCIM.\n\nAn account can hold several connections, for example one per provider, up to the count in [Limits](sendora://docs/limits#sso-and-scim). Each covers the domains you attach to it.\n\n## Prove your domain\n\nAdd the domain under [Account › Sign-in](https://app.sendora.se/account/sso). Sendora shows one TXT record, such as:\n\n```\n_sendora-sso.example.com TXT \"sendora-sso=8f14e45fceea167a5a36dedd4bea2543\"\n```\n\nPublish 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.\n\n- A domain covers its exact name only. `mail.example.com` is a domain of its own.\n- Only one Sendora account can verify a domain.\n- A verified sending domain does not count. Sending as a domain is not the same as signing its people in.\n\n## Connect by SAML\n\nEach connection has its own values for your provider. The connection's page shows each one to copy.\n\n| Value | Is |\n| ------------------------------------ | --------------------------------------------------------------- |\n| Entity ID | `https://app.sendora.se/dashboard/sso/saml/<connection id>` |\n| ACS URL (Assertion Consumer Service) | `https://app.sendora.se/dashboard/sso/saml/<connection id>/acs` |\n| Metadata URL | The entity ID, which also serves Sendora's metadata |\n| Sign-on URL | `https://app.sendora.se/login?sso_domain=<your domain>` |\n\nSendora 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.\n\nGive Sendora your provider's metadata, as a URL or as a file.\n\n- 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.\n- 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.\n\nYour 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.\n\n- 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.\n- It takes the name from `http://schemas.microsoft.com/identity/claims/displayname`, `displayName` or `urn:oid:2.16.840.1.113730.3.1.241`.\n- 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`.\n- Without any name, the sign-in still works, and the user has no name in Sendora.\n- 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.\n\n## Connect by OpenID Connect\n\nCreate a web app with the authorization code flow in your provider, with these values.\n\n| Value | Is |\n| ------------ | -------------------------------------------------------------------- |\n| Redirect URI | `https://app.sendora.se/dashboard/sso/oidc/<connection id>/callback` |\n| Scopes | `openid email profile` |\n\nThen give Sendora:\n\n- the issuer's URL, whose `/.well-known/openid-configuration` Sendora must reach from the internet;\n- the client ID and the client secret, sent as HTTP Basic (`client_secret_basic`) or in the form (`client_secret_post`);\n- the date the secret expires, if it does. Sendora mails the administrators 30 and 7 days before.\n\nSendora 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.\n\n- `sub` is who the person is to Sendora.\n- `email` is the address. Sendora refuses one whose `email_verified` is false.\n- The name is `name`, or else `given_name` and `family_name`. Without one, the sign-in still works.\n\n## Each provider's steps\n\nThe 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.\n\n### Microsoft Entra ID\n\n1. 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**.\n2. Name the app Sendora. Choose **Integrate any other application you don't find in the gallery (Non-gallery)**, then **Create**.\n3. 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**.\n4. 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.\n5. 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.\n6. Back under **Single sign-on**, copy **App Federation Metadata Url** from **SAML Certificates**, and give it to Sendora.\n\nThen test the sign-in in Sendora. Entra's own **Test** button works only once the connection is on.\n\n### Google Workspace\n\n1. 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**.\n2. On **Google Identity Provider details**, choose **Download Metadata** under **Option 1**, and keep the file for Sendora. Choose **Continue**.\n3. 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**.\n4. Under **Attribute mapping**, map **Basic Information > First Name** to `firstName` and **Last Name** to `lastName`. Choose **Finish**.\n5. 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**.\n6. Give Sendora the metadata file.\n\nThe 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.\n\n### Okta\n\n1. In the Okta Admin Console, go to **Applications and Resources** and **Applications**. Choose **Create App Integration**.\n2. Choose **SAML 2.0** as the **Sign-in method**, then **Next**. Name the app Sendora and choose **Next**.\n3. 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.\n4. Choose **EmailAddress** as the **Name ID format** and **Email** as the **Application username**. Leave **Show Advanced Settings** as it is, and choose **Next**.\n5. Under **Feedback**, tick **This is an internal app that we have created**, then choose **Finish**.\n6. 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`.\n7. Under **Assignments**, assign the people or groups who sign in to Sendora.\n8. Back on the **Sign On** tab, copy **Metadata URL** from **Metadata details**, and give it to Sendora.\n\n### AD FS\n\n1. In **AD FS Management**, choose **Relying Party Trusts**, then **Add Relying Party Trust** under **Actions**. Choose **Claims aware** and **Start**.\n2. 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.\n3. 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.\n4. 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**.\n5. Add a rule from the template **Transform an Incoming Claim** that turns **E-Mail Address** into **Name ID**, with the format **Email**.\n6. 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.\n7. Give Sendora your federation service's metadata, at `https://<your federation service>/FederationMetadata/2007-06/FederationMetadata.xml`. If Sendora cannot reach it from the internet, save it from a browser and give Sendora the file.\n\n### Keycloak\n\n1. In the Keycloak admin console, choose your realm. Go to **Clients** and choose **Create client**.\n2. Leave **Client type** as **OpenID Connect**, enter `sendora` as the **Client ID** and choose **Next**.\n3. Turn on **Client authentication**, leave **Standard flow** ticked and choose **Next**.\n4. 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**.\n5. Under the **Credentials** tab, copy **Client Secret**.\n6. 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.\n\nSendora 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.\n\n## Test and switch on\n\nTest 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.\n\n| A failed test says | Check |\n| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |\n| `provider_error` | Your provider stopped the sign-in. Check that the person may use the app. |\n| `discovery_failed` | Sendora could not read the issuer's configuration. Check the issuer's URL. |\n| `token_exchange_failed` | Your provider refused the client ID or the secret. |\n| `id_token_invalid` | The ID token failed Sendora's checks. Check the issuer's URL and the client ID. |\n| `userinfo_failed` | Sendora could not read the userinfo endpoint. Try again shortly. |\n| `email_missing` | Your provider sent no address. Let the app read the person's email. |\n| `email_unverified` | Your provider says the address is not verified. |\n| `response_invalid` | Sendora could not accept the response. Check that your provider uses Sendora's metadata. |\n| `signature_invalid` | The signature does not match the connection's certificate. Give the connection your provider's metadata again, and sign with SHA-256. |\n| `response_expired` | The response was not valid at this moment. Check your provider's clock. |\n| `audience_mismatch` | The response was meant for another service. Check Sendora's entity ID and ACS URL in your provider. |\n| `issuer_mismatch` | The response came from another provider. Give the connection your provider's metadata again. |\n\nAn administrator switches the connection on, once three things hold:\n\n- the connection has a verified domain;\n- a test has passed since its settings last changed;\n- Sendora has approved the account.\n\nThrough 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.\n\n## Sign in\n\nA person types their address on the sign-in screen. At a domain of a connection that is on, a button under the field starts the sign-in at your provider. Only an address at one of the connection's verified domains signs in through it.\n\nA 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.\n\nThe first time, Sendora finds the person's user:\n\n- A user whom SCIM created, or whom this connection created, is linked at once.\n- 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.\n- A person with no user gets one as a member with no servers, if the connection creates users. Otherwise an administrator invites them first.\n\nA sign-in through SSO lasts at most 12 hours. A person whose address is in another Sendora account cannot sign in here.\n\n## Require SSO\n\nAn administrator can require SSO on a connection that is on. It covers the people at its domains and those linked to it.\n\n- A password fails for everyone it covers.\n- A member signs in only through your provider. A request for a mailed link gets the SSO button instead.\n- 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.\n- Switching the requirement on ends these people's other sessions and their connected apps, such as an AI assistant.\n- People at other domains, keys, SMTP and webhooks are unaffected.\n\nIf no administrator can sign in, [Sendora's support](https://sendora.se/contact) can lift the requirement.\n\n## SCIM\n\nWith 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.\n\n### Connect your provider\n\nAn 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.\n\n| Setting | Value |\n| -------- | ------------------------------------------- |\n| Base URL | `https://api.sendora.se/scim/v2` |\n| Token | The SCIM token, `scim_…`, as a bearer token |\n\nEvery request carries `Authorization: Bearer scim_…`. Answers are `application/scim+json`, and errors use the error schema of RFC 7644.\n\nIn 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.\n\n1. In the enterprise app, open **Provisioning** and set the mode to **Automatic**.\n2. Enter the base URL as **Tenant URL** and the token as **Secret Token**. Choose **Test Connection**, then **Save**.\n3. Under **Mappings**, turn off **Provision Microsoft Entra ID Groups**, since Sendora keeps no groups. A group assigned to the app still provisions its members.\n4. 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`.\n5. Turn provisioning on. It provisions the users assigned to the app, directly or through a group.\n\nIn Okta, use the SAML integration from above, since Okta offers SCIM only on SAML integrations.\n\n1. Under the app's **General** tab, edit **App Settings**, choose **SCIM** as **Provisioning** and save.\n2. Under the **Provisioning** tab, edit **Integration**. Enter the base URL as **SCIM connector base URL** and `userName` as **Unique identifier field for users**.\n3. Under **Supported provisioning actions**, tick **Push New Users** and **Push Profile Updates**, and leave the rest unticked. Sendora keeps no groups.\n4. Choose **HTTP Header** as **Authentication Mode**, and enter the token after **Bearer**. Choose **Test Connector Configuration**, then **Save**.\n5. Under **To App**, choose **Edit**, enable **Create Users**, **Update User Attributes** and **Deactivate Users**, and save.\n6. 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.\n\nAD FS and Keycloak have no SCIM client of their own. Google Workspace may not offer SCIM for a custom SAML app.\n\n### Endpoints\n\n| Endpoint | Does |\n| ---------------------------------------------------------- | ---------------------------------------------------- |\n| `GET /ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | Describe what Sendora serves |\n| `GET /Users` | Lists or filters, with `startIndex` and `count` |\n| `POST /Users` | Creates a member with no servers |\n| `GET /Users/:id` | Reads one |\n| `PUT /Users/:id` | Replaces the attributes it carries |\n| `PATCH /Users/:id` | Changes one |\n| `DELETE /Users/:id` | Removes one |\n| `GET /Groups` | Returns an empty list, since Sendora keeps no groups |\n\nAny 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.\n\nSCIM 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`.\n\nA 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.\n\n### The user\n\n- `id` is the Sendora user's id.\n- `userName` and `externalId` are kept as your provider sent them.\n- 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`.\n- `name.formatted` sets the full name, or else the given and family names, or else `displayName`.\n- `active` is false while the user is deactivated.\n- `password`, `roles`, `groups` and `entitlements` are ignored.\n\n### What each request does\n\n- `POST` creates a member with no servers and mails nothing. An address that any Sendora account already holds fails with 409 `uniqueness`.\n- `PUT` replaces each attribute it carries. One it leaves out keeps its value, `active` included.\n- `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.\n- `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.\n- 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`.\n- `DELETE` removes the user. For the account's last administrator it fails with 409, and they stay deactivated instead.\n- Deactivating the last active administrator still happens, since a leaver must lose access. Sendora's support then helps the account name a new one.\n\n## When certificates and secrets expire\n\nSendora 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.\n\nWith 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).\n\n[Limits](sendora://docs/limits#sso-and-scim) lists the counts and rates for SSO and SCIM."},{"uri":"sendora://docs/microsoft-365","title":"Move from Microsoft 365","description":"Move a system that sends through a Microsoft 365 mailbox with a password to Sendora's SMTP.","text":"# Move from Microsoft 365\n\nMove a system that sends through a Microsoft 365 mailbox with a password to Sendora's SMTP.\n\nMove 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.\n\n## What Microsoft changes\n\nMicrosoft 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:\n\n- Until the end of December 2026, nothing changes.\n- 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.\n- In organisations created after December 2026, it is unavailable by default, and OAuth is the way to sign in.\n- In the second half of 2027, Microsoft will announce the date it is removed for good.\n\nA 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.\n\n## Before you start\n\n- 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.\n- Access to the DNS of the domain your system sends from.\n- Access to the system's mail settings.\n\n## Move the system\n\n1. Add the domain your system sends from under [Domains](https://app.sendora.se/domains), and add the two DNS records it shows, as [Domains](sendora://docs/domains) explains. The records leave your MX and SPF records as they are, so Microsoft 365 keeps receiving the domain's mail.\n2. 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](sendora://docs/test-servers) takes mail as a live server does and delivers none of it.\n3. In the system's mail settings, replace Microsoft 365's values with Sendora's:\n\n| Setting | Microsoft 365 | Sendora |\n| ------------------- | ---------------------------------- | ------------------------------------------------- |\n| Server | `smtp.office365.com` | `smtp.sendora.se` |\n| Port and encryption | 587 with STARTTLS | 587 with STARTTLS, or 465 with TLS from the start |\n| User name | The mailbox's address | The server key |\n| Password | The mailbox's password | The server key |\n| From address | The mailbox, or one it may send as | Any address on a verified domain |\n\n4. 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).\n5. 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.\n6. 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.\n\n## What changes for the system\n\n- Replies go where they went before. They reach the From or `Reply-To` address, so a mailbox in Microsoft 365 still receives them.\n- 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](sendora://docs/limits#retention) gives.\n- 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](sendora://docs/suppressions).\n- Sendora's [limits](sendora://docs/limits) apply instead of Microsoft's.\n\n## If a message is refused\n\nSendora 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](sendora://docs/smtp#smtp-replies) lists every reply."},{"uri":"sendora://errors","title":"Error codes","description":"Every code the API answers, with its HTTP statuses and what it means.","text":"# Error codes\n\nEvery error answers a JSON body with an `error` code and a `message`; the Errors guide (sendora://docs/errors) says what each refusal carries.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled already; nothing is left to cancel. |\n| `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |\n| `default_stream` | 409 | The default stream of a server cannot be archived. |\n| `domain_exists` | 409 | The account already has that domain. |\n| `domain_reserved` | 400 | The name is Sendora’s own, or a return-path host; no account can receive on it. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |\n| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |\n| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |\n| `inbound_domain_exists` | 409 | The stream already has a domain, or another account holds the name verified. |\n| `inbound_stream_exists` | 409 | A server has one inbound stream at a time; archive it to make another. |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted; create another first. |\n| `last_token` | 409 | This is the only live key of the server; create another before revoking it. |\n| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |\n| `mode_immutable` | 400 | A server’s mode is fixed when it is created; create a new server for the other mode. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `not_found` | 404 | No such broadcast of this server. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `rate_limited` | 429 | More was sent within a minute than the limit allows. |\n| `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. |\n| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `secret_limit` | 409 | The webhook already holds two live secrets: one in use and one to roll to. Delete one before creating another. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; try again once they have left. |\n| `server_limit` | 409 | The account has as many servers as it may; Sendora raises the cap on request. |\n| `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. |\n| `spam_complaint_locked` | 403 | The recipient reported a message as spam; only Sendora support lifts that, at the recipient’s own request. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_exists` | 409 | The server already has a stream with this name, archived or not. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; the streamId names a transactional one. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |\n| `substitution_missing` | 422 | The subject, text or HTML names a {{ key }} that a message does not give. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `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. |\n| `tracking_domain_exists` | 409 | The account already has that host, or a host on that sending domain. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may have. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. A tracking domain is a subdomain of one. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of this account. |\n| `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `unsubscribe_locked` | 403 | The recipient unsubscribed themselves; only Sendora support lifts that, at the recipient’s own request. |\n| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL. |\n| `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. |"},{"uri":"sendora://sdk/readme","title":"The TypeScript SDK","description":"@sendora/sdk's README: every call, error code and webhook event.","text":"# @sendora/sdk\n\nThe official TypeScript SDK for [Sendora](https://sendora.se), transactional\nemail delivery. Zero dependencies. Runs on Node 20.19 or newer and on any runtime with `fetch` and WebCrypto: Bun, Deno, Cloudflare\nWorkers. ESM only; CommonJS projects on Node 20.19 or 22.12 and newer load it\nwith `require`.\n\n```sh\nnpm install @sendora/sdk\n```\n\nTwo clients, two kinds of key. `Sendora` takes a server key (`sk_…`) and\ndoes everything inside one server: sending, broadcasts, the log,\nreceived mail, streams, suppressions, the not-tracked list, tokens, webhooks and the server's\nown limits. `SendoraAccount` takes an account key\n(`ak_…`) and manages the account: its servers, their keys, its sending and tracking\ndomains. Both keys are secrets: keep them on the server and never ship\nthem to a browser. Either client throws at construction when its key is\nmissing, so build it at startup.\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });\n\nconst { messageId } = await sendora.email.send({\n from: { email: 'no-reply@example.se', name: 'Example AB' },\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n attachments: [{ name: 'faktura.pdf', content: pdfBytes, contentType: 'application/pdf' }],\n tag: 'invoice',\n metadata: { invoiceId: '2026-0912' },\n});\n```\n\n`from` must be on a verified sending domain. `to`, `cc` and `bcc` take\nplain addresses or objects with a display name, at most 50 in all; at least\none of `text` and `html` is required. `streamId` names the stream of the\nserver the message goes on; without it the default transactional stream. Attachments take bytes (`Uint8Array`,\nso a Node `Buffer` too) or a base64 string. Every method takes what the\nroute takes and answers what the route answers; every call accepts an\n`options.signal` to cancel it.\n\n## Sending\n\n| Call | Answers |\n| --------------------------------------------- | -------------------------------------------------------------------------- |\n| `sendora.email.send(message, options?)` | `{ messageId, status: 'accepted', submittedAt, test }` |\n| `sendora.email.sendBatch(messages, options?)` | `{ results }`, one per message in order, `accepted` with its id or `error` |\n\n`accepted` means the message is stored and on its way to the mail server;\ndelivery, deferral, bounce and complaint arrive later as events on the\nmessage and as webhooks.\n\n`options.idempotencyKey` names the request so a retry cannot send twice.\nThe SDK makes a random UUID when you give none; give your own when a retry\nmay come from another process. The API remembers a key for 24 hours and\nrefuses it with a different body (`idempotency_key_mismatch`).\n\nA batch never fails halfway. Each result is `status: 'accepted'` with the\nid, or `status: 'error'` with the same `error` code a single send would\nthrow; a retried batch answers the items sent before with `replayed: true`.\n\n```ts\nconst { results } = await sendora.email.sendBatch(messages, { idempotencyKey: 'invoices-2026-09' });\nfor (const result of results) {\n if (result.status === 'error') {\n console.log(result.index, result.error, result.message);\n }\n}\n```\n\n## Messages\n\n| Call | Answers |\n| ------------------------------------------------------------ | --------------------------------------------------------------------------------------- |\n| `sendora.messages.get(messageId)` | The message with its recipients, `tracking`, its attachments described and its timeline |\n| `sendora.messages.search(filters?)` | `{ messages, next }`, one page newest first |\n| `sendora.messages.searchAll(filters?)` | Every match, page by page, for `for await` |\n| `sendora.inbound.get(inboundMessageId)` | A received message: envelope, parties, headers, text, HTML, attachments described |\n| `sendora.inbound.search(filters?)` | `{ messages, next }` of received mail, newest first |\n| `sendora.inbound.searchAll(filters?)` | Every received match, page by page, for `for await` |\n| `sendora.inbound.raw(inboundMessageId)` | The raw message as bytes, `message/rfc822` |\n| `sendora.inbound.attachment(inboundMessageId, attachmentId)` | An attachment's bytes |\n\nA stream can also receive on a domain of yours: every address on it,\nwhatever the local part, lands on the stream. Claim it with the stream\nid, add the two records the answer gives, an MX that names Sendora and a\nTXT record that proves the claim, and mail is accepted once both are\nseen. A stream holds one domain, and the first account to verify a name\nholds it.\n\n| Call | Answers |\n| ----------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `sendora.inboundDomains.create({ streamId, domain })` | The domain with `mx` and `txt` to add |\n| `sendora.inboundDomains.list()` | `{ domains }` of the server |\n| `sendora.inboundDomains.get(inboundDomainId)` | One domain with the state of its records |\n| `sendora.inboundDomains.verify(inboundDomainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `sendora.inboundDomains.delete(inboundDomainId)` | Nothing; mail to it is refused from then on |\n\nFilters: `recipient`, a whole address or three characters or more of one,\n`streamId`, `tag`, `status` (`queued`, `delivered`, `deferred`, `bounced`,\n`expired`, or a list of them), `from` and `to` as `Date` or ISO 8601,\n`limit` (1 to 100), `after`. Bodies are never returned; the log keeps messages for 13\nmonths.\n\nWhere mail is tracked, each recipient has `tracked`, `opens`, `clicks`,\n`firstOpenAt` and `firstHumanOpenAt`. `messages.get` adds `tracking`, and its\ntimeline holds `Open` and `Click` events with their labels. Tracking is not\nopen to accounts yet.\n\n```ts\nconst message = await sendora.messages.get(messageId);\nconst delivered = message.recipients.every((recipient) => recipient.status === 'delivered');\n\nfor await (const bounced of sendora.messages.searchAll({\n status: 'bounced',\n from: new Date('2026-09-01'),\n})) {\n console.log(bounced.messageId, bounced.subject);\n}\n```\n\n## Streams\n\nEvery message goes on a stream of its server. A server starts with a\ndefault transactional stream, and the account adds more: transactional\nand broadcast ones, open to every account, and one inbound stream, which\nreceives mail at its `inboundAddress` instead of sending. Each sending stream has a\nsuppression list of its own.\n\n| Call | Answers |\n| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |\n| `sendora.streams.create({ kind, name })` | The stream; a taken name is `stream_exists`, a second inbound stream `inbound_stream_exists`, both with `existingId` |\n| `sendora.streams.list()` | `{ streams }`, archived ones included |\n| `sendora.streams.get(streamId)` | One stream |\n| `sendora.streams.update(streamId, { name, contentRetentionDays })` | The updated stream; the days apply to an inbound stream |\n| `sendora.streams.archive(streamId)` | The stream with `archivedAt`; it takes no new messages from then on |\n\n```ts\nconst stream = await sendora.streams.create({ kind: 'transactional', name: 'Aviseringar' });\nawait sendora.email.send({ ...message, streamId: stream.streamId });\n```\n\n## Broadcasts\n\nOne message to many on a broadcast stream, sent as a whole: the content\nonce and one entry per message, up to 50,000 and 50 MB per broadcast.\nEvery message is a message in the log with its own events and webhooks,\ncarrying the `broadcastId`, and every recipient gets their own\nunsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the\nstream's suppression list are dropped and counted in `suppressed`. The\nSDK sets an idempotency key, so a retry answers the same broadcast. A\nmessage's `substitutions`, up to 20 strings, replace `{{ key }}` in the\nsubject, the text and the HTML, escaped in the HTML; a key the content\nnames that a message lacks throws `substitution_missing`.\n\n| Call | Answers |\n| ---------------------------------------------- | ---------------------------------------------------------------------------------- |\n| `sendora.broadcasts.send(broadcast, options?)` | `{ broadcastId, total, suppressed, test }`; refused whole otherwise |\n| `sendora.broadcasts.get(broadcastId)` | The broadcast with `status`, `released`, `failed`, `opened`, `clicked` and `links` |\n| `sendora.broadcasts.list({ limit?, after? })` | `{ broadcasts, next }`, newest first |\n| `sendora.broadcasts.cancel(broadcastId)` | The cancelled broadcast; what had not left counts as failed |\n\n```ts\nconst { broadcastId } = await sendora.broadcasts.send({\n streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n from: { email: 'nyheter@example.se', name: 'Example AB' },\n subject: 'Nyheter i oktober',\n text: 'Hej! Här är månadens nyheter. Vill du inte ha fler? {{ unsubscribe_url }}',\n messages: ['anna@example.com', 'bo@example.com'].map((email) => ({ to: [email] })),\n});\nconst progress = await sendora.broadcasts.get(broadcastId);\n```\n\n## Suppressions\n\nAddresses the server no longer sends to: hard bounces, spam complaints,\nunsubscribes and entries added by hand. A send to one of them throws `recipient_suppressed`.\n\n| Call | Answers |\n| ---------------------------------------------------------- | ----------------------------------------- |\n| `sendora.suppressions.list({ streamId?, limit?, after? })` | `{ suppressions, next }`, newest first |\n| `sendora.suppressions.listAll({ streamId? })` | Every entry, for `for await` |\n| `sendora.suppressions.delete({ address, streamId? })` | Nothing; the address may be sent to again |\n\nEvery stream has a list of its own; without `streamId` the calls mean the\ndefault transactional stream. A spam complaint or a recipient's own\nunsubscribe cannot be lifted this way (`spam_complaint_locked`,\n`unsubscribe_locked`); only Sendora support can, at the recipient's own\nrequest. A refused send carries `streamId` beside `suppressed`.\n\n```ts\nawait sendora.suppressions.delete({ address: 'anna@example.com' });\n```\n\n## The not-tracked list\n\nAddresses the server never tracks, on any of its streams. Mail to one still\ngoes out, untracked, and its opens and clicks of mail already sent are not\nrecorded. Tracking is not open to accounts yet, so the list changes nothing\nuntil it is.\n\n| Call | Answers |\n| -------------------------------------------------- | ------------------------------------------------- |\n| `sendora.trackingOptOuts.list({ limit?, after? })` | `{ trackingOptOuts, next }`, newest first |\n| `sendora.trackingOptOuts.listAll()` | Every entry, for `for await` |\n| `sendora.trackingOptOuts.add({ address })` | The entry, as it stands if the address was listed |\n| `sendora.trackingOptOuts.delete({ address })` | Nothing; `not_found` if the address is not listed |\n\nAn entry's `source` is `manual`, or `recipient` if the recipient asked for it.\nRemove a recipient's own entry only after new consent. An entry's `address` is\n`null` once an erasure has left only a keyed hash of it. `delete` still finds\nthat entry by its address, and nothing removes it without the address.\n\n```ts\nawait sendora.trackingOptOuts.add({ address: 'anna@example.com' });\n```\n\n## Tokens\n\nThe server's own keys, from its own key. Every key of a server has the\nsame rights; the value is shown once, when it is created; a server holds\nup to two live keys, and a third is `token_limit`. The account key\nmanages the same keys under `account.servers.tokens`.\n\n| Call | Answers |\n| --------------------------------- | ---------------------------------------------------- |\n| `sendora.tokens.create({ name })` | The token with its `token` value, once |\n| `sendora.tokens.list()` | `{ tokens }`, revoked ones included |\n| `sendora.tokens.get(tokenId)` | One token, without its value |\n| `sendora.tokens.revoke(tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\n## The server\n\nThe key's own server: its name and id, whether it is live or a test\nserver, every limit a send on it is held to with how much of it this\nminute and this month have used, and the account's sending domains a From\naddress may use.\n\n`inboundCap` is the account's monthly cap on received mail, apart from the\nlimits on sending, with its `limit`, `used` and `resetsAt`. Past it, mail\nto the account is refused for now, so senders retry. It is null on a test\nserver, which receives no mail.\n\n| Call | Answers |\n| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |\n| `sendora.server.get()` | The server with `mode`, `limits`, each with `used` and `resetsAt`, `inboundCap`, and `sendingDomains` to send from |\n\n```ts\nconst { mode, limits, sendingDomains } = await sendora.server.get();\nconst month = limits.find((limit) => limit.period === 'month');\nconsole.log(\n mode,\n month?.used,\n month?.limit,\n sendingDomains.map(({ domain }) => domain),\n);\n```\n\n## Webhooks\n\nAn https URL of yours that Sendora posts events to as they happen, signed\nwith a secret shown once at creation. A webhook holds up to two live\nsecrets, so a new one can be rolled in beside the old one: every delivery\nis then signed with each, the newest first, and the old secret is deleted\nonce your receiver has switched.\n\n| Call | Answers |\n| -------------------------------------------------------- | ------------------------------------------------ |\n| `sendora.webhooks.create({ url, events?, streamId? })` | The webhook with its `secret`, once |\n| `sendora.webhooks.list()` | `{ webhooks }` |\n| `sendora.webhooks.get(webhookId)` | One webhook |\n| `sendora.webhooks.delete(webhookId)` | Nothing; pending deliveries are dropped |\n| `sendora.webhooks.deliveries(webhookId, { status? })` | `{ deliveries, next }`, newest first |\n| `sendora.webhooks.deliveriesAll(webhookId, { status? })` | Every delivery, for `for await` |\n| `sendora.webhooks.replay(webhookId, deliveryId)` | The delivery, queued again |\n| `sendora.webhooks.createSecret(webhookId)` | A second secret with its `secret` value, once |\n| `sendora.webhooks.deleteSecret(webhookId, secretId)` | Nothing; the last live secret is refused |\n| `sendora.webhooks.receive(request, secret)` | The verified, typed event from a Fetch `Request` |\n\nEvents: `delivered`, `bounced`, `deferred`, `spam_complaint` and\n`unsubscribed` about a message, with `messageId`, `streamId`, `recipient`,\n`occurredAt`, `serverId`, your `tag` and `metadata`, and `details` from the\nreceiver, or for `unsubscribed` the `source` (`link` or `one_click`);\n`opened` and `clicked` about a tracked copy, sent only to a webhook that names\nthem, with the same fields and `details` that say whether a machine did it and,\nfor `clicked`, give the `link`; `cap_warning` and `cap_reached` about usage, with `scope`, `cap`, `used`\nand the period; `inbound` about a message received on an inbound stream,\nwith `inboundMessageId`, `envelopeRecipient`, the sizes, the\n`authentication` verdicts and, for a webhook with `inboundContent:\n'full'`, the message itself: `from`, `to`, `subject`, `text`, `html`,\n`attachments` and the rest. `inbound_cap_warning` and\n`inbound_cap_reached` say that received mail nears or reaches the\naccount's monthly cap, with `cap`, `used` and the period. Every event\ncarries `id`, the delivery id, and `attempt`. Tracking is not open to accounts\nyet, so `opened` and `clicked` are not sent until it is.\n\nA failed delivery is retried with growing delays for about a day, then it\nis `dead` until you replay it. Deliveries arrive at least once and not\nalways in order, so key your handling on `event.id`.\n\n### Receiving\n\nVerify the signature before you parse anything, answer 2xx quickly, and do\nthe work afterwards. With a framework that hands you a Fetch `Request`\n(Next.js route handlers, Hono, SvelteKit, Cloudflare Workers):\n\n```ts\nimport { Sendora } from '@sendora/sdk';\n\nconst sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });\n\nexport async function POST(request: Request): Promise<Response> {\n const event = await sendora.webhooks.receive(request, process.env.SENDORA_WEBHOOK_SECRET);\n if (event.event === 'bounced' && event.details.hard) {\n console.log(\n `${event.recipient} is undeliverable: ${event.details.classification ?? 'unknown'}`,\n );\n }\n return new Response(null, { status: 204 });\n}\n```\n\nWith Express or Fastify, keep the raw body (`express.raw({ type: '*/*' })`\nor the raw-body plugin) and call the verifier directly:\n\n```ts\nimport { verifyWebhook } from '@sendora/sdk';\n\nconst event = await verifyWebhook({\n secret: process.env.SENDORA_WEBHOOK_SECRET,\n signature: req.headers['sendora-signature'],\n body: req.body,\n});\nswitch (event.event) {\n case 'bounced':\n console.log(event.recipient, event.details.hard);\n break;\n case 'unknown':\n console.log('an event newer than this release:', event.name, event.data);\n break;\n}\n```\n\nAn event this release does not know, one newer than it, comes as an\n`UnknownEvent` rather than an error, so your endpoint does not fail it\nuntil it is dead. Its `event` is `unknown`, `name` holds the event's name\nand `data` the payload as it came. Upgrade to read it as its type. A\nsigned body that is no event at all throws `unexpected_response`.\n\nThe signature is `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`,\none `v1` per live secret of the webhook, each an HMAC-SHA256 with that\nsecret over `<t>.<raw body>`, compared in constant time through WebCrypto;\nthe one your secret produces is enough. To roll a secret, create the new\none with `webhooks.createSecret`, move your receiver to it while both\nsign every delivery, then delete the old one. A signature older than five minutes is refused\n(`stale_signature`; `toleranceSeconds` changes the limit), a wrong one\nthrows `invalid_signature`.\n\n## The account: servers, keys, domains and erasures\n\nAn account key (`ak_…`) manages the account and never sends. An\nadministrator creates one in the dashboard under Account › API keys; the\naccount holds up to two live ones. `SendoraAccount` takes the same\noptions as `Sendora`.\n\n```ts\nimport { Sendora, SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN });\n\nconst server = await account.servers.create({ name: 'Fakturering' });\nconst sendora = new Sendora({ token: server.token.token });\n```\n\nA server is created with its default transactional stream and its first\nkey, whose value is in the answer this once. A server holds up to two\nlive keys and never falls below one, so rotation is create the new key,\nswitch to it, revoke the old one.\n\n### Test servers\n\nA server created with `mode: 'test'` goes through everything a live one\ndoes, validation, the suppression list, the log and the webhooks, but\nnothing it accepts is delivered. Its keys start `sk_test_`. Addresses at\n`simulator.sendora.se` (`hardbounce@`, `softbounce@`, `deferred@`,\n`complaint@`, with any `+label`) produce those outcomes; any other\naddress is marked delivered. A server's mode is fixed when it is\ncreated, so going live means a new server and a new key.\n\n```ts\nimport { isTestKey, Sendora, SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN });\nconst ci = await account.servers.create({ name: 'CI', mode: 'test' });\n\nconst token = process.env.SENDORA_API_TOKEN ?? '';\nif (process.env.NODE_ENV === 'production' && isTestKey(token)) {\n throw new Error('SENDORA_API_TOKEN is a test key; nothing would be delivered');\n}\nconst sendora = new Sendora({ token });\n```\n\nTest mail is free and counts against its own monthly cap and per-minute\nlimit, shared by the account's test servers. A test server has no\ninbound stream (`stream_kind_not_allowed`), and a rename that names a\nmode is `mode_immutable`.\n\n| Call | Answers |\n| --------------------------------------------------- | ---------------------------------------------------------------------------------- |\n| `account.servers.create({ name, mode })` | The server with its first key as `token`, once; a taken name is `server_exists` |\n| `account.servers.list()` | `{ servers }`, oldest first |\n| `account.servers.get(serverId)` | One server |\n| `account.servers.update(serverId, { name })` | The renamed server |\n| `account.servers.delete(serverId)` | Nothing; `server_in_flight` while its messages are still being delivered |\n| `account.servers.tokens.create(serverId, { name })` | The key with its `token` value, once; a third live key is `token_limit` with `max` |\n| `account.servers.tokens.list(serverId)` | `{ tokens }`, revoked ones included |\n| `account.servers.tokens.get(serverId, tokenId)` | One key, without its value |\n| `account.servers.tokens.revoke(serverId, tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\n```ts\nconst { token } = await account.servers.tokens.create(serverId, { name: 'Fakturasystemet' });\n```\n\nSending domains belong to the account, and every server sends from them.\nA domain sends once both DNS records are seen. A lookup that fails at the\nresolver (`dns_error`) changes nothing, and a verified domain whose record\ngoes missing keeps sending for 24 hours while the account's administrators\nare told, with `failingSince` saying when that began.\n\n| Call | Answers |\n| ------------------------------------ | ---------------------------------------------------------------------------- |\n| `account.domains.create({ domain })` | The domain with `returnPath` (CNAME) and `dkim` (TXT) to add |\n| `account.domains.list()` | `{ domains }` |\n| `account.domains.get(domainId)` | One domain with the state of its records |\n| `account.domains.verify(domainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `account.domains.delete(domainId)` | Nothing; mail from it is refused from then on |\n\n```ts\nconst domain = await account.domains.create({ domain: 'example.se' });\nconsole.log(`${domain.returnPath.type} ${domain.returnPath.host} -> ${domain.returnPath.value}`);\nconsole.log(`${domain.dkim.type} ${domain.dkim.host} -> ${domain.dkim.value}`);\n```\n\nA tracking domain is the host that tracked links and the open pixel use: a\nsubdomain of a verified sending domain, with one CNAME record to add. CAA\nrecords above it must let Let's Encrypt issue its certificate. Tracking is\nnot open to accounts yet, so a host carries no mail until it is. A host\nwhose records are not right within 7 days goes.\n\n| Call | Answers |\n| -------------------------------------------------- | --------------------------------------------------------------------- |\n| `account.trackingDomains.create({ host })` | The tracking domain with `cname` to add |\n| `account.trackingDomains.list()` | `{ trackingDomains }` |\n| `account.trackingDomains.get(trackingDomainId)` | One tracking domain with what the last check found |\n| `account.trackingDomains.verify(trackingDomainId)` | The tracking domain as it stands: `state`, `cname.check`, `caa.check` |\n| `account.trackingDomains.delete(trackingDomainId)` | Nothing; new mail stops using it at once |\n\n```ts\nconst tracking = await account.trackingDomains.create({ host: 'links.example.se' });\nconsole.log(`${tracking.cname.type} ${tracking.cname.host} -> ${tracking.cname.value}`);\n```\n\nAn erasure removes one person's data from every server of the account,\nas a request under the right to erasure asks. The content of every\nmessage to or from the address, sent and received, is deleted, and the\naddress becomes a pseudonym in the message log. The erasure answers\n`pending`, and Sendora carries it out within a minute. Erasing an address\nagain changes nothing more, and later mail to or from it is sent and\nreceived as before.\n\n| Call | Answers |\n| -------------------------------------- | ----------------------------------------------------------------------------------------- |\n| `account.erasures.create({ address })` | The erasure, `pending` |\n| `account.erasures.get(erasureId)` | The erasure; once `done`, its `pseudonym`, `messages` and `receivedMessages`, for 30 days |\n\n```ts\nconst erasure = await account.erasures.create({ address: 'anna@example.com' });\nconst { status } = await account.erasures.get(erasure.erasureId);\n```\n\nA key of the wrong kind answers `wrong_token_kind`: a server key on the\naccount's routes, or an account key on a server's.\n\n## Errors\n\nEvery failed call throws `SendoraError`. A path id that is empty, `.` or\n`..` throws `TypeError` instead, before any request, since the URL would\nreach another route.\n\nNarrow on `code`:\n\n```ts\nimport { SendoraError } from '@sendora/sdk';\n\ntry {\n await sendora.email.send(message);\n} catch (error) {\n if (error instanceof SendoraError) {\n switch (error.code) {\n case 'recipient_suppressed':\n // error.suppressed lists the addresses and why\n break;\n case 'rate_limited':\n // error.retryAfter seconds; error.scope and error.limit say whose limit\n break;\n case 'invalid_request':\n // error.issues, one { path, message } per field\n break;\n }\n }\n throw error;\n}\n```\n\n| Code | Status | Meaning |\n| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------- |\n| `invalid_request` | 400 | A field is wrong; `issues` says which. |\n| `idempotency_key_required` | 400 | A batch was sent without a key. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `wrong_token_kind` | 403 | A server key where an account key is needed, or the reverse; `message` names the kind. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `account_paused` | 403 | Sendora has paused the account. |\n| `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |\n| `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |\n| `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |\n| `not_found` | 404 | No such id on this server, or no such suppressed or not-tracked address. |\n| `domain_exists` | 409 | The account already has the domain; `existingId` names it. |\n| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |\n| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existingId` names it. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself; choose a subdomain. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |\n| `tracking_host_taken` | 409 | Another account holds the host. |\n| `inbound_domain_exists` | 409 | The stream already has a domain (`existingId` names it), or another account holds the name verified. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL; `existingId` names it. |\n| `stream_exists` | 409 | The server already has a stream with that name; `existingId` names it. |\n| `inbound_stream_exists` | 409 | The server already has a live inbound stream; `existingId` names it. |\n| `default_stream` | 409 | The default stream cannot be archived. |\n| `last_token` | 409 | The only live key cannot be revoked. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted. |\n| `secret_limit` | 409 | The webhook already holds two live secrets; `max` says how many. |\n| `token_limit` | 409 | The server or the account already holds two live keys; `max` says how many. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_limit` | 409 | The account has as many servers as it may; `max` says how many. |\n| `server_reserved` | 409 | The server sends Sendora's own sign-in mail and cannot be renamed or removed. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; delete it later. |\n| `request_too_large` | 413 | The body exceeds 10 MB, or 64 MB for a broadcast. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain. |\n| `stream_not_found` | 422 | `streamId` names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora paused the stream after complaints; support resumes it. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no list. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; this one is transactional. |\n| `substitution_missing` | 422 | The content names a `{{ key }}` a message does not give; `index` and `keys` say which. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled; nothing is left to cancel. |\n| `unsubscribe_placeholder_missing` | 422 | A broadcast message lacks `{{ unsubscribe_url }}` in a part. |\n| `list_unsubscribe_reserved` | 422 | Sendora writes List-Unsubscribe on broadcast streams; leave it out. |\n| `recipient_suppressed` | 422 | Recipients on the suppression list; `suppressed` lists them. |\n| `test_address_on_live_server` | 422 | Addresses at simulator.sendora.se are for test servers; `addresses` lists them. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `mode_immutable` | 400 | A server's mode is fixed at creation; create a new server for the other mode. |\n| `idempotency_key_mismatch` | 422 | The key was used before with a different body. |\n| `content_expired` | 410 | The received message's content window has passed; the message itself stays. |\n| `content_unreadable` | 409 | The received message's stored content cannot be opened; Sendora has been told. |\n| `rate_limited` | 429 | Per-minute limit; `retryAfter`, `scope`, `limit`. From the edge, too many requests at once. |\n| `monthly_cap_reached` | 429 | The monthly cap is used up; `scope`, `cap`, `used`, `resetsAt`. |\n| `sending_disabled` | 503 | Sending is paused for everyone; `retryAfter`. |\n| `connection_failed` | none | The request never reached the API; `cause` holds the failure. |\n| `timeout` | none | No answer within `timeoutMs`. |\n| `unexpected_response` | any | An answer without a JSON error body, such as from a proxy, or a signed webhook body that is no event. |\n| `invalid_signature` | none | A webhook's signature is missing, malformed or wrong. |\n| `stale_signature` | none | A webhook's signature is older than the tolerance. |\n\n`error.retryable` is true when waiting and calling again could succeed.\nNeither the key nor a request body is ever part of an error.\n\n## Retries\n\nLost connections, timeouts, `rate_limited` (the edge's own included),\n`sending_disabled` and server errors are retried with jittered backoff,\ntwice by default, honouring `retryAfter` when it fits within five seconds\nof waiting in all. A longer `retryAfter` is thrown at once for you to\nschedule. A request that creates something (`streams.create`,\n`tokens.create`, `webhooks.create`, `webhooks.createSecret`,\n`webhooks.replay`, `inboundDomains.create`, `servers.create`,\n`servers.tokens.create`, `domains.create`, `trackingDomains.create`) is repeated\nonly after a `rate_limited` or `sending_disabled` answer, never after a\nlost connection or a server error, so nothing is created twice. Nothing\nelse is retried. `new Sendora({ maxRetries: 0 })` turns retries off;\n`timeoutMs` sets the limit per attempt.\n\n## For coding agents\n\nThe package ships `skills/sendora/SKILL.md`, a short guide in the agent\nskill format with the rules above; point your agent at it or at this\nREADME. Every exported symbol carries JSDoc with an example.\n\n## Docs\n\nGuides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).\n"},{"uri":"sendora://sdk/skill","title":"Rules for agents using the TypeScript SDK","description":"The skill file @sendora/sdk ships for coding agents.","text":"---\nname: sendora\ndescription: Send transactional email through Sendora with @sendora/sdk, read the message log, manage streams, suppressions, keys, webhooks, servers and sending domains, and receive signed webhooks. Use when a task mentions Sendora, @sendora/sdk, or sending email from a system hosted in Sweden.\n---\n\n# Sendora\n\n`@sendora/sdk` is the official TypeScript client for the Sendora API. It\nhas zero dependencies, runs on Node 20.19 or newer and on any runtime with\n`fetch` and WebCrypto, and is ESM only. The README in this package is the\ncomplete reference; every call, every error code and every event is in it.\n\n## Rules\n\n- Two kinds of key, two clients. A server key (`sk_…`) drives\n `new Sendora({ token })`: `email`, `broadcasts`, `messages`, `inbound`,\n `inboundDomains`, `server`, `streams`, `suppressions`, `trackingOptOuts`, `tokens`,\n `webhooks`, everything inside one server. An account key (`ak_…`) drives `new SendoraAccount({ token })`:\n `servers`, `servers.tokens`, `domains` and `trackingDomains`, and it never sends. A key of\n the wrong kind throws `wrong_token_kind`.\n- Every key is a secret. Read it from the environment on the server and\n never send it to a browser. Either client throws when its key is\n missing, so construct it at startup.\n- A server is created with its first key: `account.servers.create({ name })`\n answers `token.token` once, and `new Sendora({ token })` sends with it. A\n server or the account holds at most two live keys, so rotate by creating\n the new key, switching to it, then revoking the old one; the last live\n key can never be revoked.\n- For tests and CI, use a test server: `account.servers.create({ name,\nmode: 'test' })`. It accepts, logs and fires webhooks like a live one\n but delivers nothing; its keys start `sk_test_`, and `isTestKey(token)`\n tells them apart at startup. Addresses at `simulator.sendora.se`\n (`hardbounce@`, `softbounce@`, `deferred@`, `complaint@`) produce those\n outcomes on a test server and are refused on a live one\n (`test_address_on_live_server`). A server's mode never changes.\n- Every method takes what the route takes and answers what the route\n answers. Do not build request bodies by hand or call `fetch` yourself.\n- Do not add idempotency keys or retry loops of your own. The SDK gives\n every send a key and repeats a call only when repeating is safe. Pass\n `idempotencyKey` only when a retry may come from another process.\n- Every failed call throws `SendoraError`. Catch it, narrow on\n `error.code`, and let anything else propagate. Never swallow it.\n- A batch never throws for one bad message: read `results[i].status`.\n- Delivery is asynchronous. `email.send` answers `accepted`; whether the\n receiver took the message is in `messages.get(messageId).recipients`\n and in the `delivered`, `bounced`, `deferred` and `spam_complaint`\n webhook events.\n- Verify a webhook before parsing it. With a Fetch `Request`, call\n `sendora.webhooks.receive(request, secret)`; with Express or Fastify,\n call `verifyWebhook` with the secret, the header and the raw body.\n Narrow on `event.event`, and handle `unknown`, an event newer than the\n release, whose payload is in `event.data`. Answer 2xx quickly and do the\n work afterwards. Deliveries arrive at least once, so key handling on\n `event.id`.\n- Addresses on the suppression list are refused with\n `recipient_suppressed`; lift a hard bounce with\n `suppressions.delete({ address })`; a spam complaint or an unsubscribe cannot\n be lifted by the customer. Lists are per stream: pass `streamId` when the send went on\n a stream other than the default.\n- `trackingOptOuts` is the server's list of addresses that are never\n tracked. Remove an entry whose `source` is `recipient` only after new\n consent. An erased entry has `address: null`, and only its address removes it.\n- `from` must be on a verified sending domain of the account;\n `account.domains.create` answers the two DNS records to add, and\n `account.domains.verify` checks them now. A server key cannot manage\n domains, but `server.get()` names those a From may use (`sendingDomains`). A verified domain whose record goes missing keeps sending for\n 24 hours (`failingSince`); a `dns_error` changes nothing.\n- A person who asks to be forgotten is erased with the account key:\n `account.erasures.create({ address })` answers a pending erasure, and\n `account.erasures.get(erasureId)` shows it `done` within a minute. It\n deletes their messages' content and puts a pseudonym in the log on every\n server; it blocks nothing.\n- A message goes on the server's default transactional stream unless it\n names another with `streamId`; `streams.create` adds one. Never make a\n stream per message or per customer; a stream is a kind of mail.\n- Mail to many goes as one broadcast: `broadcasts.send({ streamId, from,\nsubject, text, messages })` with one entry per recipient, on a\n broadcast stream, at most 50,000 entries; follow it with\n `broadcasts.get(broadcastId)` and stop it with `broadcasts.cancel`.\n Personalise with `{{ key }}` in the content and `substitutions` on each\n entry. Never loop over `email.send` for a list.\n- A message on a broadcast stream must carry `{{ unsubscribe_url }}` in\n its text and its HTML; Sendora puts each recipient's own link there and\n writes the List-Unsubscribe headers itself. An `unsubscribed` event\n tells your system to stop; the address is then on that stream's list.\n\n## The shape of a call\n\n```ts\nimport { Sendora, SendoraError } from '@sendora/sdk';\n\nconst sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });\n\ntry {\n const { messageId } = await sendora.email.send({\n from: { email: 'no-reply@example.se', name: 'Example AB' },\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n tag: 'invoice',\n });\n} catch (error) {\n if (error instanceof SendoraError && error.code === 'recipient_suppressed') {\n // error.suppressed lists the addresses and why\n }\n throw error;\n}\n```\n\n## Where to read more\n\n- The README of this package: every method, every error code, retries,\n webhooks and events.\n- The guides and the HTTP reference at https://sendora.se/docs.\n"},{"uri":"sendora://sdk/python/readme","title":"The Python SDK","description":"sendora's README: every call, error code and webhook event.","text":"# sendora\n\nThe Python client for [Sendora](https://sendora.se), transactional email\ndelivery. Python 3.11 or newer, one dependency (httpx2), typed throughout:\nevery request is a `TypedDict` your editor completes and every answer a\nfrozen dataclass.\n\n```sh\npip install sendora\n```\n\nTwo kinds of key, two clients. `Sendora` takes a server key (`sk_…`) and\ndoes everything inside one server: sending, broadcasts, the log, received\nmail, streams, suppressions, the not-tracked list, tokens, webhooks and the server's own limits.\n`SendoraAccount` takes an account key (`ak_…`) and manages the account:\nits servers, their keys, its sending and tracking domains. `AsyncSendora` and\n`AsyncSendoraAccount` are the same clients for asyncio. Both keys are\nsecrets: keep them on the server and never ship them to a browser.\n\n```python\nfrom pathlib import Path\n\nfrom sendora import Sendora\n\nsendora = Sendora() # the key from SENDORA_API_TOKEN\n\naccepted = sendora.email.send(\n from_={\"email\": \"no-reply@example.se\", \"name\": \"Example AB\"},\n to=[\"anna@example.com\"],\n subject=\"Din faktura för september\",\n text=\"Hej Anna, fakturan finns bifogad.\",\n attachments=[\n {\n \"name\": \"faktura.pdf\",\n \"content\": Path(\"faktura.pdf\").read_bytes(),\n \"content_type\": \"application/pdf\",\n }\n ],\n tag=\"invoice\",\n metadata={\"invoice_id\": \"2026-0912\"},\n)\nprint(accepted.message_id)\n```\n\nA client without a key raises `ValueError` at construction, so build it\nat startup; `Sendora(\"sk_…\")` takes the key directly and\n`SendoraAccount()` reads `SENDORA_ACCOUNT_TOKEN`. Make one client per\nprocess and share it: it is safe across threads and reuses its\nconnections. `with Sendora() as sendora:` closes them at the end, as\n`sendora.close()` does.\n\n`from_` must be on a verified sending domain; it ends in an underscore\nbecause `from` is a Python keyword. `to`, `cc` and `bcc` take plain\naddresses or `{\"email\", \"name\"}` dictionaries, at most 50 in all; at least\none of `text` and `html` is required. `stream_id` names the stream the\nmessage goes on; without it the default transactional stream. Attachments\ntake bytes or a base64 string. Every method takes what its route takes,\nwith snake_case names, and answers what the route answers; your own keys,\nin `metadata`, `headers` and `substitutions`, are sent as you wrote them.\n\n## Sending\n\n| Call | Answers |\n| ------------------------------------ | ---------------------------------------------------------------------- |\n| `sendora.email.send(**message)` | `AcceptedEmail` with `message_id`, `status`, `submitted_at` and `test` |\n| `sendora.email.send_batch(messages)` | `BatchResult` with `results`, one per message in order |\n\n`accepted` means the message is stored and on its way to the mail server;\ndelivery, deferral, bounce and complaint arrive later as events on the\nmessage and as webhooks.\n\n`idempotency_key=` names the request so a retry cannot send twice. The\nSDK makes a random UUID when you give none; give your own when a retry\nmay come from another process. The API remembers a key for 24 hours and\nrefuses it with a different body (`idempotency_key_mismatch`). An error\nfrom a send carries the key it went under as `error.idempotency_key`.\n\nA batch never raises for one refused message. Each result is an\n`AcceptedBatchItem` with the id, or a `RefusedBatchItem` with the same\n`code` a single send would raise; a retried batch answers the items sent\nbefore with `replayed=True`.\n\n```python\nfrom sendora.types import RefusedBatchItem\n\nbatch = sendora.email.send_batch(messages, idempotency_key=\"invoices-2026-09\")\nfor result in batch.results:\n if isinstance(result, RefusedBatchItem):\n print(result.index, result.code, result.message)\n```\n\n## Messages\n\n| Call | Answers |\n| --------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| `sendora.messages.get(message_id)` | The message with its recipients, `tracking`, its attachments described and its events |\n| `sendora.messages.search(**filters)` | `MessagePage` with `messages` and `next`, newest first |\n| `sendora.messages.search_all(**filters)` | Every match, page by page, as an iterator |\n| `sendora.inbound.get(inbound_message_id)` | A received message: envelope, parties, headers, text, HTML, attachments described |\n| `sendora.inbound.search(**filters)` | `InboundPage` of received mail, newest first |\n| `sendora.inbound.search_all(**filters)` | Every received match, page by page, as an iterator |\n| `sendora.inbound.raw(inbound_message_id)` | The raw message as `bytes`, `message/rfc822` |\n| `sendora.inbound.attachment(inbound_message_id, attachment_id)` | An attachment's `bytes` |\n\nA stream can also receive on a domain of yours: every address on it,\nwhatever the local part, lands on the stream. Claim it with the stream\nid, add the two records the answer gives, an MX that names Sendora and a\nTXT record that proves the claim, and mail is accepted once both are\nseen. A stream holds one domain, and the first account to verify a name\nholds it.\n\n| Call | Answers |\n| ----------------------------------------------------- | ---------------------------------------------------------------------------- |\n| `sendora.inbound_domains.create(stream_id=, domain=)` | The domain with `mx` and `txt` to add |\n| `sendora.inbound_domains.list()` | `InboundDomainList` with `domains` |\n| `sendora.inbound_domains.get(inbound_domain_id)` | One domain with the state of its records |\n| `sendora.inbound_domains.verify(inbound_domain_id)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `sendora.inbound_domains.delete(inbound_domain_id)` | `None`; mail to it is refused from then on |\n\nThe filters of `messages.search` are `recipient`, a whole address or\nthree characters or more of one, `stream_id`, `broadcast_id`, `tag`,\n`status` (`queued`, `delivered`, `deferred`, `bounced`, `expired`, or a\nlist of them), `from_` and `to` as an aware `datetime` or ISO 8601,\n`limit` (1 to 100, 50 by default) and `after`, the previous page's\n`next`. Bodies are never returned; the log keeps messages for 13 months.\n\nWhere mail is tracked, each recipient has `tracked`, `opens`, `clicks`,\n`first_open_at` and `first_human_open_at`. `messages.get` adds `tracking`, and\nits timeline holds `Open` and `Click` events with their labels. Tracking is\nnot open to accounts yet.\n\n```python\nfrom datetime import UTC, datetime\n\nmessage = sendora.messages.get(message_id)\ndelivered = all(recipient.status == \"delivered\" for recipient in message.recipients)\n\nfor bounced in sendora.messages.search_all(\n status=\"bounced\", from_=datetime(2026, 9, 1, tzinfo=UTC)\n):\n print(bounced.message_id, bounced.subject)\n```\n\n## Streams\n\nEvery message goes on a stream of its server. A server starts with a\ndefault transactional stream, and the account adds more: transactional\nand broadcast ones, and one inbound stream, which receives mail at its\n`inbound_address` instead of sending. Each sending stream has a\nsuppression list of its own.\n\n| Call | Answers |\n| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| `sendora.streams.create(kind=, name=)` | The stream; a taken name is `stream_exists`, a second inbound stream `inbound_stream_exists`, both with `existing_id` |\n| `sendora.streams.list()` | `StreamList` with `streams`, archived ones included |\n| `sendora.streams.get(stream_id)` | One stream |\n| `sendora.streams.update(stream_id, name=, content_retention_days=)` | The updated stream; the days apply to an inbound stream |\n| `sendora.streams.archive(stream_id)` | The stream with `archived_at`; it takes no new messages from then on |\n\n```python\nstream = sendora.streams.create(kind=\"transactional\", name=\"Aviseringar\")\nsendora.email.send(\n from_=\"no-reply@example.se\",\n to=[\"anna@example.com\"],\n subject=\"Leveransen är på väg\",\n text=\"Hej Anna, paketet lämnade lagret i dag.\",\n stream_id=stream.stream_id,\n)\n```\n\n## Broadcasts\n\nOne message to many on a broadcast stream, sent as a whole: the content\nonce and one entry per message, up to 50,000 and 50 MB per broadcast.\nEvery message is a message in the log with its own events and webhooks,\ncarrying the `broadcast_id`, and every recipient gets their own\nunsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the\nstream's suppression list are dropped and counted in `suppressed`. The\nSDK sets an idempotency key, so a retry answers the same broadcast. A\nmessage's `substitutions`, up to 20 strings, replace `{{ key }}` in the\nsubject, the text and the HTML, escaped in the HTML; a key the content\nnames that a message lacks raises `substitution_missing`.\n\n| Call | Answers |\n| ----------------------------------------- | ---------------------------------------------------------------------------------- |\n| `sendora.broadcasts.send(**broadcast)` | `AcceptedBroadcast` with `broadcast_id`, `total`, `suppressed` and `test` |\n| `sendora.broadcasts.get(broadcast_id)` | The broadcast with `status`, `released`, `failed`, `opened`, `clicked` and `links` |\n| `sendora.broadcasts.list(limit=, after=)` | `BroadcastPage` with `broadcasts` and `next`, newest first |\n| `sendora.broadcasts.cancel(broadcast_id)` | The cancelled broadcast; what had not left counts as failed |\n\n```python\naccepted = sendora.broadcasts.send(\n stream_id=\"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n from_={\"email\": \"nyheter@example.se\", \"name\": \"Example AB\"},\n subject=\"Nyheter i oktober, {{ first_name }}\",\n text=\"Hej! Här är månadens nyheter. Vill du inte ha fler? {{ unsubscribe_url }}\",\n messages=[\n {\"to\": [\"anna@example.com\"], \"substitutions\": {\"first_name\": \"Anna\"}},\n {\"to\": [\"bo@example.com\"], \"substitutions\": {\"first_name\": \"Bo\"}},\n ],\n)\nprogress = sendora.broadcasts.get(accepted.broadcast_id)\n```\n\n## Suppressions\n\nAddresses the server no longer sends to: hard bounces, spam complaints,\nunsubscribes and entries added by hand. A send to one of them raises\n`recipient_suppressed`.\n\n| Call | Answers |\n| ------------------------------------------------------- | ------------------------------------------------ |\n| `sendora.suppressions.list(stream_id=, limit=, after=)` | `SuppressionPage` with `suppressions` and `next` |\n| `sendora.suppressions.list_all(stream_id=)` | Every entry, as an iterator |\n| `sendora.suppressions.delete(address=, stream_id=)` | `None`; the address may be sent to again |\n\nEvery stream has a list of its own; without `stream_id` the calls mean\nthe default transactional stream. A spam complaint or a recipient's own\nunsubscribe cannot be lifted this way (`spam_complaint_locked`,\n`unsubscribe_locked`); only Sendora support can, at the recipient's own\nrequest. A refused send carries `stream_id` beside `suppressed`.\n\n```python\nsendora.suppressions.delete(address=\"anna@example.com\")\n```\n\n## The not-tracked list\n\nAddresses the server never tracks, on any of its streams. Mail to one still\ngoes out, untracked, and its opens and clicks of mail already sent are not\nrecorded. Tracking is not open to accounts yet, so the list changes nothing\nuntil it is.\n\n| Call | Answers |\n| ------------------------------------------------ | -------------------------------------------------------- |\n| `sendora.tracking_opt_outs.list(limit=, after=)` | `TrackingOptOutPage` with `tracking_opt_outs` and `next` |\n| `sendora.tracking_opt_outs.list_all()` | Every entry, as an iterator |\n| `sendora.tracking_opt_outs.add(address=)` | `TrackingOptOut`, as it stands if the address was listed |\n| `sendora.tracking_opt_outs.delete(address=)` | `None`; `not_found` if the address is not listed |\n\nAn entry's `source` is `manual`, or `recipient` if the recipient asked for it.\nRemove a recipient's own entry only after new consent. An entry's `address` is\n`None` once an erasure has left only a keyed hash of it. `delete` still finds\nthat entry by its address, and nothing removes it without the address.\n\n```python\nsendora.tracking_opt_outs.add(address=\"anna@example.com\")\n```\n\n## Tokens\n\nThe server's own keys, from its own key. Every key of a server has the\nsame rights; the value is shown once, when it is created; a server holds\nup to two live keys, and a third is `token_limit`. The account key\nmanages the same keys under `account.servers.tokens`.\n\n| Call | Answers |\n| --------------------------------- | --------------------------------------------------- |\n| `sendora.tokens.create(name=)` | `CreatedToken` with its `token` value, once |\n| `sendora.tokens.list()` | `TokenList` with `tokens`, revoked ones included |\n| `sendora.tokens.get(token_id)` | One token, without its value |\n| `sendora.tokens.revoke(token_id)` | `None`; the last live key is refused (`last_token`) |\n\nA key's value is kept out of the answer's `repr`, so a logged answer does\nnot print it; read it from `.token`.\n\n## The server\n\nThe key's own server: its name and id, whether it is live or a test\nserver, every limit a send on it is held to with how much of it this\nminute and this month have used, and the account's sending domains a From\naddress may use.\n\n`inbound_cap` is the account's monthly cap on received mail, apart from the\nlimits on sending, with its `limit`, `used` and `resets_at`. Past it,\nmail to the account is refused for now, so senders retry. It is `None` on\na test server, which receives no mail.\n\n| Call | Answers |\n| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |\n| `sendora.server.get()` | `OwnServer` with `mode`, `limits`, each with `used` and `resets_at`, `inbound_cap`, and `sending_domains` to send from |\n\n```python\nown = sendora.server.get()\nfor limit in own.limits:\n print(limit.scope, limit.period, limit.used, \"of\", limit.limit)\nprint([sending.domain for sending in own.sending_domains])\n```\n\n## Webhooks\n\nAn https URL of yours that Sendora posts events to as they happen, signed\nwith a secret shown once at creation. A webhook holds up to two live\nsecrets, so a new one can be rolled in beside the old one: every delivery\nis then signed with each, the newest first, and the old secret is deleted\nonce your receiver has switched.\n\n| Call | Answers |\n| ---------------------------------------------------------------------- | --------------------------------------------- |\n| `sendora.webhooks.create(url=, events=, stream_id=, inbound_content=)` | `CreatedWebhook` with its `secret`, once |\n| `sendora.webhooks.list()` | `WebhookList` with `webhooks` |\n| `sendora.webhooks.get(webhook_id)` | One webhook |\n| `sendora.webhooks.delete(webhook_id)` | `None`; pending deliveries are dropped |\n| `sendora.webhooks.deliveries(webhook_id, status=)` | `DeliveryPage` with `deliveries` and `next` |\n| `sendora.webhooks.deliveries_all(webhook_id, status=)` | Every delivery, as an iterator |\n| `sendora.webhooks.replay(webhook_id, delivery_id)` | The delivery, queued again |\n| `sendora.webhooks.create_secret(webhook_id)` | A second secret with its `secret` value, once |\n| `sendora.webhooks.delete_secret(webhook_id, secret_id)` | `None`; the last live secret is refused |\n\nEvents: `delivered`, `bounced`, `deferred`, `spam_complaint` and\n`unsubscribed` about a message, with `message_id`, `stream_id`,\n`recipient`, `occurred_at`, `server_id`, your `tag` and `metadata`, and\n`details` from the receiver, or for `unsubscribed` the `source` (`link` or\n`one_click`); `opened` and `clicked` about a tracked copy, sent only to a\nwebhook that names them, with the same fields and `details` that say whether\na machine did it and, for `clicked`, give the `link`; `cap_warning` and `cap_reached` about usage, with `scope`,\n`cap`, `used` and the period; `inbound` about a message received on an\ninbound stream, with `inbound_message_id`, `envelope_recipient`, the\nsizes, the `authentication` verdicts and, for a webhook with\n`inbound_content=\"full\"`, the message itself under `content`: `from_`,\n`to`, `subject`, `text`, `html`, `attachments` and the rest.\n`inbound_cap_warning` and `inbound_cap_reached` say that received mail\nnears or reaches the account's monthly cap, with `cap`, `used` and the\nperiod. Every event carries `id`, the delivery id, and `attempt`. Tracking is\nnot open to accounts yet, so `opened` and `clicked` are not sent until it is.\n\nA failed delivery is retried with growing delays for about a day, then it\nis `dead` until you replay it. Deliveries arrive at least once and not\nalways in order, so key your handling on `event.id`.\n\n### Receiving\n\nVerify the signature before you parse anything, answer 2xx quickly, and\ndo the work afterwards. `verify_webhook` takes the raw body exactly as it\narrived and the `Sendora-Signature` header, and answers the event as a\nclass of its own. The raw body is `request.get_data()` in Flask and\n`await request.body()` in FastAPI and Starlette. In Django, read it with\n`request.read()`, since `request.body` refuses a body over\n`DATA_UPLOAD_MAX_MEMORY_SIZE`, 2.5 MB by default, and an `inbound` event\nwith its content can be larger. In Flask, pass `dict(request.headers)` to\n`receive` below, since type checkers do not take Werkzeug's headers as a\n`Mapping`.\n\n```python\nimport os\nfrom collections.abc import Mapping\n\nfrom sendora import (\n BouncedEvent,\n InboundEvent,\n UnknownEvent,\n WebhookVerificationError,\n verify_webhook,\n)\n\n\ndef receive(body: bytes, headers: Mapping[str, str]) -> int:\n # Header names ignore case, and a plain dict does not.\n lowered = {name.lower(): value for name, value in headers.items()}\n try:\n event = verify_webhook(\n body,\n signature=lowered.get(\"sendora-signature\"),\n secret=os.environ[\"SENDORA_WEBHOOK_SECRET\"],\n )\n except WebhookVerificationError:\n return 400\n match event:\n case BouncedEvent(details=details) if details.hard:\n print(f\"{event.recipient} is undeliverable: {details.classification}\")\n case InboundEvent(content=content) if content is not None:\n print(content.subject, [part.name for part in content.attachments])\n case UnknownEvent():\n print(\"an event newer than this release:\", event.event, event.data)\n case _:\n pass\n return 204\n```\n\nTell the events apart with `isinstance` or a class pattern, as above;\ncomparing `event.event` with a string does not narrow the type. A signed\nevent this release cannot read, one newer than it, is an `UnknownEvent`\nwith its payload as it came rather than an error, so your endpoint does\nnot fail it until it is dead; upgrade to read it as its class.\n\nThe signature is `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`,\none `v1` per live secret of the webhook, each an HMAC-SHA256 with that\nsecret over `<t>.<raw body>`, compared in constant time; the one your\nsecret produces is enough. To roll a secret, create the new one with\n`webhooks.create_secret`, move your receiver to it while both sign every\ndelivery, then delete the old one. A signature older than five minutes is\nrefused (`stale_signature`; `tolerance=` changes the limit), a wrong one\nraises `invalid_signature`, and a missing secret raises `ValueError`.\n\n## The account: servers, keys, domains and erasures\n\nAn account key (`ak_…`) manages the account and never sends. An\nadministrator creates one in the dashboard under Account › API keys; the\naccount holds up to two live ones. `SendoraAccount` takes the same\narguments as `Sendora`.\n\n```python\nfrom sendora import Sendora, SendoraAccount\n\naccount = SendoraAccount() # the key from SENDORA_ACCOUNT_TOKEN\n\nserver = account.servers.create(name=\"Fakturering\")\nsendora = Sendora(server.token.token)\n```\n\nA server is created with its default transactional stream and its first\nkey, whose value is in the answer this once. A server holds up to two\nlive keys and never falls below one, so rotation is create the new key,\nswitch to it, revoke the old one.\n\n| Call | Answers |\n| ---------------------------------------------------- | ------------------------------------------------------------------------------------ |\n| `account.servers.create(name=, mode=)` | `CreatedServer` with its first key as `token`, once; a taken name is `server_exists` |\n| `account.servers.list()` | `ServerList` with `servers`, oldest first |\n| `account.servers.get(server_id)` | One server |\n| `account.servers.update(server_id, name=)` | The renamed server |\n| `account.servers.delete(server_id)` | `None`; `server_in_flight` while its messages are still being delivered |\n| `account.servers.tokens.create(server_id, name=)` | `CreatedToken` with its `token` value, once; a third live key is `token_limit` |\n| `account.servers.tokens.list(server_id)` | `TokenList` with `tokens`, revoked ones included |\n| `account.servers.tokens.get(server_id, token_id)` | One key, without its value |\n| `account.servers.tokens.revoke(server_id, token_id)` | `None`; the last live key is refused (`last_token`) |\n\nSending domains belong to the account, and every server sends from them.\nA domain sends once both DNS records are seen. A lookup that fails at the\nresolver (`dns_error`) changes nothing, and a verified domain whose record\ngoes missing keeps sending for 24 hours while the account's administrators\nare told, with `failing_since` saying when that began.\n\n| Call | Answers |\n| ----------------------------------- | ---------------------------------------------------------------------------- |\n| `account.domains.create(domain=)` | `SendingDomain` with `return_path` (CNAME) and `dkim` (TXT) to add |\n| `account.domains.list()` | `DomainList` with `domains` |\n| `account.domains.get(domain_id)` | One domain with the state of its records |\n| `account.domains.verify(domain_id)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `account.domains.delete(domain_id)` | `None`; mail from it is refused from then on |\n\n```python\ndomain = account.domains.create(domain=\"example.se\")\nprint(domain.return_path.type, domain.return_path.host, domain.return_path.value)\nprint(domain.dkim.type, domain.dkim.host, domain.dkim.value)\n```\n\nA tracking domain is the host that tracked links and the open pixel use: a\nsubdomain of a verified sending domain, with one CNAME record to add. CAA\nrecords above it must let Let's Encrypt issue its certificate. Tracking is\nnot open to accounts yet, so a host carries no mail until it is. A host\nwhose records are not right within 7 days goes.\n\n| Call | Answers |\n| ----------------------------------------------------- | --------------------------------------------------------------------- |\n| `account.tracking_domains.create(host=)` | `TrackingDomain` with `cname` to add |\n| `account.tracking_domains.list()` | `TrackingDomainList` with `tracking_domains` |\n| `account.tracking_domains.get(tracking_domain_id)` | One tracking domain with what the last check found |\n| `account.tracking_domains.verify(tracking_domain_id)` | The tracking domain as it stands: `state`, `cname.check`, `caa.check` |\n| `account.tracking_domains.delete(tracking_domain_id)` | `None`; new mail stops using it at once |\n\n```python\ntracking = account.tracking_domains.create(host=\"links.example.se\")\nprint(tracking.cname.type, tracking.cname.host, tracking.cname.value)\n```\n\nAn erasure removes one person's data from every server of the account,\nas a request under the right to erasure asks. The content of every\nmessage to or from the address, sent and received, is deleted, and the\naddress becomes a pseudonym in the message log. The erasure answers\n`pending`, and Sendora carries it out within a minute. Erasing an address\nagain changes nothing more, and later mail to or from it is sent and\nreceived as before.\n\n| Call | Answers |\n| ----------------------------------- | ------------------------------------------------------------------------------------------ |\n| `account.erasures.create(address=)` | `Erasure`, `pending` |\n| `account.erasures.get(erasure_id)` | The erasure; once `done`, its `pseudonym`, `messages` and `received_messages`, for 30 days |\n\n```python\nerasure = account.erasures.create(address=\"anna@example.com\")\nprint(account.erasures.get(erasure.erasure_id).status)\n```\n\nA key of the wrong kind raises `wrong_token_kind`: a server key on the\naccount's routes, or an account key on a server's.\n\n## Errors\n\nEvery failed call raises a `SendoraError`, whose class says what happened\nand whose `code` says why:\n\n| Class | When |\n| -------------------------- | ---------------------------------------------------------- |\n| `APIConnectionError` | No answer: `connection_failed` |\n| `APITimeoutError` | A step took longer than `timeout`: `timeout` |\n| `BadRequestError` | 400 |\n| `AuthenticationError` | 401 |\n| `PermissionDeniedError` | 403 |\n| `NotFoundError` | 404 |\n| `ConflictError` | 409 |\n| `UnprocessableEntityError` | 422 |\n| `RateLimitError` | 429, `rate_limited` and `monthly_cap_reached` alike |\n| `InternalServerError` | 500 or more |\n| `APIStatusError` | Any other status, and the base of the classes for a status |\n| `ResponseValidationError` | An answer the SDK could not read; the call took effect |\n| `WebhookVerificationError` | `verify_webhook`: `invalid_signature` or `stale_signature` |\n\nA path id that is empty, `.` or `..` raises `ValueError` before any\nrequest, since the URL would reach another route.\n\n```python\nfrom sendora import RateLimitError, SendoraError\n\ntry:\n sendora.email.send(**message)\nexcept RateLimitError as error:\n print(\"try again in\", error.retry_after, \"seconds\")\nexcept SendoraError as error:\n if error.code != \"recipient_suppressed\":\n raise\n print([(entry.address, entry.reason) for entry in error.suppressed])\n```\n\n| Code | Status | Meaning |\n| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------- |\n| `invalid_request` | 400 | A field is wrong; `issues` says which. |\n| `idempotency_key_required` | 400 | A batch was sent without a key. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `wrong_token_kind` | 403 | A server key where an account key is needed, or the reverse; `message` names the kind. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `account_paused` | 403 | Sendora has paused the account. |\n| `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |\n| `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |\n| `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |\n| `not_found` | 404 | No such id on this server, or no such suppressed or not-tracked address. |\n| `domain_exists` | 409 | The account already has the domain; `existing_id` names it. |\n| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |\n| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existing_id` names it. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself; choose a subdomain. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |\n| `tracking_host_taken` | 409 | Another account holds the host. |\n| `inbound_domain_exists` | 409 | The stream already has a domain (`existing_id` names it), or another account holds the name verified. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL; `existing_id` names it. |\n| `stream_exists` | 409 | The server already has a stream with that name; `existing_id` names it. |\n| `inbound_stream_exists` | 409 | The server already has a live inbound stream; `existing_id` names it. |\n| `default_stream` | 409 | The default stream cannot be archived. |\n| `last_token` | 409 | The only live key cannot be revoked. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted. |\n| `secret_limit` | 409 | The webhook already holds two live secrets; `max` says how many. |\n| `token_limit` | 409 | The server or the account already holds two live keys; `max` says how many. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_limit` | 409 | The account has as many servers as it may; `max` says how many. |\n| `server_reserved` | 409 | The server sends Sendora's own sign-in mail and cannot be renamed or removed. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; delete it later. |\n| `request_too_large` | 413 | The body exceeds 10 MB, or 64 MB for a broadcast. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain. |\n| `stream_not_found` | 422 | `stream_id` names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora paused the stream after complaints; support resumes it. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no list. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; this one is transactional. |\n| `substitution_missing` | 422 | The content names a `{{ key }}` a message does not give; `index` and `keys` say which. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled; nothing is left to cancel. |\n| `unsubscribe_placeholder_missing` | 422 | A broadcast message lacks `{{ unsubscribe_url }}` in a part. |\n| `list_unsubscribe_reserved` | 422 | Sendora writes List-Unsubscribe on broadcast streams; leave it out. |\n| `recipient_suppressed` | 422 | Recipients on the suppression list; `suppressed` lists them. |\n| `test_address_on_live_server` | 422 | Addresses at simulator.sendora.se are for test servers; `addresses` lists them. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `mode_immutable` | 400 | A server's mode is fixed at creation; create a new server for the other mode. |\n| `idempotency_key_mismatch` | 422 | The key was used before with a different body. |\n| `content_expired` | 410 | The received message's content window has passed; the message itself stays. |\n| `content_unreadable` | 409 | The received message's stored content cannot be opened; Sendora has been told. |\n| `rate_limited` | 429 | Per-minute limit; `retry_after`, `scope`, `limit`. From the edge, too many requests at once. |\n| `monthly_cap_reached` | 429 | The monthly cap is used up; `scope`, `cap`, `used`, `resets_at`. |\n| `sending_disabled` | 503 | Sending is paused for everyone; `retry_after`. |\n| `connection_failed` | none | The request did not reach the API, or its answer was lost; `__cause__` says why. |\n| `timeout` | none | A step of the request took longer than `timeout`. |\n| `unexpected_response` | any | An answer without a JSON error body, such as from a proxy. |\n| `invalid_signature` | none | A webhook's signature is missing, malformed or wrong. |\n| `stale_signature` | none | A webhook's signature is older than the tolerance. |\n\n`error.retryable` is true when waiting and calling again could succeed.\nNeither the key nor a request's subject, text, HTML or attachments is ever\npart of an error, its message, its `repr` or its pickled form, and an\nerror survives `pickle`, so it crosses a process pool as its own class. A\ncode newer than this release comes through as its string, its class\nchosen by the status.\n\n## New values and fields\n\nThe API may add a value to a set the SDK knows, such as a recipient\nstatus, before your release does. The SDK hands it over as its string\nrather than failing the call, so a `match` or an `if` chain over such a\nfield keeps a `case _:` or an `else`, and never `assert_never`. A field\nnewer than your release is not an attribute yet, but every answer keeps\nwhat the API sent: `answer.to_dict()` answers it as it came, with the\nAPI's camelCase keys, and `answer.to_json()` its text.\n\n## Retries and timeouts\n\nLost connections, timeouts, `rate_limited` (the edge's own included),\n`sending_disabled` and server errors are retried with jittered backoff,\ntwice by default, honouring `retry_after` when it fits within five\nseconds of waiting in all. A longer `retry_after` is raised at once for\nyou to schedule. A call that creates something (`streams.create`,\n`tokens.create`, `webhooks.create`, `webhooks.create_secret`,\n`webhooks.replay`, `inbound_domains.create`, `servers.create`,\n`servers.tokens.create`, `domains.create`, `tracking_domains.create`) is repeated only after a\n`rate_limited` or `sending_disabled` answer, never after a lost connection\nor a server error, so nothing is created twice. Nothing else is retried.\n\n`timeout` is in seconds, 30 by default, and bounds each step of a\nrequest: connecting, a read, a write and waiting for a connection.\n`max_retries=0` turns retries off. `with_options` answers a copy of the\nclient that shares its connections, for one call that may take longer or\na job that must not retry:\n\n```python\nfrom sendora import Sendora\n\nsendora = Sendora(timeout=10, max_retries=3)\npatient = sendora.with_options(timeout=120, max_retries=0)\n```\n\n## Async\n\n`AsyncSendora` and `AsyncSendoraAccount` have the same methods as\ncoroutines, and their iterators are async iterators. They run under\nasyncio, as FastAPI, Starlette and Django do; under another event loop,\nsuch as trio, the first call raises `TypeError`. Wrap a call in\n`asyncio.timeout()` to bound it as a whole; cancelling a task cancels its\nrequest.\n\n```python\nimport asyncio\n\nfrom sendora import AsyncSendora\n\n\nasync def main() -> None:\n async with AsyncSendora() as sendora:\n accepted = await sendora.email.send(\n from_=\"no-reply@example.se\",\n to=[\"anna@example.com\"],\n subject=\"Välkommen\",\n text=\"Hej Anna!\",\n tag=\"welcome\",\n )\n print(accepted.message_id)\n async for message in sendora.messages.search_all(tag=\"welcome\"):\n print(message.message_id, message.subject)\n\n\nasyncio.run(main())\n```\n\n## Your own HTTP client\n\nPass an `httpx2.Client` of your own, an `httpx2.AsyncClient` to the async\nclients, for a proxy, a private certificate authority or your own\nconnection limits. The SDK takes its connections, proxy and TLS settings\nand nothing else: the key, the timeout and the SDK's headers go on every\nrequest, redirects are never followed, and the SDK never closes a client\nyou gave it. Certificates are verified against the operating system's\nstore; a container without one needs its `ca-certificates` package or\n`SSL_CERT_FILE`.\n\n```python\nimport httpx2\n\nfrom sendora import Sendora\n\nhttp_client = httpx2.Client(proxy=\"http://proxy.example.se:3128\")\nsendora = Sendora(http_client=http_client)\n```\n\n## Testing your code\n\nFor tests that talk to Sendora, create a test server: it goes through\neverything a live one does, validation, the suppression list, the log and\nthe webhooks, but nothing it accepts is delivered. Its keys start\n`sk_test_`, and `is_test_key` tells them apart, so a production process\ncan refuse one at startup. Addresses at `simulator.sendora.se`\n(`hardbounce@`, `softbounce@`, `deferred@`, `complaint@`, with any\n`+label`) produce those outcomes; any other address is marked delivered.\nA server's mode is fixed when it is created, so going live means a new\nserver and a new key. Test mail is free and counts against its own\nmonthly cap, shared by the account's test servers.\n\n```python\nimport os\n\nfrom sendora import Sendora, SendoraAccount, is_test_key\n\naccount = SendoraAccount()\nci = account.servers.create(name=\"CI\", mode=\"test\")\n\ntoken = os.environ[\"SENDORA_API_TOKEN\"]\nif os.environ.get(\"APP_ENV\") == \"production\" and is_test_key(token):\n raise RuntimeError(\"SENDORA_API_TOKEN is a test key; nothing would be delivered\")\nsendora = Sendora(token)\n```\n\nFor unit tests that must not reach the network, give the client an\n`httpx2.Client` over a `MockTransport` that answers as the API would:\n\n```python\nimport json\n\nimport httpx2\n\nfrom sendora import Sendora\n\n\ndef answer(request: httpx2.Request) -> httpx2.Response:\n assert request.url.path == \"/v1/email\"\n assert json.loads(request.content)[\"to\"] == [\"anna@example.com\"]\n return httpx2.Response(\n 200,\n json={\n \"messageId\": \"0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-26T10:00:00.000Z\",\n \"test\": False,\n },\n )\n\n\nmock = httpx2.Client(transport=httpx2.MockTransport(answer))\nsendora = Sendora(\"sk_unit_test\", http_client=mock)\naccepted = sendora.email.send(\n from_=\"no-reply@example.se\", to=[\"anna@example.com\"], subject=\"Hej\", text=\"Hej\"\n)\nassert accepted.status == \"accepted\"\n```\n\n## Logging\n\nThe SDK logs one thing: each retry, at `DEBUG` on the `sendora` logger,\nwith the method, the path, the code and the wait. httpx2 logs one `INFO`\nline per request with its URL on `httpx2`, and httpcore2 logs response\nheaders at `DEBUG` on `httpcore2`, an attachment's filename among them, so\nset both to `WARNING` in production:\n\n```python\nimport logging\n\nlogging.getLogger(\"httpx2\").setLevel(logging.WARNING)\nlogging.getLogger(\"httpcore2\").setLevel(logging.WARNING)\n```\n\nAn error tracker that records the local variables of each frame, as\nSentry does by default, may record the contents of a request from\nhttpx2's and httpcore2's frames when a connection fails while it is being\nwritten. The SDK's own frames hold neither the key nor the message. Turn\nlocal variables off (`include_local_variables=False` in Sentry) if a\nmessage's contents must not reach the tracker.\n\n## Tracing\n\nThe client sends every request through the `httpx2.Client` you give it,\nso its event hooks see each attempt, retries included, and can time it:\n\n```python\nimport logging\nimport time\n\nimport httpx2\n\nfrom sendora import Sendora\n\nlog = logging.getLogger(\"app.sendora\")\n\n\ndef started(request: httpx2.Request) -> None:\n request.extensions[\"started\"] = time.monotonic()\n\n\ndef finished(response: httpx2.Response) -> None:\n request = response.request\n took = time.monotonic() - float(request.extensions[\"started\"])\n log.info(\n \"%s %s %s %.3fs\", request.method, request.url.path, response.status_code, took\n )\n\n\ntraced = httpx2.Client(event_hooks={\"request\": [started], \"response\": [finished]})\nsendora = Sendora(http_client=traced)\n```\n\nRecord the path and not the whole URL: a search's query carries the\nrecipient address you filtered on.\n\n## For coding agents\n\nThe package ships `sendora/skills/sendora/SKILL.md`, a short guide in the\nagent skill format with the rules above; point your agent at it or at\nthis README. Every public class and method carries a docstring with an\nexample, and the package is typed for your editor and your type checker.\n\n## Versions\n\nThe package follows semantic versioning. A breaking change comes only in\na major version: a renamed or removed method, argument or field, or a\nchanged error code. A minor adds methods, fields, codes or events, and a\npatch fixes a bug. `CHANGELOG.md` lists what each release changed, as\ndoes the Changelog page of the docs.\n\n## Docs\n\nGuides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).\n"},{"uri":"sendora://sdk/python/skill","title":"Rules for agents using the Python SDK","description":"The skill file sendora ships for coding agents.","text":"---\nname: sendora\ndescription: Send transactional email through Sendora with the sendora Python package, read the message log, manage streams, suppressions, keys, webhooks, servers and sending domains, and receive signed webhooks. Use when a task mentions Sendora, the sendora package, or sending email from a system hosted in Sweden.\n---\n\n# Sendora\n\n`sendora` is the Python client for the Sendora API. It runs on Python 3.11\nor newer with one dependency, httpx2, and is typed throughout. The README\nof this package is the complete reference; every call, every error code\nand every event is in it.\n\n## Rules\n\n- Two kinds of key, two clients. A server key (`sk_…`) drives\n `Sendora()`: `email`, `broadcasts`, `messages`, `inbound`,\n `inbound_domains`, `server`, `streams`, `suppressions`,\n `tracking_opt_outs`, `tokens`,\n `webhooks`, everything inside one server. An account key (`ak_…`)\n drives `SendoraAccount()`: `servers`, `servers.tokens`, `domains` and\n `tracking_domains`, and it never sends. A key of the wrong kind raises `wrong_token_kind`.\n `AsyncSendora` and `AsyncSendoraAccount` are the same clients for\n asyncio; use them in async code and never call the regular ones there.\n- Every key is a secret. Leave `token` out and the clients read\n `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN` from the environment;\n never hard-code a key or send it to a browser. A client without a key\n raises `ValueError`, so construct one per process at startup and share\n it.\n- A server is created with its first key:\n `account.servers.create(name=...)` answers `token.token` once, and\n `Sendora(token)` sends with it. A server or the account holds at most\n two live keys, so rotate by creating the new key, switching to it, then\n revoking the old one; the last live key can never be revoked.\n- For tests and CI, use a test server:\n `account.servers.create(name=..., mode=\"test\")`. It accepts, logs and\n fires webhooks like a live one but delivers nothing; its keys start\n `sk_test_`, and `is_test_key(token)` tells them apart at startup.\n Addresses at `simulator.sendora.se` (`hardbounce@`, `softbounce@`,\n `deferred@`, `complaint@`) produce those outcomes on a test server and\n are refused on a live one (`test_address_on_live_server`). A server's\n mode never changes. For unit tests, pass `http_client=` an\n `httpx2.Client` over an `httpx2.MockTransport`.\n- Arguments are keyword arguments with snake_case names; ids of a path\n come first and positionally. `from` is `from_`. Do not build request\n bodies by hand or call httpx2 yourself.\n- Do not add idempotency keys or retry loops of your own. The SDK gives\n every send a key and repeats a call only when repeating is safe. Pass\n `idempotency_key=` only when a retry may come from another process.\n- Every failed call raises a `SendoraError`. Catch its subclass or narrow\n on `error.code`, and let anything else propagate. Never swallow it.\n- Answers are frozen dataclasses. A field that holds one of a set of\n values, such as a recipient's `status`, may hold a value newer than the\n release, so a `match` over it keeps a `case _:`; never `assert_never`.\n- A batch never raises for one bad message: check each of\n `send_batch(...).results` with `isinstance(result, RefusedBatchItem)`.\n- Delivery is asynchronous. `email.send` answers `accepted`; whether the\n receiver took the message is in `messages.get(message_id).recipients`\n and in the `delivered`, `bounced`, `deferred` and `spam_complaint`\n webhook events.\n- Verify a webhook before parsing it with `verify_webhook`, given the raw\n body exactly as it arrived, `signature=` the `Sendora-Signature` header\n and `secret=` the webhook's secret. Tell the events apart with `isinstance` or\n a class pattern (`case BouncedEvent(details=details):`), never by\n comparing `event.event`, and handle `UnknownEvent`, an event newer than\n the release. Answer 2xx quickly and do the work afterwards. Deliveries\n arrive at least once, so key handling on `event.id`.\n- `tracking_opt_outs` is the server's list of addresses that are never\n tracked. Remove an entry whose `source` is `recipient` only after new\n consent. An erased entry has `address=None`, and only its address removes it.\n- Addresses on the suppression list are refused with\n `recipient_suppressed`; lift a hard bounce with\n `suppressions.delete(address=...)`; a spam complaint or an unsubscribe\n cannot be lifted by the customer. Lists are per stream: pass\n `stream_id=` when the send went on a stream other than the default.\n- `from_` must be on a verified sending domain of the account;\n `account.domains.create(domain=...)` answers the two DNS records to\n add, and `account.domains.verify` checks them now. A server key cannot\n manage domains, but `server.get()` names those a From may use\n (`sending_domains`). A verified domain whose record goes missing keeps\n sending for 24 hours (`failing_since`); a `dns_error` changes nothing.\n- A person who asks to be forgotten is erased with the account key:\n `account.erasures.create(address=...)` answers a pending erasure, and\n `account.erasures.get(erasure_id)` shows it `done` within a minute. It\n deletes their messages' content and puts a pseudonym in the log on every\n server; it blocks nothing.\n- A message goes on the server's default transactional stream unless it\n names another with `stream_id=`; `streams.create` adds one. Never make a\n stream per message or per customer; a stream is a kind of mail.\n- Mail to many goes as one broadcast: `broadcasts.send` on a broadcast\n stream, the content once and `messages=` with one entry per\n recipient, at most 50,000 entries; follow it with\n `broadcasts.get(broadcast_id)` and stop it with `broadcasts.cancel`.\n Personalise with `{{ key }}` in the content and `substitutions` on each\n entry. Never loop over `email.send` for a list.\n- A message on a broadcast stream must carry `{{ unsubscribe_url }}` in\n its text and its HTML; Sendora puts each recipient's own link there and\n writes the List-Unsubscribe headers itself. An `unsubscribed` event\n tells your system to stop; the address is then on that stream's list.\n\n## The shape of a call\n\n```python\nfrom sendora import Sendora, SendoraError\n\nsendora = Sendora()\n\ntry:\n accepted = sendora.email.send(\n from_={\"email\": \"no-reply@example.se\", \"name\": \"Example AB\"},\n to=[\"anna@example.com\"],\n subject=\"Din faktura för september\",\n text=\"Hej Anna, fakturan finns bifogad.\",\n tag=\"invoice\",\n )\nexcept SendoraError as error:\n if error.code == \"recipient_suppressed\":\n print([entry.address for entry in error.suppressed])\n raise\n```\n\n## Where to read more\n\n- The README of this package: every method, every error code, retries,\n webhooks and events, testing, logging and tracing.\n- The guides and the HTTP reference at https://sendora.se/docs.\n"},{"uri":"sendora://sdk/php/readme","title":"The PHP SDK","description":"sendora/sendora-php's README: every call, error code and webhook event.","text":"# sendora/sendora-php\n\nThe PHP client for [Sendora](https://sendora.se), transactional email\ndelivery. PHP 8.2 or newer, with ext-curl and ext-json and no other\npackage. Typed throughout: every argument is named and typed for your\neditor and PHPStan, and every answer is a readonly class.\n\n```sh\ncomposer require sendora/sendora-php\n```\n\nIn a Laravel app, install `sendora/sendora-laravel` instead. It installs\nthis package, sends Laravel's mail with `MAIL_MAILER=sendora`, and takes\nwebhooks on a route of its own, with a Laravel event for each.\n\nTwo kinds of key, two clients. `Sendora` takes a server key (`sk_…`) and\ndoes everything inside one server: sending, broadcasts, the log, received\nmail, streams, suppressions, the not-tracked list, tokens, webhooks and the server's own limits.\n`SendoraAccount` takes an account key (`ak_…`) and manages the account:\nits servers, their keys, its sending and tracking domains. Both keys are secrets:\nkeep them on the server and never ship them to a browser.\n\n```php\nuse Sendora\\Attachment;\nuse Sendora\\Sendora;\n\n$sendora = new Sendora(); // the key from SENDORA_API_TOKEN\n\n$accepted = $sendora->email->send(\n from: ['email' => 'no-reply@example.se', 'name' => 'Example AB'],\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n attachments: [Attachment::fromFile('faktura.pdf', contentType: 'application/pdf')],\n tag: 'invoice',\n metadata: ['invoice_id' => '2026-0912'],\n);\necho $accepted->messageId;\n```\n\nA client without a key throws `InvalidArgumentException` at construction,\nso build it at startup. `new Sendora('sk_…')` takes the key directly, and\n`new SendoraAccount()` reads `SENDORA_ACCOUNT_TOKEN`. The SDK reads a\nvariable from `$_SERVER`, then `$_ENV`, then `getenv()`, so a framework's\n`.env` loader works. PHP-FPM clears its workers' environment by default,\nso pass the key in, or set it in the pool with `env[SENDORA_API_TOKEN]`.\nMake one client per process and share it. It reuses its connection and\nneeds no closing. `var_dump()` and `print_r()` of a client show its\n`baseUrl`, never the key.\n\nBoth clients take the same arguments, each of them optional:\n\n| Argument | Default | What it is |\n| --------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------- |\n| `token` | `SENDORA_API_TOKEN`, or `SENDORA_ACCOUNT_TOKEN` for `SendoraAccount` | The key |\n| `baseUrl` | `SENDORA_BASE_URL`, then `https://api.sendora.se` | The API's address: https, or http to a loopback address alone |\n| `timeout` | 30 | The seconds a whole attempt may take over curl |\n| `maxRetries` | 2 | How often a failed call is repeated; 0 turns retries off |\n| `httpClient` | curl | A PSR-18 client of your own |\n| `requestFactory`, `streamFactory` | The client itself, when it implements PSR-17 | The PSR-17 factories that build the client's requests |\n| `logger` | None | A PSR-3 logger, which records each retry |\n\n`from` must be on a verified sending domain. `to`, `cc` and `bcc` take\nplain addresses or `['email' => …, 'name' => …]` arrays, at most 50 in\nall, and at least one of `text` and `html` is required. `streamId` names\nthe stream the message goes on; without it the default transactional\nstream. An attachment is `Attachment::fromFile()` or\n`Attachment::fromBytes()`, or an array whose `content` is already base64.\n`fromFile()` names it after its file unless `name:` says otherwise.\n\nEvery method takes what its route takes, with the API's camelCase names,\nand returns what the route returns. A path's ids come first, by position.\nName every argument after them, as the examples do. Your own keys, in\n`metadata`, `headers` and `substitutions`, are sent as you wrote them,\nkeys of digits included.\n\n## Sending\n\n| Call | Returns |\n| --------------------------------------- | -------------------------------------------------------------------- |\n| `$sendora->email->send(...$message)` | `AcceptedEmail` with `messageId`, `status`, `submittedAt` and `test` |\n| `$sendora->email->sendBatch($messages)` | `BatchResult` with `results`, one per message in order; 100 at most |\n\n`accepted` means the message is stored and on its way to the mail server.\nDelivery, deferral, bounce and complaint arrive later as events on the\nmessage and as webhooks.\n\n`send()` also takes a message as an array with the same keys as its\narguments, spread with `...`, and so does each item of `sendBatch()`.\nPHPStan knows the array as `EmailMessage`: import it with\n`@phpstan-import-type EmailMessage from Email`.\n\n`idempotencyKey:` names the request so a retry cannot send twice. The SDK\nmakes a random UUID when you give none. Give your own when a retry may\ncome from another process, such as a queued job. The API remembers a key\nfor 24 hours and refuses it with a different body\n(`idempotency_key_mismatch`). An exception from a send carries the key it\nwent under as `$error->idempotencyKey`.\n\nA batch never throws for one refused message. Each result is an\n`AcceptedBatchItem` with the `messageId`, or a `RefusedBatchItem` with\nthe `errorCode` and fields that a single send would throw. Both have\n`index`, the message's place in the list, counted from 0. A retried batch\nreturns the items sent before with `replayed` set.\n\n```php\nuse Sendora\\Types\\RefusedBatchItem;\n\n$batch = $sendora->email->sendBatch($messages, idempotencyKey: 'invoices-2026-09');\nforeach ($batch->results as $result) {\n if ($result instanceof RefusedBatchItem) {\n echo $result->index, ' ', $result->errorCode, ' ', $result->message, \"\\n\";\n }\n}\n```\n\n## Messages\n\n| Call | Returns |\n| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |\n| `$sendora->messages->get($messageId)` | `MessageDetail`: the message with its recipients, `tracking`, its attachments described and its events |\n| `$sendora->messages->search(...$filters)` | `MessagePage` with `messages` and `next`, newest first |\n| `$sendora->messages->searchAll(...$filters)` | Every match, page by page, as a generator |\n| `$sendora->inbound->get($inboundMessageId)` | A received message: envelope, parties, headers, text, HTML, attachments described |\n| `$sendora->inbound->search(...$filters)` | `InboundPage` of received mail, newest first |\n| `$sendora->inbound->searchAll(...$filters)` | Every received match, page by page, as a generator |\n| `$sendora->inbound->raw($inboundMessageId)` | The raw message's bytes as a string, `message/rfc822` |\n| `$sendora->inbound->attachment($inboundMessageId, $attachmentId)` | An attachment's bytes as a string |\n\nA stream can also receive on a domain of yours: every address on it,\nwhatever the local part, lands on the stream. Claim it with the stream\nid, and add the two records the answer gives: an MX that names Sendora\nand a TXT record that proves the claim. Mail is accepted once both are\nseen. A stream holds one domain, and the first account to verify a name\nholds it.\n\n| Call | Returns |\n| ------------------------------------------------------ | ---------------------------------------------------------------------------- |\n| `$sendora->inboundDomains->create(streamId:, domain:)` | `InboundDomain` with `mx` and `txt` to add |\n| `$sendora->inboundDomains->list()` | `InboundDomainList` with `domains` |\n| `$sendora->inboundDomains->get($inboundDomainId)` | One domain with the state of its records |\n| `$sendora->inboundDomains->verify($inboundDomainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `$sendora->inboundDomains->delete($inboundDomainId)` | Nothing; mail to it is refused from then on |\n\nThe filters of `messages->search()` are `recipient`, a whole address or\nthree characters or more of one, and `streamId`, `broadcastId` and `tag`.\n`status` takes `queued`, `delivered`, `deferred`, `bounced` or `expired`,\nor a list of them. `from` and `to` take a `DateTimeInterface` or RFC 3339\ntext. `limit` is 1 to 100, 50 by default, and `after` is the previous\npage's `next`. Bodies are never returned, and the log keeps messages for\n13 months.\n\nWhere mail is tracked, each `MessageRecipient` has `tracked`, `opens`,\n`clicks`, `firstOpenAt` and `firstHumanOpenAt`. `messages->get()` adds\n`tracking`, and its timeline holds `Open` and `Click` events with their\nlabels. Tracking is not open to accounts yet.\n\nThe filters of `inbound->search()` are `streamId`, and `from`, the\nsender's address. `recipient` is the address of yours that the mail went\nto, and `mailboxHash` the text after a plus sign in it. `receivedFrom`\nand `receivedTo` take times, and `limit` and `after` work as above.\n\n```php\nuse Sendora\\Types\\MessageRecipient;\nuse Sendora\\Types\\RecipientStatus;\n\n$message = $sendora->messages->get($messageId);\n$waiting = array_filter(\n $message->recipients,\n fn(MessageRecipient $recipient): bool => $recipient->status !== RecipientStatus::Delivered->value,\n);\necho count($waiting), \" recipients not delivered yet\\n\";\n\nforeach ($sendora->messages->searchAll(status: 'bounced', from: new DateTimeImmutable('2026-09-01')) as $bounced) {\n echo $bounced->messageId, ' ', $bounced->subject, \"\\n\";\n}\n```\n\n## Streams\n\nEvery message goes on a stream of its server. A server starts with a\ndefault transactional stream, and the account adds more: transactional\nand broadcast ones, and one inbound stream, which receives mail at its\n`inboundAddress` instead of sending. Each sending stream has a\nsuppression list of its own.\n\n| Call | Returns |\n| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |\n| `$sendora->streams->create(kind:, name:)` | `Stream`; a taken name is `stream_exists`, a second inbound stream `inbound_stream_exists`, both with `existingId` |\n| `$sendora->streams->list()` | `StreamList` with `streams`, archived ones included |\n| `$sendora->streams->get($streamId)` | One stream |\n| `$sendora->streams->update($streamId, name:, contentRetentionDays:)` | The updated stream; the days apply to an inbound stream |\n| `$sendora->streams->archive($streamId)` | The stream with `archivedAt`; it takes no new messages from then on |\n\n`kind` takes a `StreamKind` or its value. `contentRetentionDays: null`\nputs an inbound stream back to the default of 30 days, while leaving the\nargument out leaves the days as they are.\n\n```php\nuse Sendora\\Types\\StreamKind;\n\n$stream = $sendora->streams->create(kind: StreamKind::Transactional, name: 'Aviseringar');\n$sendora->email->send(\n from: 'no-reply@example.se',\n to: ['anna@example.com'],\n subject: 'Leveransen är på väg',\n text: 'Hej Anna, paketet lämnade lagret i dag.',\n streamId: $stream->streamId,\n);\n```\n\n## Broadcasts\n\nOne message to many on a broadcast stream, sent as a whole: the content\nonce and one entry per message, up to 50,000 and 50 MB per broadcast.\nEvery message is a message in the log with its own events and webhooks,\ncarrying the `broadcastId`. Every recipient gets their own unsubscribe\nlink where `{{ unsubscribe_url }}` stands. Addresses on the stream's\nsuppression list are dropped and counted in `suppressed`. The SDK sets an\nidempotency key, so a retry returns the same broadcast.\n\nA message's `substitutions`, up to 20 strings, replace `{{ key }}` in\nthe subject, the text and the HTML, escaped in the HTML. A key that the\ncontent names and a message lacks throws `substitution_missing`.\n\n| Call | Returns |\n| -------------------------------------------- | -------------------------------------------------------------------------------------- |\n| `$sendora->broadcasts->send(...$broadcast)` | `AcceptedBroadcast` with `broadcastId`, `total`, `suppressed` and `test` |\n| `$sendora->broadcasts->get($broadcastId)` | `BroadcastDetail` with `status`, `released`, `failed`, `opened`, `clicked` and `links` |\n| `$sendora->broadcasts->list(limit:, after:)` | `BroadcastPage` with `broadcasts` and `next`, newest first |\n| `$sendora->broadcasts->cancel($broadcastId)` | The cancelled broadcast; what had not left counts as failed |\n\n```php\n$accepted = $sendora->broadcasts->send(\n streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',\n from: ['email' => 'nyheter@example.se', 'name' => 'Example AB'],\n subject: 'Nyheter i oktober, {{ first_name }}',\n messages: [\n ['to' => ['anna@example.com'], 'substitutions' => ['first_name' => 'Anna']],\n ['to' => ['bo@example.com'], 'substitutions' => ['first_name' => 'Bo']],\n ],\n text: 'Hej! Här är månadens nyheter. Vill du inte ha fler? {{ unsubscribe_url }}',\n);\n$progress = $sendora->broadcasts->get($accepted->broadcastId);\n```\n\n## Suppressions\n\nAddresses the server no longer sends to: hard bounces, spam complaints,\nunsubscribes and entries added by hand. A send to one of them throws\n`recipient_suppressed`.\n\n| Call | Returns |\n| --------------------------------------------------------- | ------------------------------------------------ |\n| `$sendora->suppressions->list(streamId:, limit:, after:)` | `SuppressionPage` with `suppressions` and `next` |\n| `$sendora->suppressions->listAll(streamId:)` | Every entry, as a generator |\n| `$sendora->suppressions->delete(address:, streamId:)` | Nothing; the address may be sent to again |\n\nEvery stream has a list of its own. Without `streamId`, the calls mean\nthe default transactional stream. A spam complaint or a recipient's own\nunsubscribe cannot be lifted this way (`spam_complaint_locked`,\n`unsubscribe_locked`). Only Sendora support can, at the recipient's own\nrequest. A refused send carries `streamId` beside `suppressed`.\n\n```php\n$sendora->suppressions->delete(address: 'anna@example.com');\n```\n\n## The not-tracked list\n\nAddresses the server never tracks, on any of its streams. Mail to one still\ngoes out, untracked, and its opens and clicks of mail already sent are not\nrecorded. Tracking is not open to accounts yet, so the list changes nothing\nuntil it is.\n\n| Call | Returns |\n| ------------------------------------------------- | -------------------------------------------------------- |\n| `$sendora->trackingOptOuts->list(limit:, after:)` | `TrackingOptOutPage` with `trackingOptOuts` and `next` |\n| `$sendora->trackingOptOuts->listAll()` | Every entry, as a generator |\n| `$sendora->trackingOptOuts->add(address:)` | `TrackingOptOut`, as it stands if the address was listed |\n| `$sendora->trackingOptOuts->delete(address:)` | Nothing; `not_found` if the address is not listed |\n\nAn entry's `source` is `manual`, or `recipient` if the recipient asked for it.\nRemove a recipient's own entry only after new consent. An entry's `address` is\n`null` once an erasure has left only a keyed hash of it. `delete()` still finds\nthat entry by its address, and nothing removes it without the address.\n\n```php\n$sendora->trackingOptOuts->add(address: 'anna@example.com');\n```\n\n## Tokens\n\nThe server's own keys, from its own key. Every key of a server has the\nsame rights, and its value is shown once, when it is created. A server\nholds up to two live keys, and a third is `token_limit`. The account key\nmanages the same keys under `$account->servers->tokens`.\n\n| Call | Returns |\n| ------------------------------------ | ---------------------------------------------------- |\n| `$sendora->tokens->create(name:)` | `CreatedToken` with its `token` value, once |\n| `$sendora->tokens->list()` | `TokenList` with `tokens`, revoked ones included |\n| `$sendora->tokens->get($tokenId)` | One token, without its value |\n| `$sendora->tokens->revoke($tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\nA key's value is kept out of `var_dump()`, `print_r()` and `json_encode()`\nof the answer, so a logged answer does not show it. Read it from\n`->token`. `toArray()`, `var_export()` and Symfony's `dump()`, which\nLaravel's `dd()` uses, still show it. A webhook's secret is kept the same\nway.\n\n## The server\n\nThe key's own server: its name and id, and whether it is live or a test\nserver. It lists every limit a send on it is held to, with how much of\neach this minute and this month have used. It also lists the account's\nsending domains a From address may use.\n\n`inboundCap` is the account's monthly cap on received mail, apart from\nthe limits on sending, with its `limit`, `used` and `resetsAt`. Past it,\nmail to the account is refused for now, so senders retry. It is `null` on\na test server, which receives no mail.\n\n| Call | Returns |\n| ------------------------- | ------------------------------------------------------------------------------------------------------------------- |\n| `$sendora->server->get()` | `OwnServer` with `mode`, `limits`, each with `used` and `resetsAt`, `inboundCap`, and `sendingDomains` to send from |\n\n```php\n$own = $sendora->server->get();\nforeach ($own->limits as $limit) {\n echo $limit->scope, ' ', $limit->period, ' ', $limit->used, ' of ', $limit->limit, \"\\n\";\n}\nforeach ($own->sendingDomains as $sending) {\n echo $sending->domain, \"\\n\";\n}\n```\n\n## Webhooks\n\nAn https URL of yours that Sendora posts events to as they happen, signed\nwith a secret shown once at creation. A webhook holds up to two live\nsecrets, so a new one can be rolled in beside the old one. Every delivery\nis then signed with each, the newest first, and the old secret is deleted\nonce your receiver has switched.\n\n| Call | Returns |\n| ----------------------------------------------------------------------- | --------------------------------------------- |\n| `$sendora->webhooks->create(url:, events:, streamId:, inboundContent:)` | `CreatedWebhook` with its `secret`, once |\n| `$sendora->webhooks->list()` | `WebhookList` with `webhooks` |\n| `$sendora->webhooks->get($webhookId)` | One webhook |\n| `$sendora->webhooks->delete($webhookId)` | Nothing; pending deliveries are dropped |\n| `$sendora->webhooks->deliveries($webhookId, status:, limit:, after:)` | `DeliveryPage` with `deliveries` and `next` |\n| `$sendora->webhooks->deliveriesAll($webhookId, status:)` | Every delivery, as a generator |\n| `$sendora->webhooks->replay($webhookId, $deliveryId)` | The delivery, queued again |\n| `$sendora->webhooks->createSecret($webhookId)` | A second secret with its `secret` value, once |\n| `$sendora->webhooks->deleteSecret($webhookId, $secretId)` | Nothing; the last live secret is refused |\n\nEvents about a message are `delivered`, `bounced`, `deferred`,\n`spam_complaint` and `unsubscribed`. Each has `messageId`, `streamId`,\n`recipient`, `occurredAt`, `serverId`, your `tag` and `metadata`, and\n`details` from the receiver, or for `unsubscribed` the `source` (`link`\nor `one_click`). `cap_warning` and `cap_reached` are about usage, with\n`scope`, `cap`, `used` and the period.\n\n`opened` and `clicked` are about a tracked copy, and go only to a webhook\nthat names them. They have the same fields as the events above. Their `details`\nsay whether a machine did it, and for `clicked` give the `link`. Tracking is\nnot open to accounts yet, so they are not sent until it is.\n\n`inbound` is about a message received on an inbound stream. It has\n`inboundMessageId`, `envelopeRecipient`, the sizes and the\n`authentication` verdicts. For a webhook with `inboundContent: 'full'`,\nthe default, it has the message itself under `content`: `from`, `to`,\n`subject`, `text`, `html`, `attachments` and the rest.\n`inbound_cap_warning` and `inbound_cap_reached` say that received mail\nnears or reaches the account's monthly cap, with `cap`, `used` and the\nperiod. Every event carries `id`, the delivery id, and `attempt`.\n\nA failed delivery is retried with growing delays for about a day, then it\nis `dead` until you replay it. Deliveries arrive at least once and not\nalways in order, so key your handling on `$event->id`.\n\n### Receiving\n\nVerify the signature before you parse anything, answer 2xx quickly, and\ndo the work afterwards. Under PHP-FPM, `fastcgi_finish_request()` sends\nthe answer and lets the work go on; elsewhere, hand the work to a queued\njob. `WebhookVerifier::verify()` takes the raw body exactly as it arrived\nand the `Sendora-Signature` header. It returns the event as a class of\nits own.\n\n- The raw body is `file_get_contents('php://input')` in plain PHP and\n `$request->getContent()` in Symfony and Laravel.\n- The header is `$_SERVER['HTTP_SENDORA_SIGNATURE']` in plain PHP and\n `$request->headers->get('Sendora-Signature')` in Symfony and Laravel.\n- An `inbound` event with its content can take a few megabytes, more than\n nginx's default `client_max_body_size` of 1 MB. Raise it for the\n webhook's route.\n\n```php\nuse Sendora\\Events\\BouncedEvent;\nuse Sendora\\Events\\InboundEvent;\nuse Sendora\\Events\\UnknownEvent;\nuse Sendora\\Exception\\WebhookVerificationException;\nuse Sendora\\WebhookVerifier;\n\n$signature = $_SERVER['HTTP_SENDORA_SIGNATURE'] ?? null;\n\ntry {\n $event = WebhookVerifier::verify(\n (string) file_get_contents('php://input'),\n signature: is_string($signature) ? $signature : null,\n secret: (string) getenv('SENDORA_WEBHOOK_SECRET'),\n );\n} catch (WebhookVerificationException) {\n http_response_code(400);\n exit;\n}\n\nif ($event instanceof BouncedEvent && $event->details->hard) {\n error_log(\"{$event->messageId} bounced for good: {$event->details->classification}\");\n} elseif ($event instanceof InboundEvent && $event->content !== null) {\n error_log(\"{$event->inboundMessageId} arrived with {$event->attachmentCount} attachments\");\n} elseif ($event instanceof UnknownEvent) {\n error_log(\"An event newer than this release: {$event->event}\");\n}\nhttp_response_code(204);\n```\n\nTell the events apart with `instanceof`, as above. Comparing\n`$event->event` with a string does not narrow the type. Each class is in\n`Sendora\\Events` and extends `WebhookEvent`, which has `id`, `attempt`\nand `event`.\n\n| `event` | Class |\n| --------------------- | ------------------------ |\n| `delivered` | `DeliveredEvent` |\n| `bounced` | `BouncedEvent` |\n| `deferred` | `DeferredEvent` |\n| `spam_complaint` | `SpamComplaintEvent` |\n| `unsubscribed` | `UnsubscribedEvent` |\n| `opened` | `OpenedEvent` |\n| `clicked` | `ClickedEvent` |\n| `cap_warning` | `CapWarningEvent` |\n| `cap_reached` | `CapReachedEvent` |\n| `inbound` | `InboundEvent` |\n| `inbound_cap_warning` | `InboundCapWarningEvent` |\n| `inbound_cap_reached` | `InboundCapReachedEvent` |\n| any other | `UnknownEvent` |\n\nA signed event this release cannot read, one newer than it, is an\n`UnknownEvent` with its payload in `data`, rather than an error. So your\nendpoint can still answer it with 2xx, rather than fail it until Sendora\ngives up. Upgrade to read it as its class.\n\nThe signature is `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`,\none `v1` per live secret of the webhook. Each is an HMAC-SHA256 with that\nsecret over `<t>.<raw body>`, compared in constant time, and the one your\nsecret produces is enough. To roll a secret, create the new one with\n`webhooks->createSecret()`, and move your receiver to it while both sign\nevery delivery. Then delete the old one. A signature older than five\nminutes is refused (`stale_signature`), and `tolerance:` changes the\nlimit. A wrong one throws `invalid_signature`, and a missing or empty\nsecret throws `InvalidArgumentException`.\n\n## The account: servers, keys, domains and erasures\n\nAn account key (`ak_…`) manages the account and never sends. An\nadministrator creates one in the dashboard under Account › API keys, and\nthe account holds up to two live ones. `SendoraAccount` takes the same\narguments as `Sendora`.\n\n```php\nuse Sendora\\Sendora;\nuse Sendora\\SendoraAccount;\n\n$account = new SendoraAccount(); // the key from SENDORA_ACCOUNT_TOKEN\n\n$server = $account->servers->create(name: 'Fakturering');\n$sendora = new Sendora($server->token->token);\n```\n\nA server is created with its default transactional stream and its first\nkey, whose value is in the answer this once. A server holds up to two\nlive keys and never falls below one. So to rotate a key, create the new\none, switch to it, and revoke the old one.\n\n| Call | Returns |\n| -------------------------------------------------------- | ------------------------------------------------------------------------------------ |\n| `$account->servers->create(name:, mode:)` | `CreatedServer` with its first key as `token`, once; a taken name is `server_exists` |\n| `$account->servers->list()` | `ServerList` with `servers`, oldest first |\n| `$account->servers->get($serverId)` | One server |\n| `$account->servers->update($serverId, name:)` | The renamed server |\n| `$account->servers->delete($serverId)` | Nothing; `server_in_flight` while its messages are still being delivered |\n| `$account->servers->tokens->create($serverId, name:)` | `CreatedToken` with its `token` value, once; a third live key is `token_limit` |\n| `$account->servers->tokens->list($serverId)` | `TokenList` with `tokens`, revoked ones included |\n| `$account->servers->tokens->get($serverId, $tokenId)` | One key, without its value |\n| `$account->servers->tokens->revoke($serverId, $tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\nSending domains belong to the account, and every server sends from them.\nA domain sends once both DNS records are seen. A lookup that fails at the\nresolver (`dns_error`) changes nothing. A verified domain whose record\ngoes missing keeps sending for 24 hours while the account's\nadministrators are told, and `failingSince` says when that began.\n\n| Call | Returns |\n| -------------------------------------- | ---------------------------------------------------------------------------- |\n| `$account->domains->create(domain:)` | `SendingDomain` with `returnPath` (CNAME) and `dkim` (TXT) to add |\n| `$account->domains->list()` | `DomainList` with `domains` |\n| `$account->domains->get($domainId)` | One domain with the state of its records |\n| `$account->domains->verify($domainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `$account->domains->delete($domainId)` | Nothing; mail from it is refused from then on |\n\n```php\n$domain = $account->domains->create(domain: 'example.se');\necho $domain->returnPath->type, ' ', $domain->returnPath->host, ' ', $domain->returnPath->value, \"\\n\";\necho $domain->dkim->type, ' ', $domain->dkim->host, ' ', $domain->dkim->value, \"\\n\";\n```\n\nA tracking domain is the host that tracked links and the open pixel use: a\nsubdomain of a verified sending domain, with one CNAME record to add. CAA\nrecords above it must let Let's Encrypt issue its certificate. Tracking is\nnot open to accounts yet, so a host carries no mail until it is. A host\nwhose records are not right within 7 days goes.\n\n| Call | Answers |\n| ------------------------------------------------------ | ----------------------------------------------------------------------- |\n| `$account->trackingDomains->create(host:)` | `TrackingDomain` with `cname` to add |\n| `$account->trackingDomains->list()` | `TrackingDomainList` with `trackingDomains` |\n| `$account->trackingDomains->get($trackingDomainId)` | One tracking domain with what the last check found |\n| `$account->trackingDomains->verify($trackingDomainId)` | The tracking domain as it stands: `state`, `cname->check`, `caa->check` |\n| `$account->trackingDomains->delete($trackingDomainId)` | Nothing; new mail stops using it at once |\n\n```php\n$tracking = $account->trackingDomains->create(host: 'links.example.se');\necho $tracking->cname->type, ' ', $tracking->cname->host, ' ', $tracking->cname->value, \"\\n\";\n```\n\nAn erasure removes one person's data from every server of the account,\nas a request under the right to erasure asks. The content of every\nmessage to or from the address, sent and received, is deleted, and the\naddress becomes a pseudonym in the message log. The erasure is\n`pending`, and Sendora carries it out within a minute. Erasing an address\nagain changes nothing more, and later mail to or from it is sent and\nreceived as before.\n\n| Call | Returns |\n| -------------------------------------- | ----------------------------------------------------------------------------------------- |\n| `$account->erasures->create(address:)` | `Erasure`, `pending` |\n| `$account->erasures->get($erasureId)` | The erasure; once `done`, its `pseudonym`, `messages` and `receivedMessages`, for 30 days |\n\n```php\n$erasure = $account->erasures->create(address: 'anna@example.com');\necho $account->erasures->get($erasure->erasureId)->status, \"\\n\";\n```\n\nA key of the wrong kind throws `wrong_token_kind`: a server key on the\naccount's routes, or an account key on a server's.\n\n## Errors\n\nEvery failed call throws a `SendoraException`, which extends\n`RuntimeException`. Its class says what happened and its `errorCode` says\nwhy. Every class is in `Sendora\\Exception`.\n\n| Class | When |\n| ------------------------------ | --------------------------------------------------------------------- |\n| `ApiConnectionException` | No answer: `connection_failed` |\n| `ApiTimeoutException` | An attempt took longer than `timeout`: `timeout` |\n| `BadRequestException` | 400 |\n| `AuthenticationException` | 401 |\n| `PermissionDeniedException` | 403 |\n| `NotFoundException` | 404 |\n| `ConflictException` | 409 |\n| `UnprocessableEntityException` | 422 |\n| `RateLimitException` | 429, `rate_limited` and `monthly_cap_reached` alike |\n| `InternalServerException` | 500 or more |\n| `ApiStatusException` | Any other status, and the base of the classes for a status |\n| `ResponseValidationException` | An answer the SDK could not read; the call took effect |\n| `WebhookVerificationException` | `WebhookVerifier::verify()`: `invalid_signature` or `stale_signature` |\n\n`ApiTimeoutException` extends `ApiConnectionException`. `getCode()`\nreturns the HTTP status, or 0 when no answer came, and `getMessage()`\nthe API's message.\n\nA bad argument throws the SDK's `InvalidArgumentException` before any\nrequest: a key with a character no key has, a string that is not UTF-8,\nor a path id that is empty, `.` or `..`. It extends PHP's own, and is no\n`SendoraException`, since nothing was sent. Both implement\n`ExceptionInterface`, so `catch (ExceptionInterface $error)` catches\neverything the SDK throws.\n\n```php\nuse Sendora\\Exception\\RateLimitException;\nuse Sendora\\Exception\\SendoraException;\nuse Sendora\\Types\\ErrorCode;\n\ntry {\n $sendora->email->send(...$message);\n} catch (RateLimitException $error) {\n if ($error->resetsAt !== null) {\n echo 'The monthly cap is used up until ', $error->resetsAt->format('Y-m-d H:i'), \" UTC\\n\";\n } else {\n echo 'Try again in ', $error->retryAfter ?? 60, \" seconds\\n\";\n }\n} catch (SendoraException $error) {\n if ($error->errorCode !== ErrorCode::RecipientSuppressed->value) {\n throw $error;\n }\n foreach ($error->suppressed as $entry) {\n echo $entry->address, ' ', $entry->reason, \"\\n\";\n }\n}\n```\n\n| Code | Status | Meaning |\n| --------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------- |\n| `invalid_request` | 400 | A field is wrong; `issues` says which. |\n| `idempotency_key_required` | 400 | A batch was sent without a key, which the SDK always sends. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `wrong_token_kind` | 403 | A server key where an account key is needed, or the reverse; `message` names the kind. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `account_paused` | 403 | Sendora has paused the account. |\n| `account_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |\n| `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |\n| `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |\n| `not_found` | 404 | No such id on this server, or no such suppressed or not-tracked address. |\n| `domain_exists` | 409 | The account already has the domain; `existingId` names it. |\n| `domain_reserved` | 400 | The name is Sendora's own; no account can receive on it. |\n| `tracking_domain_exists` | 409 | The account already has the host, or one on that sending domain; `existingId` names it. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself; choose a subdomain. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of the account. |\n| `tracking_host_taken` | 409 | Another account holds the host. |\n| `inbound_domain_exists` | 409 | The stream already has a domain (`existingId` names it), or another account holds the name verified. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL; `existingId` names it. |\n| `stream_exists` | 409 | The server already has a stream with that name; `existingId` names it. |\n| `inbound_stream_exists` | 409 | The server already has a live inbound stream; `existingId` names it. |\n| `default_stream` | 409 | The default stream cannot be archived. |\n| `last_token` | 409 | The only live key cannot be revoked. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted. |\n| `secret_limit` | 409 | The webhook already holds two live secrets; `max` says how many. |\n| `token_limit` | 409 | The server or the account already holds two live keys; `max` says how many. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_limit` | 409 | The account has as many servers as it may; `max` says how many. |\n| `server_reserved` | 409 | The server sends Sendora's own sign-in mail and cannot be renamed or removed. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; delete it later. |\n| `request_too_large` | 413 | The body exceeds 10 MB, or 64 MB for a broadcast. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain. |\n| `stream_not_found` | 422 | `streamId` names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora paused the stream after complaints; support resumes it. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no list. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; this one is transactional. |\n| `substitution_missing` | 422 | The content names a `{{ key }}` a message does not give; `index` and `keys` say which. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled; nothing is left to cancel. |\n| `unsubscribe_placeholder_missing` | 422 | A broadcast message lacks `{{ unsubscribe_url }}` in a part. |\n| `list_unsubscribe_reserved` | 422 | Sendora writes List-Unsubscribe on broadcast streams; leave it out. |\n| `recipient_suppressed` | 422 | Recipients on the suppression list; `suppressed` lists them. |\n| `test_address_on_live_server` | 422 | Addresses at simulator.sendora.se are for test servers; `addresses` lists them. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n| `mode_immutable` | 400 | A server's mode is fixed at creation; create a new server for the other mode. |\n| `idempotency_key_mismatch` | 422 | The key was used before with a different body. |\n| `content_expired` | 410 | The received message's content window has passed; the message itself stays. |\n| `content_unreadable` | 409 | The received message's stored content cannot be opened; Sendora has been told. |\n| `rate_limited` | 429 | Per-minute limit; `retryAfter`, `scope`, `limit`. From the API's proxy, too many requests at once. |\n| `monthly_cap_reached` | 429 | The monthly cap is used up; `scope`, `cap`, `used`, `resetsAt`. |\n| `sending_disabled` | 503 | Sending is paused for everyone; `retryAfter`. |\n| `connection_failed` | none | The request did not reach the API, or its answer was lost; `getMessage()` says why. |\n| `timeout` | none | An attempt took longer than `timeout`. |\n| `unexpected_response` | any | An answer without a JSON error body, such as from a proxy. |\n| `invalid_signature` | none | A webhook's signature is missing, malformed or wrong. |\n| `stale_signature` | none | A webhook's signature is older than the tolerance. |\n\n`$error->isRetryable()` is true when waiting and calling again could\nsucceed. `ErrorCode` lists every code this release knows. A code newer\nthan the release comes through as its string, its class chosen by the\nstatus.\n\nNeither the key nor a request's subject, text, HTML or attachments is\never part of an exception, its message or a dump of it. The recipient\naddresses the API names are in `suppressed` and `addresses` alone.\n\n## New values and fields\n\nThe API may add a value to a set the SDK knows, such as a recipient's\nstatus, before your release does. So such a property is a `string`,\nwhich takes the new value rather than failing the call. A backed enum,\nsuch as `RecipientStatus`, lists the values the release knows. Compare\nwith `RecipientStatus::Delivered->value`, or use `tryFrom()`. A `match`\nover such a property keeps a `default` arm.\n\nA field newer than your release is not a property yet, but every answer\nkeeps what the API sent. `$answer->toArray()` returns it as arrays, with\nthe API's camelCase keys, in which an empty object becomes an empty\narray. `json_encode($answer)` writes the JSON back as it came.\n\n## Retries and timeouts\n\nLost connections, timeouts, `rate_limited`, `sending_disabled` and server\nerrors are retried with jittered backoff, twice by default. That includes\nthe plain-text 429 of the proxy in front of the API. A retry honours\n`retryAfter`, the seconds the API asks for, when it fits within five\nseconds of waiting in all. A longer `retryAfter` is thrown at once for\nyou to schedule. No other failure is retried, so `monthly_cap_reached`\nis thrown at once, with `resetsAt`.\n\nA call that creates something is repeated only after a `rate_limited` or\n`sending_disabled` answer. It is never repeated after a lost connection\nor a server error, so nothing is created twice. These calls are\n`streams->create()`, `tokens->create()`, `webhooks->create()`,\n`webhooks->createSecret()`, `webhooks->replay()`,\n`inboundDomains->create()`, `servers->create()`,\n`servers->tokens->create()`, `domains->create()` and `trackingDomains->create()`.\n\n`timeout` is in seconds, 30 by default, and bounds a whole attempt,\nconnecting included. `maxRetries: 0` turns retries off. `withOptions()`\nreturns a copy of the client that shares its connection, for one call\nthat may take longer or a job that must not retry:\n\n```php\nuse Sendora\\Sendora;\n\n$sendora = new Sendora(timeout: 10, maxRetries: 3);\n$patient = $sendora->withOptions(timeout: 120, maxRetries: 0);\n```\n\nThe SDK waits in the process between attempts, so a retry holds a web\nrequest up to five seconds longer. PHP has no standard event loop, so\nthere is no async client. When a request must not wait, send from a\nqueued job, such as a Symfony Messenger handler or a Laravel job.\n\n## Symfony Mailer\n\n`SendoraTransport` sends Symfony Mailer's messages through the API rather\nthan over SMTP. Its factory takes the DSN `sendora+api://KEY@default`,\nwith the server key as the user. Install `symfony/mailer`, from 6.4 to\n8.x, beside the SDK.\n\nIn a Symfony app, register the factory as a service, and set the DSN:\n\n```yaml\n# config/services.yaml\nservices:\n Sendora\\Mailer\\SendoraTransportFactory:\n parent: mailer.transport_factory.abstract\n tags: ['mailer.transport_factory']\n```\n\n```dotenv\nMAILER_DSN=sendora+api://sk_…@default\n```\n\nSymfony's own `config/packages/mailer.yaml` reads `MAILER_DSN`, so the\napp's `MailerInterface` then sends through Sendora. `?stream=<stream id>`\nsends on a stream other than the default. Any host but `default` is used\nas given, over https. Without the framework, give the transport a\nclient, or build the DSN's transport from the factory:\n\n```php\nuse Sendora\\Mailer\\SendoraTransport;\nuse Sendora\\Mailer\\SendoraTransportFactory;\nuse Sendora\\Sendora;\nuse Symfony\\Component\\Mailer\\Header\\MetadataHeader;\nuse Symfony\\Component\\Mailer\\Header\\TagHeader;\nuse Symfony\\Component\\Mailer\\Mailer;\nuse Symfony\\Component\\Mailer\\Transport;\nuse Symfony\\Component\\Mime\\Email;\n\n$mailer = new Mailer(new SendoraTransport(new Sendora()));\n\n// Or from the DSN, beside Symfony's own transports:\n$factories = [new SendoraTransportFactory(), ...Transport::getDefaultFactories()];\n$mailer = new Mailer((new Transport($factories))->fromString((string) getenv('MAILER_DSN')));\n\n$email = (new Email())\n ->from('no-reply@example.se')\n ->to('anna@example.com')\n ->subject('Din faktura för september')\n ->text('Hej Anna, fakturan finns bifogad.')\n ->attachFromPath('faktura.pdf', contentType: 'application/pdf');\n$email->getHeaders()\n ->add(new TagHeader('invoice'))\n ->add(new MetadataHeader('invoice_id', '2026-0912'));\n$mailer->send($email);\n```\n\n`Transport::fromDsn()` alone knows only Symfony's own transports. A\nmessage maps to a send this way:\n\n| From the message | To the send |\n| ------------------------------------------------------------------------------------------- | ------------------------------------------------- |\n| The From header's address and name | `from`; a second address fails the send |\n| The envelope's recipients, each placed by the To, Cc and Bcc headers | `to`, `cc`, `bcc` |\n| The subject, and the text and HTML bodies | `subject`, `text`, `html` |\n| Attachments, and inline parts with their content id | `attachments` |\n| `Reply-To`, `In-Reply-To`, `References`, the two unsubscribe headers and other `X-` headers | `headers`, the first of each name |\n| `X-Sendora-Tag`, or else `TagHeader` | `tag`; two different tags fail the send |\n| `MetadataHeader` and `X-Sendora-Metadata-<key>` | `metadata`, `X-Sendora-Metadata-` winning a tie |\n| `X-Sendora-Stream`, or else the DSN's `stream` | `streamId` |\n| `X-Sendora-Idempotency-Key` | `idempotencyKey`, or else a new key for each send |\n\n- Any other header, such as `Importance`, is left off, as over SMTP. The\n `X-Sendora-`, `X-Tag` and `X-Metadata-` headers never reach `headers`.\n- The envelope decides who gets the message, as over SMTP. A recipient\n that a listener adds, such as Symfony's `EnvelopeListener` redirecting\n a staging app's mail, goes to `to`. A Cc or Bcc address that the\n envelope lacks gets nothing.\n- After a send, the sent message's `getMessageId()` is Sendora's message\n id, the one the log and the webhooks use. Symfony's `SentMessageEvent`\n carries it.\n- A failure is a `TransportException` with the SDK's message, the HTTP\n status as its code and the SDK's exception as its previous. So\n Symfony's failover and round-robin transports work.\n- Set `X-Sendora-Idempotency-Key` from an id of your own, such as an\n order's, when a queued mail may be sent again. It helps only when the\n message renders the same each time. An embedded image gets a new\n content id on each render, and the API then refuses the key with\n `idempotency_key_mismatch`. The transport's message then says that the\n mail went out on an earlier attempt.\n\nTo send over SMTP instead, Symfony's own `smtp://` transport reaches\nSendora, as the SMTP guide at [sendora.se/docs](https://sendora.se/docs)\nshows.\n\n## Your own HTTP client\n\nWithout a client of your own, the SDK speaks to the API through curl. It\ntakes a proxy from `https_proxy`, `HTTPS_PROXY`, `all_proxy` or\n`ALL_PROXY`, and honours `NO_PROXY`, so a proxy needs no setting in the\nSDK. PHP-FPM passes those variables to its workers only when the pool\nsets them with `env[HTTPS_PROXY]` or `clear_env = no`.\n\nCertificates are verified against `curl.cainfo` or `openssl.cafile` in\n`php.ini`, or curl's default store. A file set there takes the place of\ncurl's store, so it must hold the public roots as well as your own\nauthority, such as the one a TLS-inspecting proxy signs with. On Windows,\nPHP ships no certificate file, so when neither setting is set, the SDK\nuses Windows' own store. A container without a store needs its\n`ca-certificates` package.\n\nPass any PSR-18 client as `httpClient:` for a private certificate\nauthority, tracing or tests. The SDK still builds every request, with its\nown headers, and still retries. `requestFactory:` and `streamFactory:`\nbuild the requests, and default to the client itself when it implements\nPSR-17, as Symfony's `Psr18Client` does. Guzzle's client needs both, such\nas `new GuzzleHttp\\Psr7\\HttpFactory()`. A client of your own uses its own\nproxy and certificate settings.\n\n- Set a timeout on the client. PSR-18 has none, so the SDK's `timeout`\n does not reach it.\n- Turn redirects off. The API never redirects.\n- Pass a client without default headers. The SDK's own headers win, but\n the client's others still go out.\n\n```php\nuse Sendora\\Sendora;\nuse Symfony\\Component\\HttpClient\\HttpClient;\nuse Symfony\\Component\\HttpClient\\Psr18Client;\n\n$http = HttpClient::create([\n 'max_duration' => 30,\n 'max_redirects' => 0,\n 'cafile' => '/etc/ssl/certs/internal-ca.pem',\n]);\n$sendora = new Sendora(httpClient: new Psr18Client($http));\n```\n\nSymfony's `Psr18Client` needs a PSR-7 implementation, such as\n`nyholm/psr7`. A failure of the client's is `connection_failed`, and its\nclass and message are in the exception's message.\n\n## Testing your code\n\nFor tests that talk to Sendora, create a test server. It goes through\neverything a live one does: validation, the suppression list, the log\nand the webhooks. But nothing it accepts is delivered. Its keys start\n`sk_test_`, and `Keys::isTestKey()` tells them apart, so a production\nprocess can refuse one at startup.\n\nAddresses at `simulator.sendora.se` (`hardbounce@`, `softbounce@`,\n`deferred@`, `complaint@`, with any `+label`) produce those outcomes,\nand any other address is marked delivered. A server's mode is fixed\nwhen it is created, so going live means a new server and a new key.\nTest mail is free and counts against its own monthly cap, shared by the\naccount's test servers.\n\n```php\nuse Sendora\\Keys;\nuse Sendora\\Sendora;\nuse Sendora\\SendoraAccount;\n\n$account = new SendoraAccount();\n$ci = $account->servers->create(name: 'CI', mode: 'test');\n\n$token = (string) getenv('SENDORA_API_TOKEN');\nif (getenv('APP_ENV') === 'production' && Keys::isTestKey($token)) {\n throw new RuntimeException('SENDORA_API_TOKEN is a test key, and nothing would be delivered.');\n}\n$sendora = new Sendora($token);\n```\n\nFor unit tests that must not reach the network, give the client a fake\nPSR-18 client that returns what the API would, such as Symfony's\n`MockHttpClient` inside a `Psr18Client`:\n\n```php\nuse PHPUnit\\Framework\\TestCase;\nuse Sendora\\Sendora;\nuse Symfony\\Component\\HttpClient\\MockHttpClient;\nuse Symfony\\Component\\HttpClient\\Psr18Client;\nuse Symfony\\Component\\HttpClient\\Response\\JsonMockResponse;\n\nfinal class InvoiceMailTest extends TestCase\n{\n public function testTheInvoiceIsSent(): void\n {\n $answer = new JsonMockResponse([\n 'messageId' => '0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11',\n 'status' => 'accepted',\n 'submittedAt' => '2026-09-26T10:00:00.000Z',\n 'test' => false,\n ]);\n $sendora = new Sendora('sk_unit_test', httpClient: new Psr18Client(new MockHttpClient($answer)));\n\n $accepted = $sendora->email->send(from: 'no-reply@example.se', to: ['anna@example.com'], subject: 'Hej', text: 'Hej');\n\n self::assertSame('https://api.sendora.se/v1/email', $answer->getRequestUrl());\n self::assertSame('accepted', $accepted->status);\n }\n}\n```\n\nTo test a failure, answer with the API's error body and status, such as\n`422` with `{\"error\": \"from_domain_not_verified\", \"message\": \"…\"}`. The SDK\nrepeats a call after a retryable answer, such as a `503`, so pass\n`maxRetries: 0` in a test that gives one.\n\nCode that takes a resource, such as `Email`, rather than the client can\ntake a double of it. Each resource class can be doubled with PHPUnit's\n`createMock()` or with Mockery. An answer's or a webhook event's\nconstructor is outside the semver promise, since a new property adds a\nparameter. So build either with named arguments, as below, and expect an\nupgrade to ask for more.\n\n```php\nuse PHPUnit\\Framework\\TestCase;\nuse Sendora\\Resources\\Email;\nuse Sendora\\Types\\AcceptedEmail;\n\nfinal class WelcomeMail\n{\n public function __construct(private readonly Email $email) {}\n\n public function send(string $address): string\n {\n $accepted = $this->email->send(from: 'no-reply@example.se', to: [$address], subject: 'Välkommen', text: 'Hej!');\n\n return $accepted->messageId;\n }\n}\n\nfinal class WelcomeMailTest extends TestCase\n{\n public function testTheWelcomeIsSent(): void\n {\n $email = $this->createMock(Email::class);\n $email->expects(self::once())->method('send')->willReturn(new AcceptedEmail(\n messageId: '0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11',\n status: 'accepted',\n submittedAt: new DateTimeImmutable('2026-09-26T10:00:00Z'),\n test: true,\n ));\n\n self::assertSame('0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11', (new WelcomeMail($email))->send('anna@example.com'));\n }\n}\n```\n\n## Logging and tracing\n\nThe SDK logs one thing: each retry, at debug level, on the PSR-3 logger\nyou pass as `logger:`. The entry has the method, the path, the code and\nthe wait. Monolog and the loggers of Symfony and Laravel all take PSR-3.\n\nA stack trace never shows the key, or a message's addresses, subject,\ntext, HTML or attachments. The SDK marks those parameters\n`#[\\SensitiveParameter]`, so PHP puts a placeholder in their place. That\nholds in an error tracker's traces and in Laravel's failed jobs alike.\n\nThe client sends every attempt through the PSR-18 client you give it,\nretries included. So a decorator around it sees each attempt and can\ntime it:\n\n```php\nuse Psr\\Http\\Client\\ClientInterface;\nuse Psr\\Http\\Message\\RequestInterface;\nuse Psr\\Http\\Message\\ResponseInterface;\nuse Psr\\Log\\LoggerInterface;\nuse Sendora\\Sendora;\nuse Symfony\\Component\\HttpClient\\Psr18Client;\n\nfinal class TimedClient implements ClientInterface\n{\n public function __construct(\n private readonly ClientInterface $inner,\n private readonly LoggerInterface $logger,\n ) {}\n\n public function sendRequest(RequestInterface $request): ResponseInterface\n {\n $started = microtime(true);\n $response = $this->inner->sendRequest($request);\n $this->logger->info('{method} {path} {status} in {seconds} s', [\n 'method' => $request->getMethod(),\n 'path' => $request->getUri()->getPath(),\n 'status' => $response->getStatusCode(),\n 'seconds' => round(microtime(true) - $started, 3),\n ]);\n\n return $response;\n }\n}\n\n$psr18 = new Psr18Client();\n$sendora = new Sendora(\n httpClient: new TimedClient($psr18, $logger),\n requestFactory: $psr18,\n streamFactory: $psr18,\n);\n```\n\nRecord the path, never the request's body, which holds the message.\n\n## For coding agents\n\nThe package ships `skills/sendora/SKILL.md`, a short guide in the agent\nskill format with the rules above. Point your agent at\n`vendor/sendora/sendora-php/skills/sendora/SKILL.md` or at this README.\nEvery public method carries a doc comment, and PHPStan reads the array\nshapes in them.\n\n## Versions\n\nThe package follows semantic versioning. A breaking change comes only in\na major version: a renamed or removed method, argument or property, or a\nchanged error code. A minor adds methods, arguments, properties, codes or\nevents, and a patch fixes a bug. `composer require` writes a constraint\nsuch as `^1.0`, which takes every later 1.x release.\n\nA release adds an argument only at the end of its method, so a call by\nposition keeps working. An answer's or a webhook event's constructor is\noutside the promise.\n`CHANGELOG.md` lists what each release changed.\n\n## Docs\n\nGuides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).\n"},{"uri":"sendora://sdk/php/skill","title":"Rules for agents using the PHP SDK","description":"The skill file sendora/sendora-php ships for coding agents.","text":"---\nname: sendora\ndescription: Send transactional email through Sendora with the sendora/sendora-php package, read the message log, manage streams, suppressions, keys, webhooks, servers and sending domains, receive signed webhooks, and send Symfony Mailer's mail through the API. Use when a task mentions Sendora, the sendora/sendora-php package, or sending email from a PHP system hosted in Sweden.\n---\n\n# Sendora\n\n`sendora/sendora-php` is the PHP client for the Sendora API. It runs on\nPHP 8.2 or newer with ext-curl and ext-json, and is typed for PHPStan.\nThe README of this package, `README.md` at its root, is the complete\nreference: every call, every error code and every event is in it.\n\n## Rules\n\n- Two kinds of key, two clients. A server key (`sk_…`) drives\n `new Sendora()`: `email`, `broadcasts`, `messages`, `inbound`,\n `inboundDomains`, `server`, `streams`, `suppressions`, `trackingOptOuts`,\n `tokens` and\n `webhooks`, everything inside one server. An account key (`ak_…`)\n drives `new SendoraAccount()`: `servers`, `servers->tokens`, `domains`,\n `trackingDomains` and `erasures`, and it never sends. A key of the wrong kind throws\n `wrong_token_kind`.\n- Every key is a secret. Leave `token` out and the clients read\n `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN` from `$_SERVER`,\n `$_ENV` or `getenv()`. Never hard-code a key or send it to a browser.\n Under PHP-FPM, set the variable in the pool with `env[...]` or pass the\n key in. A client without a key throws `InvalidArgumentException`, so\n construct one per process at startup and share it, as a service in a\n container.\n- A server is created with its first key:\n `$account->servers->create(name: ...)` returns `->token->token` once,\n and `new Sendora($token)` sends with it. A server or the account holds\n at most two live keys. So rotate by creating the new key, switching to\n it, then revoking the old one. The last live key can never be revoked.\n- For tests and CI, use a test server:\n `$account->servers->create(name: ..., mode: 'test')`. It accepts, logs\n and fires webhooks like a live one but delivers nothing. Its keys start\n `sk_test_`, and `Keys::isTestKey($token)` tells them apart at startup.\n Addresses at `simulator.sendora.se` (`hardbounce@`, `softbounce@`,\n `deferred@`, `complaint@`) produce those outcomes on a test server and\n are refused on a live one (`test_address_on_live_server`). A server's\n mode never changes. For unit tests, pass `httpClient:` a fake PSR-18\n client, such as Symfony's `MockHttpClient` inside a `Psr18Client`, or\n double a resource class with `createMock()`.\n- Arguments are named, with the API's camelCase names. The ids of a path\n come first, by position, and every other argument is named. Do not\n build request bodies by hand or call curl yourself.\n- Do not add idempotency keys or retry loops of your own. The SDK gives\n every send a key and repeats a call only when repeating is safe. Pass\n `idempotencyKey:` only when a retry may come from another process,\n such as a queued job. There is no async client: send from a queued job\n when a request must not wait.\n- Every failed call throws a `SendoraException`. Catch its subclass or\n compare `$error->errorCode` with an `ErrorCode` case's `->value`, and\n rethrow anything else. Never swallow it. A bad argument throws the\n SDK's `InvalidArgumentException` before any request.\n- Answers are readonly classes. A property that holds one of a set of\n values, such as a recipient's `status`, is a string that may hold a\n value newer than the release. Compare it with an enum's `->value`, such\n as `RecipientStatus::Delivered->value`, and give a `match` over it a\n `default` arm.\n- A batch never throws for one bad message: check each of\n `sendBatch(...)->results` with `instanceof RefusedBatchItem`.\n- Delivery is asynchronous. `email->send()` returns `accepted`. Whether\n the receiver took the message is in\n `messages->get($messageId)->recipients` and in the `delivered`,\n `bounced`, `deferred` and `spam_complaint` webhook events.\n- Verify a webhook before parsing it with `WebhookVerifier::verify()`.\n Give it the raw body exactly as it arrived,\n `file_get_contents('php://input')` or `$request->getContent()`, with\n `signature:` the `Sendora-Signature` header and `secret:` the\n webhook's secret. Tell the events apart with `instanceof`, never by\n comparing `$event->event`, and handle `UnknownEvent`, an event newer\n than the release. Answer 2xx quickly and do the work afterwards.\n Deliveries arrive at least once, so key handling on `$event->id`.\n- `trackingOptOuts` is the server's list of addresses that are never\n tracked. Remove an entry whose `source` is `recipient` only after new\n consent. An erased entry has a `null` `address`, and only its address removes it.\n- Addresses on the suppression list are refused with\n `recipient_suppressed`. Lift a hard bounce with\n `suppressions->delete(address: ...)`. A spam complaint or an\n unsubscribe cannot be lifted by the customer. Lists are per stream:\n pass `streamId:` when the send went on a stream other than the default.\n- `from` must be on a verified sending domain of the account.\n `$account->domains->create(domain: ...)` returns the two DNS records to\n add, and `$account->domains->verify()` checks them now. A server key\n cannot manage domains, but `server->get()` names those a From may use\n (`sendingDomains`). A verified domain whose record goes missing keeps\n sending for 24 hours (`failingSince`), and a `dns_error` changes\n nothing.\n- A person who asks to be forgotten is erased with the account key.\n `$account->erasures->create(address: ...)` returns a pending erasure,\n and `$account->erasures->get($erasureId)` shows it `done` within a\n minute. It deletes their messages' content and puts a pseudonym in the\n log on every server. It blocks nothing.\n- A message goes on the server's default transactional stream unless it\n names another with `streamId:`, and `streams->create()` adds one. Never\n make a stream per message or per customer: a stream is a kind of mail.\n- Mail to many goes as one broadcast: `broadcasts->send()` on a broadcast\n stream, with the content once and `messages:` holding one entry per\n recipient, at most 50,000. Follow it with `broadcasts->get()` and stop\n it with `broadcasts->cancel()`. Personalise with `{{ key }}` in the\n content and `substitutions` on each entry. Never loop over\n `email->send()` for a list.\n- A message on a broadcast stream must carry `{{ unsubscribe_url }}` in\n its text and its HTML. Sendora puts each recipient's own link there and\n writes the List-Unsubscribe headers itself. An `unsubscribed` event\n tells your system to stop, and the address is then on that stream's\n list.\n- Symfony Mailer sends through the API with\n `MAILER_DSN=sendora+api://KEY@default`, once `SendoraTransportFactory`\n is registered as the README shows. Symfony's `TagHeader` and\n `MetadataHeader` become the tag and the metadata.\n- In a Laravel app, install `sendora/sendora-laravel`, which installs\n this package. `MAIL_MAILER=sendora` sends Laravel's mail, the clients\n come from the container, and the package's route checks each webhook\n and fires a Laravel event. Its README has the settings.\n\n## The shape of a call\n\n```php\nuse Sendora\\Exception\\SendoraException;\nuse Sendora\\Sendora;\nuse Sendora\\Types\\ErrorCode;\n\n$sendora = new Sendora();\n\ntry {\n $accepted = $sendora->email->send(\n from: ['email' => 'no-reply@example.se', 'name' => 'Example AB'],\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n tag: 'invoice',\n );\n echo $accepted->messageId, \"\\n\";\n} catch (SendoraException $error) {\n if ($error->errorCode !== ErrorCode::RecipientSuppressed->value) {\n throw $error;\n }\n foreach ($error->suppressed as $entry) {\n echo $entry->address, ' is suppressed: ', $entry->reason, \"\\n\";\n }\n}\n```\n\n## Where to read more\n\n- The README of this package: every method, every error code, retries,\n webhooks and events, the Symfony Mailer transport, testing, logging\n and tracing.\n- The guides and the HTTP reference at https://sendora.se/docs.\n"},{"uri":"sendora://sdk/laravel/readme","title":"The Laravel package","description":"sendora/sendora-laravel's README: Laravel's mail through Sendora, the clients in the container, and the webhook route and its events.","text":"# sendora/sendora-laravel\n\n[Sendora](https://sendora.se), transactional email delivery, for Laravel.\nIt puts the API's clients in Laravel's container, with a facade. It sends\nLaravel's mail through the API with `MAIL_MAILER=sendora`. And it fires a\nLaravel event for each webhook, from a route that checks its signature.\nLaravel 12 on PHP 8.2 or newer, and Laravel 13 on PHP 8.3 or newer.\n\n```sh\ncomposer require sendora/sendora-laravel\n```\n\nLaravel finds the package by itself. Then set the server key, and send\nmail through it:\n\n```dotenv\nSENDORA_API_TOKEN=sk_…\nMAIL_MAILER=sendora\nMAIL_FROM_ADDRESS=no-reply@example.se\n```\n\nAn account has servers, and each server has its own mail, log and keys.\nA server key (`sk_…`) sends and reads everything of its server, and you\nget one when you create the server in the dashboard. An account key\n(`ak_…`) manages the account's servers, keys and domains. Both are\nsecrets, so keep them on the server.\n\nThe package builds on\n[`sendora/sendora-php`](https://packagist.org/packages/sendora/sendora-php),\nthe PHP client, which it installs. That package's README has every call,\nresponse, error code and webhook event. This one covers what Laravel\nadds.\n\n## Settings\n\nThe settings live in `config/sendora.php`, and the environment sets the\nones a deploy changes. Publish the file to change the rest:\n\n```sh\nphp artisan vendor:publish --tag=sendora-config\n```\n\n| Key | From the environment | Default |\n| -------------------- | ------------------------ | --------------------------- |\n| `token` | `SENDORA_API_TOKEN` | none |\n| `account_token` | `SENDORA_ACCOUNT_TOKEN` | none |\n| `base_url` | `SENDORA_BASE_URL` | `https://api.sendora.se` |\n| `timeout` | | 10 seconds an attempt |\n| `max_retries` | | 2; 0 turns retries off |\n| `http_client` | | none, so curl |\n| `stream` | `SENDORA_STREAM` | the server's default stream |\n| `webhook.enabled` | | true |\n| `webhook.path` | | `sendora/webhook` |\n| `webhook.domain` | | none, so any domain |\n| `webhook.middleware` | | none |\n| `webhook.secrets` | `SENDORA_WEBHOOK_SECRET` | none, so no route |\n| `webhook.tolerance` | | 300 seconds |\n\n- Three attempts of 10 seconds and the waits between them stay within\n the 60 seconds that `queue:work` gives a job by default.\n- `http_client` names a PSR-18 client in the container, for a proxy or\n for tests. Otherwise the clients send through curl, so Laravel's\n `Http::fake()` never sees their requests.\n- A stream is a lane of a server's mail, transactional or broadcast, with\n a suppression list of its own.\n- `webhook.secrets` takes several secrets, separated by commas, since\n each webhook has its own.\n\nA client is built on first use, so an app that never calls Sendora needs\nno key. A client without its key throws `Sendora\\Exception\\InvalidArgumentException`,\nnaming the variable. A setting of the wrong type throws it too, naming\nthe setting.\n\n## Sending mail\n\nWith `MAIL_MAILER=sendora`, Laravel's mail goes through the API, and\n`config/mail.php` needs no entry for it. A mailable's attachments, Cc,\nBcc and Reply-To go through as Laravel sets them. Its tags and metadata\nbecome the message's `tag` and `metadata` in Sendora.\n\n```php\nuse Illuminate\\Mail\\Mailable;\nuse Illuminate\\Mail\\Mailables\\Content;\nuse Illuminate\\Mail\\Mailables\\Envelope;\nuse Illuminate\\Support\\Facades\\Mail;\n\nfinal class InvoiceMail extends Mailable\n{\n public function __construct(public readonly string $invoiceId) {}\n\n public function envelope(): Envelope\n {\n return new Envelope(\n subject: 'Din faktura för september',\n tags: ['invoice'],\n metadata: ['invoice_id' => $this->invoiceId],\n );\n }\n\n public function content(): Content\n {\n return new Content(htmlString: '<p>Hej Anna, fakturan finns bifogad.</p>');\n }\n}\n\n$sent = Mail::to('anna@example.com')->send(new InvoiceMail('2026-0912'));\nif ($sent !== null) {\n $messageId = $sent->getMessageId(); // Sendora's message id, as the log and the webhooks name it\n}\n```\n\n- `send()` returns null when the mail is queued, or when a listener of\n Laravel's `MessageSending` stops it. For queued mail, Laravel's\n `MessageSent` event carries the sent message, and with it Sendora's id.\n- A message has one tag. The same tag twice counts once, and an\n `X-Sendora-Tag` header wins over the mailable's tags. Two different\n tags fail the send before anything goes out.\n- Laravel sends from `MAIL_FROM_ADDRESS` unless a mailable sets its own\n sender. Either way, the address must be on a domain that the account\n has verified.\n- Notifications sent by mail go the same way, and the `tag()` and\n `metadata()` of a `MailMessage` set Sendora's.\n- The headers `X-Sendora-Metadata-<key>` and `X-Sendora-Stream` set\n metadata and the stream from a mailable's `headers()`.\n\nA failure is Symfony's `TransportException`, with the SDK's exception as\nits previous one. Laravel's queue retries a queued mail that fails, as\nits settings say. Laravel's `failover` mailer moves to its next mailer\nwhen Sendora fails, as it does for any transport. The SDK's README maps\neach part of a message to the API's request.\n\n### Several servers\n\nA mailer of `config/mail.php` with the transport `sendora` may set its\nown `token`, `base_url` and `stream`, for an app that sends from several\nservers:\n\n```php\n// config/mail.php, beside the mailers that are there\nreturn [\n 'mailers' => [\n 'billing' => [\n 'transport' => 'sendora',\n 'token' => env('SENDORA_BILLING_TOKEN'),\n 'stream' => env('SENDORA_BILLING_STREAM'),\n ],\n ],\n];\n```\n\nThen `Mail::mailer('billing')->to(...)->send(...)` sends with that\nserver's key.\n\n- A setting that the mailer leaves out, or that is null as an unset\n variable leaves it, comes from `config/sendora.php`. So does every\n other setting, such as the timeout.\n- The stream is the exception. A mailer with its own `token` sends on\n its server's default stream, unless it sets `stream`.\n- For calls on that server, build a client with its key:\n `new \\Sendora\\Sendora(config('mail.mailers.billing.token'))`.\n- The secrets of that server's webhooks go in `SENDORA_WEBHOOK_SECRET`\n too, beside the first server's.\n\n### Queued mail\n\nThe SDK sends each request under an idempotency key, so a retried request\nnever sends twice. But Laravel retries a failed queued mail as a new\nsend, under a new key. If the first attempt went out before it failed,\nthe retry sends the mail again. Set `X-Sendora-Idempotency-Key` from an\nid of your own to stop that:\n\n```php\nuse Illuminate\\Mail\\Mailable;\nuse Illuminate\\Mail\\Mailables\\Headers;\n\nfinal class ReceiptMail extends Mailable\n{\n public function __construct(public readonly string $orderId) {}\n\n public function headers(): Headers\n {\n return new Headers(text: ['X-Sendora-Idempotency-Key' => \"receipt-{$this->orderId}\"]);\n }\n}\n```\n\n- The API remembers a key for 24 hours.\n- The key protects a retry only when the mailable renders the same bytes\n each time. An embedded image gets a new content id on each render, and\n a signed URL a new expiry.\n- Otherwise the API refuses the reused key with\n `idempotency_key_mismatch`. The failure's message then says that the\n mail went out on an earlier attempt, so that retry has nothing left to\n send. Inside a `failover` mailer, though, the next mailer sends it\n again.\n\n## The clients\n\nThe `Sendora` facade reaches each resource of the server client:\n`broadcasts()`, `email()`, `inbound()`, `inboundDomains()`, `messages()`,\n`server()`, `streams()`, `suppressions()`, `tokens()`, `trackingOptOuts()`\nand `webhooks()`.\n\n```php\nuse Sendora\\Laravel\\Facades\\Sendora;\n\n$accepted = Sendora::email()->send(\n from: 'no-reply@example.se',\n to: ['anna@example.com'],\n subject: 'Din faktura för september',\n text: 'Hej Anna, fakturan finns bifogad.',\n tag: 'invoice',\n);\n$message = Sendora::messages()->get($accepted->messageId);\n```\n\nThe container holds one `Sendora\\Sendora`, from `SENDORA_API_TOKEN`, and\none `Sendora\\SendoraAccount`, from `SENDORA_ACCOUNT_TOKEN`, each with\nLaravel's logger. It binds each resource class too, such as `Email` and\n`Messages` of the server client, and `Servers` and `Domains` of the\naccount client. So a class may take what it uses:\n\n```php\nuse Sendora\\Resources\\Messages;\n\nfinal class DeliveryStatus\n{\n public function __construct(private readonly Messages $messages) {}\n\n public function of(string $messageId): string\n {\n return $this->messages->get($messageId)->status;\n }\n}\n```\n\nThe account client has no facade. Take `SendoraAccount` from the\ncontainer, or one of its resources: `Domains`, `TrackingDomains`, `Erasures`\nfor erasing an address's data, `Servers` and `ServerTokens`.\n\n## Webhooks\n\nA webhook is a URL of yours that Sendora posts events to, as they happen.\nEach post is a delivery, signed with the webhook's secret. The package's\nroute takes the deliveries, checks each signature, and fires a Laravel\nevent for each.\n\n1. Create the webhook in the dashboard, or with the client. The route\n does not exist until a secret is set, so build its URL from its path:\n\n ```php\n use Sendora\\Laravel\\Facades\\Sendora;\n\n $webhook = Sendora::webhooks()->create(url: url('sendora/webhook'));\n $secret = $webhook->secret; // shown this once\n ```\n\n It receives every event except `opened` and `clicked`, unless `events:`\n lists the ones it wants.\n\n2. Put the secret in `SENDORA_WEBHOOK_SECRET`, and deploy.\n3. If the app caches its config or its routes, run `config:cache` and\n `route:cache` again where the secret is set.\n\nThe route is `POST /sendora/webhook`, named `sendora.webhook`. Until a\nsecret is set, the route does not exist. A delivery then gets a 404, and\nSendora tries it again later. The route is outside the `web` middleware\ngroup, so Laravel's CSRF check never applies to it.\n\nListen to the event of each webhook you want, as to any Laravel event:\n\n```php\nuse Illuminate\\Support\\Facades\\Log;\nuse Sendora\\Laravel\\Events\\MessageBounced;\n\nfinal class MarkTheAddressUndeliverable\n{\n public function handle(MessageBounced $event): void\n {\n if ($event->webhook->details->hard) {\n Log::warning(\"{$event->webhook->messageId} bounced for good: {$event->webhook->details->classification}\");\n }\n }\n}\n```\n\nLaravel finds a listener in `app/Listeners` by the type of its `handle()`\nargument. `Event::listen()` registers one in a provider instead.\n\n| Webhook event | Laravel event | `$event->webhook` |\n| --------------------- | ------------------------ | ------------------------ |\n| `delivered` | `MessageDelivered` | `DeliveredEvent` |\n| `bounced` | `MessageBounced` | `BouncedEvent` |\n| `deferred` | `MessageDeferred` | `DeferredEvent` |\n| `spam_complaint` | `SpamComplaintReceived` | `SpamComplaintEvent` |\n| `unsubscribed` | `RecipientUnsubscribed` | `UnsubscribedEvent` |\n| `opened` | `MessageOpened` | `OpenedEvent` |\n| `clicked` | `LinkClicked` | `ClickedEvent` |\n| `cap_warning` | `CapWarning` | `CapWarningEvent` |\n| `cap_reached` | `CapReached` | `CapReachedEvent` |\n| `inbound` | `InboundEmailReceived` | `InboundEvent` |\n| `inbound_cap_warning` | `InboundCapWarning` | `InboundCapWarningEvent` |\n| `inbound_cap_reached` | `InboundCapReached` | `InboundCapReachedEvent` |\n| any other | `UnknownWebhookReceived` | `WebhookEvent` |\n\nThe Laravel events are in `Sendora\\Laravel\\Events`. `$event->webhook` is\nthe SDK's event, from `Sendora\\Events`, whose fields the SDK's README\nlists. A cap is a monthly limit, on sending or on received mail. Every\nLaravel event implements `WebhookReceived`, so a listener of that\ninterface hears them all.\n\nA webhook that the package has no event for fires\n`UnknownWebhookReceived`. Its `webhook->event` is the event's name, and\n`webhook->toArray()` its payload as it came. Upgrade the package to read\nit as its class.\n\n### Responding in time\n\n- The route responds with 204 once the listeners that are not queued\n have run. Sendora waits 10 seconds for the response, and counts a later\n one as a failure. So queue slow work.\n- A listener that throws makes the response 500. After a failure,\n Sendora delivers the webhook again, with growing delays, for about a\n day.\n- A queued listener that fails is retried by Laravel's queue, not by\n Sendora, which has had its 204.\n- A delivery can arrive more than once, so key your handling on\n `$event->webhook->id`. The id is the same on each attempt, and\n `webhook->attempt` counts them.\n- A bad or stale signature gets a 400 and fires nothing. The log gets\n the failure's code at debug level, and nothing of the request.\n\n### Received mail\n\n`InboundEmailReceived` carries the received mail under\n`webhook->content` when the webhook was created with\n`inboundContent: 'full'`, the default. A webhook created with\n`inboundContent: 'reference'` carries no content. Then fetch the mail\nwith `Sendora::inbound()->get($event->webhook->inboundMessageId)`, for 30\ndays by default.\n\nA queued listener of `InboundEmailReceived` would put the mail in the\nqueue's store, in clear, and could pass a queue's size limit, such as\nSQS's. So queue the message's id alone and fetch the mail in the job:\n\n```php\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Support\\Facades\\Event;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Sendora\\Laravel\\Events\\InboundEmailReceived;\nuse Sendora\\Resources\\Inbound;\n\nfinal class FileTheTicket implements ShouldQueue\n{\n use Dispatchable;\n use Queueable;\n\n public function __construct(public readonly string $inboundMessageId) {}\n\n public function handle(Inbound $inbound): void\n {\n $mail = $inbound->get($this->inboundMessageId);\n Storage::put(\"tickets/{$this->inboundMessageId}.txt\", $mail->text ?? '');\n }\n}\n\nEvent::listen(static function (InboundEmailReceived $event): void {\n FileTheTicket::dispatch($event->webhook->inboundMessageId);\n});\n```\n\nA listener that is `ShouldBeEncrypted` keeps the mail unreadable in the\nqueue, but not small. A delivery with the mail's content can take a few\nmegabytes. That is more than nginx's default `client_max_body_size` of\n1 MB, so raise it for the route, or create the webhook with\n`inboundContent: 'reference'`.\n\n### The route's settings\n\n- `webhook.path` and `webhook.domain` move the route, and\n `webhook.middleware` adds middleware to it, such as a rate limit.\n- `webhook.enabled` set to false removes the route.\n- `webhook.tolerance` sets how old a signature may be, in seconds,\n either way. Keep the server's clock in sync.\n\nCached routes keep the path, the domain and the middleware that they were\ncached with. So cache the routes where the secret is set, as at a deploy,\nand not in an image built without it. The route reads the secrets and the\ntolerance with each delivery. It responds with 404 once it is turned off\nor its last secret is gone.\n\n### Rolling a secret\n\nA webhook holds up to two live secrets, and signs each delivery with\nboth. So a secret rolls without a gap:\n\n1. Create the second secret with\n `Sendora::webhooks()->createSecret($webhookId)`. Its `secret` is the\n new value, shown once.\n2. Add it to `SENDORA_WEBHOOK_SECRET`, after a comma, and deploy.\n3. Delete the old secret with\n `Sendora::webhooks()->deleteSecret($webhookId, $secretId)`.\n4. Remove the old secret from the variable, and deploy.\n\nWith the config cached, run `config:cache` after each change to the\nvariable.\n\n## Testing your code\n\n`Mail::fake()` and `Event::fake()` work as usual, and a faked mailer\nnever reaches Sendora.\n\nTo test code that calls the API, replace a resource in the container.\n`Sendora::email()` and a class that takes `Email` then both get the\ndouble. The two client classes are final, so `Sendora::shouldReceive()`\ncannot double them. The resource classes are not, so double those.\n\n```php\nuse Illuminate\\Foundation\\Testing\\TestCase;\nuse Mockery\\MockInterface;\nuse Sendora\\Laravel\\Facades\\Sendora;\nuse Sendora\\Resources\\Email;\nuse Sendora\\Types\\AcceptedEmail;\n\nfinal class WelcomeMailTest extends TestCase\n{\n public function testTheWelcomeIsSent(): void\n {\n $this->mock(Email::class, static function (MockInterface $email): void {\n $email->expects('send')->andReturn(new AcceptedEmail(\n messageId: '0b7c6a55-6f0e-4bd4-9a3c-0f6f2f8d4a11',\n status: 'accepted',\n submittedAt: new \\DateTimeImmutable('2026-09-26T10:00:00Z'),\n test: true,\n ));\n });\n\n $accepted = Sendora::email()->send(from: 'no-reply@example.se', to: ['anna@example.com'], subject: 'Välkommen', text: 'Hej!');\n\n self::assertSame('accepted', $accepted->status);\n }\n}\n```\n\nA response's constructor is outside the semver promise, since a new\nproperty adds a parameter. So build one with named arguments, and expect\nan upgrade to ask for more.\n\nTo fake the API itself, give a client a fake PSR-18 client, as the SDK's\nREADME shows. Then swap it in:\n`Sendora::swap(new \\Sendora\\Sendora('sk_test_…', httpClient: $fake))`.\n\nTo test that a delivery reaches your code through the route, set a\nsecret in `phpunit.xml`, so the route exists when the app boots:\n\n```xml\n<env name=\"SENDORA_WEBHOOK_SECRET\" value=\"whsec_test\"/>\n```\n\nThen post a delivery signed as Sendora signs it. The route checks the\nsignature's time against Laravel's clock, so `travelTo()` moves it too.\n\n```php\nuse Illuminate\\Foundation\\Testing\\TestCase;\nuse Illuminate\\Support\\Facades\\Event;\nuse Sendora\\Laravel\\Events\\CapWarning;\n\nfinal class CapWarningTest extends TestCase\n{\n public function testTheRouteFiresTheWarning(): void\n {\n Event::fake([CapWarning::class]);\n $body = (string) json_encode([\n 'event' => 'cap_warning',\n 'id' => '4d6f8a0b-2c4e-4f6a-8b0c-2d4e6f8a0b1c',\n 'attempt' => 1,\n 'scope' => 'account',\n 'serverId' => null,\n 'cap' => 50000,\n 'used' => 40000,\n 'periodStart' => '2026-09-01T00:00:00.000Z',\n 'periodEnd' => '2026-10-01T00:00:00.000Z',\n 'occurredAt' => '2026-09-24T09:30:00.000Z',\n ]);\n $at = now()->getTimestamp();\n $signature = \"t={$at},v1=\" . hash_hmac('sha256', \"{$at}.{$body}\", 'whsec_test');\n\n $this->call('POST', route('sendora.webhook'), server: ['HTTP_SENDORA_SIGNATURE' => $signature], content: $body)\n ->assertNoContent();\n\n Event::assertDispatched(CapWarning::class, static fn(CapWarning $event): bool => $event->webhook->used === 40000);\n }\n}\n```\n\n`Event::fake()` keeps the listeners from running, so this checks that\nthe route fires the event. `Event::assertListening()` then checks that\nyour listener hears it. Leave the events unfaked to run the listener\nitself.\n\nFor tests that talk to Sendora itself, use the key of a test server,\nwhich starts `sk_test_`. A test server goes through everything a live\none does, but nothing it accepts is delivered.\n\n## Logging\n\nThe clients log each retry at debug level on Laravel's logger, with the\nmethod, the path, the code and the wait. The route logs a refused\ndelivery's code at debug level. Neither logs a key, an address or a\nmessage's content.\n\n## Versions\n\nThe package follows semantic versioning. A breaking change comes only in\na major version: a renamed or removed class, method, setting or event, or\na changed default. A minor adds them, and a patch fixes a bug. Each release\nrequires the major of `sendora/sendora-php` that it was tested with, from\n1.0.0, and Composer installs it beside the package. `CHANGELOG.md` lists\nwhat each release changed.\n\n## Docs\n\nGuides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).\n"},{"uri":"sendora://reference/post-v1-email","title":"POST /v1/email","description":"Send one message","text":"# POST /v1/email\n\nSend one message\n\nAccepts one message for delivery and answers at once with its id. Delivery, deferral, bounce and complaint arrive later as events on the message and as webhooks. With an Idempotency-Key, a retry within 24 hours gets the first answer again, marked by the Idempotent-Replayed header, and sends nothing twice.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.email.send(message)`. Python: `sendora.email.send(**message)`. PHP: `$sendora->email->send(...$message)`.\n\n## Parameters\n\n- `idempotency-key` (header, string): Any text of 1 to 255 printable characters that identifies this request; the same key with a different body is refused.\n\n## Request body\n\n- `from` (one of 2, required): The sender. Its domain must be a verified sending domain of the server.\n- `streamId` (string (uuid)): The stream of this server the message goes on; the default transactional stream when absent.\n- `to` (array of one of 2, at least 1, required): Recipients; at most 50 across to, cc and bcc.\n- `cc` (array of one of 2): Copied recipients, shown in the message.\n- `bcc` (array of one of 2): Blind-copied recipients.\n- `subject` (string (at least 1, at most 998 characters), required): The subject line.\n- `text` (string (at least 1 character)): The plain-text part. At least one of text and html is required.\n- `html` (string (at least 1 character)): The HTML part.\n- `headers` (map of string to string): Custom headers by name, at most 20. Sendora writes these itself, so a message may not set them: From, Sender, To, Cc, Bcc, Subject, Date, Message-ID, MIME-Version, Return-Path, Received, Delivered-To, DKIM-Signature, DomainKey-Signature, Authentication-Results, Received-SPF, and any name that starts with Content-, Resent-, ARC- or X-Kumo.\n- `attachments` (array of object, at most 20): At most 20. A message with its attachments encoded may be at most 10 MB.\n - `name` (string, required)\n - `content` (string (base64), required)\n - `contentType` (string)\n - `contentId` (string or null)\n- `tag` (string or null): A label of up to 100 characters, returned with the message and its events.\n- `metadata` (map of string to string): Up to 20 key-value pairs of your own, returned with the message and its events.\n\nThe forms of `from`, and of each item of `to`, `cc` and `bcc`:\n\n- Form 1: string (email)\n- Form 2\n - `email` (string (email), required)\n - `name` (string (at least 1, at most 200 characters) or null): A display name of up to 200 characters.\n\nExample:\n\n```json\n{\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your invoice for September\",\n \"text\": \"Hi Anna, your invoice is attached.\",\n \"attachments\": [\n {\n \"name\": \"invoice.pdf\",\n \"content\": \"JVBERi0xLjcK\",\n \"contentType\": \"application/pdf\"\n }\n ],\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n }\n}\n```\n\n## Responses\n\n### 200 Accepted for delivery.\n\n- `messageId` (string (uuid), required): The id the message log, the events and the webhooks refer to.\n- `status` (\"accepted\", required)\n- `submittedAt` (string (date-time), required): When the message was accepted.\n- `test` (boolean, required): True when the server is a test server: the message goes through everything but delivery, and nobody receives it.\n\nHeaders:\n\n- `Idempotent-Replayed` (\"true\"): true when this is the first answer again, to a request sent before under the same Idempotency-Key; absent otherwise.\n\nExample:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `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. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |\n| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |\n| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |\n| `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. |\n| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |\n| `rate_limited` | 429 | More was sent within a minute than the limit allows. |\n| `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. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n\nHeaders sent with an error:\n\n- `Retry-After` (integer) on 429: With rate_limited, the seconds until a send is accepted; absent with monthly_cap_reached.\n- `Retry-After` (integer) on 503: Seconds to wait before trying again.\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `recipient_suppressed`\n\n- `streamId` (string (uuid), required): The stream whose suppression list refused the send.\n- `suppressed` (array of object, required): Each refused address, lower-cased, with the reason it is on the list.\n - `address` (string, required)\n - `reason` (\"hard_bounce\" | \"spam_complaint\" | \"manual\" | \"unsubscribe\", required)\n\nExample:\n\n```json\n{\n \"error\": \"recipient_suppressed\",\n \"message\": \"One or more recipients are on the suppression list of this stream; suppressed says which and why.\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"suppressed\": [\n {\n \"address\": \"anna@example.com\",\n \"reason\": \"hard_bounce\"\n }\n ]\n}\n```\n\n### `test_address_on_live_server`\n\n- `addresses` (array of string, required): The recipients at simulator.sendora.se, each once.\n\nExample:\n\n```json\n{\n \"error\": \"test_address_on_live_server\",\n \"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.\",\n \"addresses\": [\n \"hardbounce@simulator.sendora.se\"\n ]\n}\n```\n\n### `rate_limited`\n\n- `scope` (\"account\" | \"server\" | \"test\", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.\n- `limit` (integer, required): Emails per minute allowed for that scope.\n- `retryAfter` (integer, required): Seconds until the next send is accepted; also sent as the Retry-After header.\n\nExample:\n\n```json\n{\n \"error\": \"rate_limited\",\n \"message\": \"This server may send 60 emails per minute. Try again in 12 seconds.\",\n \"scope\": \"server\",\n \"limit\": 60,\n \"retryAfter\": 12\n}\n```\n\n### `monthly_cap_reached`\n\n- `scope` (\"account\" | \"server\" | \"test\", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.\n- `cap` (integer, required): Emails the scope may send in its month: from the day and time of the first payment for a paying account's live servers, otherwise the calendar month in UTC. After a cancellation, the emails that the monthly fee includes.\n- `used` (integer, required): Emails sent this month, the refused send not counted. After a cancellation, received emails count too.\n- `resetsAt` (string (date-time), required): When the month ends and the count starts again.\n\nExample:\n\n```json\n{\n \"error\": \"monthly_cap_reached\",\n \"message\": \"This account has used 50000 of 50000 emails this month. The cap resets at 2026-11-01T00:00:00.000Z.\",\n \"scope\": \"account\",\n \"cap\": 50000,\n \"used\": 50000,\n \"resetsAt\": \"2026-11-01T00:00:00.000Z\"\n}\n```"},{"uri":"sendora://reference/post-v1-email-batch","title":"POST /v1/email/batch","description":"Send up to 100 messages","text":"# POST /v1/email/batch\n\nSend up to 100 messages\n\nAccepts an array of messages and answers one result per message, in order: accepted with its id, or refused with the same error the single send would give. The Idempotency-Key is required. A retry under the same key sends nothing twice: every item accepted before is answered again with replayed set, and only the items that failed are attempted again.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.email.sendBatch(messages)`. Python: `sendora.email.send_batch(messages)`. PHP: `$sendora->email->sendBatch($messages)`.\n\n## Parameters\n\n- `idempotency-key` (header, required, string): Any text of 1 to 255 printable characters that identifies this batch; the same key with a different item is refused for that item.\n\n## Request body\n\nA array of object, at least 1, at most 100.\n\nExample:\n\n```json\n[\n {\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your invoice for September\",\n \"text\": \"Hi Anna, your invoice is attached.\",\n \"attachments\": [\n {\n \"name\": \"invoice.pdf\",\n \"content\": \"JVBERi0xLjcK\",\n \"contentType\": \"application/pdf\"\n }\n ],\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n }\n }\n]\n```\n\n## Responses\n\n### 200 Every message answered on its own; the batch as a whole never fails halfway.\n\n- `results` (array of one of 18, required): One result per message, in request order.\n\nThe forms of each item of `results`:\n\n- Form 1: The message was accepted for delivery.\n - `messageId` (string (uuid), required): The id the message log, the events and the webhooks refer to.\n - `status` (\"accepted\", required)\n - `submittedAt` (string (date-time), required): When the message was accepted.\n - `test` (boolean, required): True when the server is a test server: the message goes through everything but delivery, and nobody receives it.\n - `index` (integer, required): The position of the message in the request.\n - `replayed` (boolean, required): True when an earlier request under the same key already sent this item.\n\nA refusal carries `message`, `index`, `status` and `error`, one of these codes, with the fields the code adds:\n\n| Code | Meaning | Adds |\n| --- | --- | --- |\n| `invalid_request` | The message does not match what a send takes. | `issues` (`path`, `message`) |\n| `from_domain_not_verified` | The From domain is not a verified sending domain of this server. | |\n| `stream_not_found` | The streamId names no stream of this server. | |\n| `stream_archived` | The stream is archived and takes no new messages. | |\n| `stream_paused` | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. | |\n| `stream_not_sendable` | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. | |\n| `unsubscribe_placeholder_missing` | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. | |\n| `list_unsubscribe_reserved` | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. | |\n| `recipient_suppressed` | One or more recipients are on the suppression list of the stream. | `streamId`, `suppressed` (`address`, `reason`) |\n| `test_address_on_live_server` | Addresses at simulator.sendora.se act out an outcome on a test server; a live server never sends to them. | `addresses` |\n| `idempotency_key_mismatch` | The Idempotency-Key was already used for a different request. | |\n| `rate_limited` | More was sent within a minute than the limit allows. | `scope`, `limit`, `retryAfter` |\n| `monthly_cap_reached` | 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. | `scope`, `cap`, `used`, `resetsAt` |\n| `sending_disabled` | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. | |\n| `account_paused` | The account is paused by Sendora. | |\n| `account_not_active` | The account is closed, or not yet approved for a live server's sends or an inbound stream. | |\n| `payment_required` | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. | |\n\nExample:\n\n```json\n{\n \"results\": [\n {\n \"index\": 0,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"replayed\": false\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `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. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n\nHeaders sent with an error:\n\n- `Retry-After` (integer) on 503: Seconds to wait before trying again.\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-broadcasts","title":"GET /v1/broadcasts","description":"List the broadcasts","text":"# GET /v1/broadcasts\n\nList the broadcasts\n\nThe broadcasts of this server, newest first, a page at a time.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.list(query)`. Python: `sendora.broadcasts.list(limit=..., after=...)`. PHP: `$sendora->broadcasts->list(limit: $limit, after: $after)`.\n\n## Parameters\n\n- `limit` (query, integer (1 to 100)): Page size, 1 to 100.\n- `after` (query, string (uuid)): The `next` value of the previous page.\n\n## Responses\n\n### 200 A page of the broadcasts, newest first.\n\n- `broadcasts` (array of object, required)\n - `broadcastId` (string (uuid), required)\n - `streamId` (string (uuid), required): The broadcast stream the messages go on.\n - `status` (\"accepted\" | \"processing\" | \"completed\" | \"cancelled\", required): accepted until the first message leaves, processing while messages are leaving, completed when every message has left or failed, cancelled once cancelled.\n - `from` (string, required)\n - `subject` (string, required)\n - `tag` (string or null, required)\n - `metadata` (map of string to string, required)\n - `total` (integer, required): Messages stored, after suppressed addresses were dropped.\n - `released` (integer, required): Messages handed to the mail server so far.\n - `failed` (integer, required): Messages that could not be handed over, cancelled ones included.\n - `suppressed` (integer, required): Addresses dropped from the list for standing on the stream’s suppression list.\n - `submittedAt` (string (date-time), required)\n - `completedAt` (string (date-time) or null, required): Set once nothing is left to send, or on cancel.\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"broadcasts\": [\n {\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"status\": \"processing\",\n \"from\": \"news@acme.se\",\n \"subject\": \"News from Acme in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 0,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": null\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-broadcasts","title":"POST /v1/broadcasts","description":"Send a broadcast","text":"# POST /v1/broadcasts\n\nSend a broadcast\n\nStores the content once and one message per entry of `messages`, up to 50,000, on a broadcast stream, in one transaction. Each message is a message in the log with its own events and webhooks, sent with the recipient’s own unsubscribe link where `{{ unsubscribe_url }}` stands. Each message may carry `substitutions`, up to 20 strings that replace `{{ key }}` in the subject, the text and the HTML, escaped in the HTML; a key the content names must be given by every message. Addresses on the stream’s suppression list are dropped and counted; a list with nothing left, a missing placeholder, a missing substitution, a transactional or archived stream and a count over the monthly cap are refused whole. The Idempotency-Key is required: a retry under the same key answers the same broadcast.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.send(broadcast)`. Python: `sendora.broadcasts.send(**broadcast)`. PHP: `$sendora->broadcasts->send(...$broadcast)`.\n\n## Parameters\n\n- `idempotency-key` (header, required, string): Any text of 1 to 255 printable characters that identifies this broadcast; the same key with a different body is refused.\n\n## Request body\n\n- `streamId` (string (uuid), required): The broadcast stream of this server the messages go on.\n- `from` (one of 2, required): The sender. Its domain must be a verified sending domain of the server.\n- `subject` (string (at least 1, at most 998 characters), required): The subject line.\n- `text` (string (at least 1 character)): The plain-text part, with {{ unsubscribe_url }} where the link goes. At least one of text and html is required.\n- `html` (string (at least 1 character)): The HTML part, with {{ unsubscribe_url }} where the link goes.\n- `headers` (map of string to string): Custom headers on every message, at most 20. The List-Unsubscribe pair is Sendora’s too. Sendora writes these itself, so a message may not set them: From, Sender, To, Cc, Bcc, Subject, Date, Message-ID, MIME-Version, Return-Path, Received, Delivered-To, DKIM-Signature, DomainKey-Signature, Authentication-Results, Received-SPF, and any name that starts with Content-, Resent-, ARC- or X-Kumo.\n- `attachments` (array of object, at most 20): At most 20, sent with every message. A message with its attachments encoded may be at most 10 MB.\n - `name` (string, required)\n - `content` (string (base64), required)\n - `contentType` (string)\n - `contentId` (string or null)\n- `tag` (string or null): A label of up to 100 characters, returned with the message and its events.\n- `metadata` (map of string to string): Up to 20 key-value pairs of your own, on the broadcast and on every message.\n- `messages` (array of object, at least 1, at most 50000, required): One entry per message, at most 50000; a larger list is several broadcasts under one tag.\n - `to` (array of one of 2, at least 1, required): Recipients of this message; at most 50 across to, cc and bcc.\n - `cc` (array of one of 2): Copied recipients, shown in the message.\n - `bcc` (array of one of 2): Blind-copied recipients.\n - `metadata` (map of string to string): Up to 20 key-value pairs for this message, merged over the broadcast's.\n - `headers` (map of string to string): Custom headers for this message, merged over the broadcast's.\n - `substitutions` (map of string to string): Up to 20 strings that replace {{ key }} in the subject, the text and the HTML of this message; a key the content names must be given.\n\nThe forms of `from`:\n\n- Form 1: string (email)\n- Form 2\n - `email` (string (email), required)\n - `name` (string (at least 1, at most 200 characters) or null): A display name of up to 200 characters.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"from\": {\n \"email\": \"news@acme.se\",\n \"name\": \"Acme\"\n },\n \"subject\": \"News from Acme in October\",\n \"text\": \"Hi!\\n\\nHere is the news for October.\\n\\nRather not get these? {{ unsubscribe_url }}\",\n \"html\": \"<p>Hi!</p><p>Here is the news for October.</p><p><a href=\\\"{{ unsubscribe_url }}\\\">Unsubscribe</a></p>\",\n \"tag\": \"newsletter-2026-10\",\n \"messages\": [\n {\n \"to\": [\n \"anna@example.com\"\n ],\n \"metadata\": {\n \"customer\": \"1042\"\n }\n },\n {\n \"to\": [\n {\n \"email\": \"bo@example.com\",\n \"name\": \"Bo Berg\"\n }\n ]\n }\n ]\n}\n```\n\n## Responses\n\n### 200 The broadcast was stored; Sendora sends its messages from here.\n\n- `broadcastId` (string (uuid), required): The id the messages, the progress and the cancel refer to.\n- `status` (\"accepted\", required)\n- `total` (integer, required): Messages stored; each is a message in the log.\n- `suppressed` (integer, required): Addresses dropped for standing on the stream’s suppression list.\n- `submittedAt` (string (date-time), required)\n- `test` (boolean, required): True when the server is a test server: the messages go through everything but delivery, and nobody receives them.\n\nHeaders:\n\n- `Idempotent-Replayed` (\"true\"): true when this is the first answer again, to a request sent before under the same Idempotency-Key; absent otherwise.\n\nExample:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"status\": \"accepted\",\n \"total\": 2,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"test\": false\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `idempotency_key_required` | 400 | Batch sends need an Idempotency-Key header. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `payment_required` | 402 | The first payment has not arrived, an invoice is unpaid, payment is paused, or the subscription has ended. |\n| `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. |\n| `account_paused` | 403 | The account is paused by Sendora. |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `request_too_large` | 413 | The body exceeds 10 MB. |\n| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |\n| `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; the streamId names a transactional one. |\n| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |\n| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |\n| `substitution_missing` | 422 | The subject, text or HTML names a {{ key }} that a message does not give. |\n| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |\n| `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. |\n| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |\n| `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. |\n| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |\n\nHeaders sent with an error:\n\n- `Retry-After` (integer) on 503: Seconds to wait before trying again.\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `substitution_missing`\n\n- `index` (integer, required): The position of the message in `messages`.\n- `keys` (array of string, required): The keys the content names that the message lacks.\n\nExample:\n\n```json\n{\n \"error\": \"substitution_missing\",\n \"message\": \"The content names {{ firstName }}, which message 1 does not give.\",\n \"index\": 1,\n \"keys\": [\n \"firstName\"\n ]\n}\n```\n\n### `recipient_suppressed`\n\n- `streamId` (string (uuid), required): The stream whose suppression list refused the send.\n- `suppressed` (array of object, required): Each refused address, lower-cased, with the reason it is on the list.\n - `address` (string, required)\n - `reason` (\"hard_bounce\" | \"spam_complaint\" | \"manual\" | \"unsubscribe\", required)\n\nExample:\n\n```json\n{\n \"error\": \"recipient_suppressed\",\n \"message\": \"One or more recipients are on the suppression list of this stream; suppressed says which and why.\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"suppressed\": [\n {\n \"address\": \"anna@example.com\",\n \"reason\": \"hard_bounce\"\n }\n ]\n}\n```\n\n### `test_address_on_live_server`\n\n- `addresses` (array of string, required): The recipients at simulator.sendora.se, each once.\n\nExample:\n\n```json\n{\n \"error\": \"test_address_on_live_server\",\n \"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.\",\n \"addresses\": [\n \"hardbounce@simulator.sendora.se\"\n ]\n}\n```\n\n### `monthly_cap_reached`\n\n- `scope` (\"account\" | \"server\" | \"test\", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.\n- `cap` (integer, required): Emails the scope may send in its month: from the day and time of the first payment for a paying account's live servers, otherwise the calendar month in UTC. After a cancellation, the emails that the monthly fee includes.\n- `used` (integer, required): Emails sent this month, the refused send not counted. After a cancellation, received emails count too.\n- `resetsAt` (string (date-time), required): When the month ends and the count starts again.\n\nExample:\n\n```json\n{\n \"error\": \"monthly_cap_reached\",\n \"message\": \"This account has used 50000 of 50000 emails this month. The cap resets at 2026-11-01T00:00:00.000Z.\",\n \"scope\": \"account\",\n \"cap\": 50000,\n \"used\": 50000,\n \"resetsAt\": \"2026-11-01T00:00:00.000Z\"\n}\n```"},{"uri":"sendora://reference/get-v1-broadcasts-id","title":"GET /v1/broadcasts/{id}","description":"Get a broadcast","text":"# GET /v1/broadcasts/{id}\n\nGet a broadcast\n\nThe broadcast and how far it has come: its status, the counts of messages released and failed, and what tracking recorded of its opens and clicks.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.get(broadcastId)`. Python: `sendora.broadcasts.get(broadcast_id)`. PHP: `$sendora->broadcasts->get($broadcastId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The broadcast id.\n\n## Responses\n\n### 200 A broadcast, how far it has come and what tracking recorded of it.\n\n- `broadcastId` (string (uuid), required)\n- `streamId` (string (uuid), required): The broadcast stream the messages go on.\n- `status` (\"accepted\" | \"processing\" | \"completed\" | \"cancelled\", required): accepted until the first message leaves, processing while messages are leaving, completed when every message has left or failed, cancelled once cancelled.\n- `from` (string, required)\n- `subject` (string, required)\n- `tag` (string or null, required)\n- `metadata` (map of string to string, required)\n- `total` (integer, required): Messages stored, after suppressed addresses were dropped.\n- `released` (integer, required): Messages handed to the mail server so far.\n- `failed` (integer, required): Messages that could not be handed over, cancelled ones included.\n- `suppressed` (integer, required): Addresses dropped from the list for standing on the stream’s suppression list.\n- `submittedAt` (string (date-time), required)\n- `completedAt` (string (date-time) or null, required): Set once nothing is left to send, or on cancel.\n- `opened` (integer, required): Recipients with an open, machines’ included, or a click that was likely a person’s.\n- `openedByPeople` (integer, required): Those of them likely to be people.\n- `clicked` (integer, required): Recipients who clicked a link, machines’ included.\n- `clickedByPeople` (integer, required): Those of them likely to be people.\n- `links` (array of object, required): Each tracked link that has a click, by its index, counted from the clicks each recipient keeps.\n - `index` (integer, required): The link’s position in the HTML, the same in every copy, counting from 0.\n - `url` (string or null, required): Where the first click led, since a substitution can give a link another URL per recipient; null once it has gone with the content.\n - `clicked` (integer, required): Recipients who clicked this link, machines’ included.\n - `clickedByPeople` (integer, required): Those of them likely to be people.\n\nExample:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"status\": \"processing\",\n \"from\": \"news@acme.se\",\n \"subject\": \"News from Acme in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 0,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": null,\n \"opened\": 1,\n \"openedByPeople\": 1,\n \"clicked\": 1,\n \"clickedByPeople\": 1,\n \"links\": [\n {\n \"index\": 0,\n \"url\": \"https://acme.se/news/october\",\n \"clicked\": 1,\n \"clickedByPeople\": 1\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such broadcast of this server. |"},{"uri":"sendora://reference/post-v1-broadcasts-id-cancel","title":"POST /v1/broadcasts/{id}/cancel","description":"Cancel a broadcast","text":"# POST /v1/broadcasts/{id}/cancel\n\nCancel a broadcast\n\nHolds every message of the broadcast that has not been handed to the mail server yet; those count as failed. A message already on its way is delivered. A completed or cancelled broadcast answers 409.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.broadcasts.cancel(broadcastId)`. Python: `sendora.broadcasts.cancel(broadcast_id)`. PHP: `$sendora->broadcasts->cancel($broadcastId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The broadcast id.\n\n## Responses\n\n### 200 A broadcast and how far it has come.\n\n- `broadcastId` (string (uuid), required)\n- `streamId` (string (uuid), required): The broadcast stream the messages go on.\n- `status` (\"accepted\" | \"processing\" | \"completed\" | \"cancelled\", required): accepted until the first message leaves, processing while messages are leaving, completed when every message has left or failed, cancelled once cancelled.\n- `from` (string, required)\n- `subject` (string, required)\n- `tag` (string or null, required)\n- `metadata` (map of string to string, required)\n- `total` (integer, required): Messages stored, after suppressed addresses were dropped.\n- `released` (integer, required): Messages handed to the mail server so far.\n- `failed` (integer, required): Messages that could not be handed over, cancelled ones included.\n- `suppressed` (integer, required): Addresses dropped from the list for standing on the stream’s suppression list.\n- `submittedAt` (string (date-time), required)\n- `completedAt` (string (date-time) or null, required): Set once nothing is left to send, or on cancel.\n\nExample:\n\n```json\n{\n \"broadcastId\": \"2f1c8a4e-3b6d-4f0a-9c21-7d5e6a8b9c01\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"status\": \"cancelled\",\n \"from\": \"news@acme.se\",\n \"subject\": \"News from Acme in October\",\n \"tag\": \"newsletter-2026-10\",\n \"metadata\": {},\n \"total\": 2,\n \"released\": 1,\n \"failed\": 1,\n \"suppressed\": 0,\n \"submittedAt\": \"2026-10-01T08:00:00.000Z\",\n \"completedAt\": \"2026-10-01T08:05:00.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such broadcast of this server. |\n| `broadcast_not_open` | 409 | The broadcast is completed or cancelled already; nothing is left to cancel. |"},{"uri":"sendora://reference/get-v1-messages-id","title":"GET /v1/messages/{id}","description":"Read a message","text":"# GET /v1/messages/{id}\n\nRead a message\n\nThe message as the log holds it, its attachments described but not returned, and the events on its timeline, with what tracking recorded. Bodies are never returned.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.messages.get(messageId)`. Python: `sendora.messages.get(message_id)`. PHP: `$sendora->messages->get($messageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The message id.\n\n## Responses\n\n### 200 The message with its attachments described and its timeline.\n\n- `messageId` (string (uuid), required)\n- `streamId` (string (uuid), required): The stream the message went on.\n- `broadcastId` (string (uuid) or null, required): The broadcast the message belongs to; null for a message sent on its own.\n- `status` (\"accepted\" | \"injected\" | \"failed\", required): `accepted` until handed to the mail server, `injected` after; `failed` if that never worked.\n- `from` (string, required)\n- `subject` (string, required)\n- `tag` (string or null, required)\n- `metadata` (map of string to string, required)\n- `submittedAt` (string (date-time), required)\n- `test` (boolean, required): True when the message went through a test server: its events were simulated and nobody received it.\n- `recipients` (array of object, required)\n - `address` (string, required)\n - `kind` (\"to\" | \"cc\" | \"bcc\", required)\n - `status` (\"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\", required): queued until the receiver answers; deferred while it keeps saying try later.\n - `tracked` (boolean, required): 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.\n - `opens` (integer, required): Opens recorded, machines’ included.\n - `clicks` (integer, required): Clicks recorded, machines’ included.\n - `firstOpenAt` (string (date-time) or null, required): The first open, or the first click that was likely a person’s. Null before that.\n - `firstHumanOpenAt` (string (date-time) or null, required): The same, counting only what was likely a person. Null before that.\n- `tracking` (object, required): Whether opens and clicks are recorded now, and why tracking may have stayed out.\n - `recordingOpens` (boolean, required): Whether new opens of this message are recorded now, by its own setting, then its broadcast’s, then its stream’s at this moment. This says nothing of what a copy already sent carries.\n - `recordingClicks` (boolean, required): The same for clicks on its links.\n - `skipped` (\"no_tracking_domain\" | \"gmail_clipping\" or null, required): Why tracking stayed out of at least one copy: no_tracking_domain when the from domain had no working tracking domain, gmail_clipping when tracked links would have taken the HTML past where Gmail clips it. Null otherwise. Each recipient’s tracked says whether its copy was tracked.\n- `attachments` (array of object, required)\n - `name` (string, required)\n - `contentType` (string, required)\n - `contentId` (string or null, required)\n - `size` (integer, required): Bytes before encoding.\n- `events` (array of object, required): Oldest first. Opens and clicks are capped as Limits says, while each recipient’s opens and clicks count them all.\n - `recipient` (string or null, required): The recipient the event is about; null when it concerns the message as a whole.\n - `type` (\"Reception\" | \"Delivery\" | \"Bounce\" | \"TransientFailure\" | \"Expiration\" | \"AdminBounce\" | \"OOB\" | \"Feedback\" | \"AdminRebind\" | \"DeferredInjectionRebind\" | \"Rejection\" | \"Delayed\" | \"Open\" | \"Click\", required): Reception when the mail server took it, Delivery, TransientFailure, Bounce, OOB for a bounce report received by mail, Feedback for a spam complaint, Open and Click for what tracking recorded.\n - `at` (string (date-time), required)\n - `code` (integer or null, required): The SMTP status code the receiver answered.\n - `details` (string or null, required): The receiver’s response text.\n - `classification` (string or null, required): How a bounce was classified, such as InvalidRecipient.\n - `tracking` (object or null, required): What tracking knows of an Open or a Click; null on any other event.\n - `automated` (\"likely\" | \"unlikely\" | \"unknown\", required): Whether a machine rather than a person did it: likely, unlikely, or unknown where nobody can tell, as with Apple's privacy protection.\n - `agent` (\"apple_privacy\" | \"gmail_prefetch\" | \"gmail_proxy\" | \"yahoo_proxy\" | \"outlook_prefetch\" | \"proton_proxy\" | \"scanner\" | \"crawler\" or null, required): The proxy or machine it came through, where Sendora knows it.\n - `reasons` (array of \"privacy_proxy\" | \"prefetch_signature\" | \"delivery_timing\" | \"link_burst\" | \"scanner_network\" | \"bot_user_agent\" | \"many_networks\", required): Why it was labelled a machine’s.\n - `client` (\"apple_mail\" | \"gmail\" | \"outlook\" | \"yahoo\" | \"thunderbird\" | \"proton\" | \"browser\" | \"other\" or null, required): The mail program, where Sendora knows it.\n - `device` (\"desktop\" | \"mobile\" | \"tablet\" or null, required): The kind of device, where Sendora knows it.\n - `first` (boolean, required): The recipient’s first event of this kind.\n - `firstHuman` (boolean, required): The recipient’s first event of this kind that was likely a person’s.\n - `link` (object or null, required): The link clicked; null on an Open.\n - `index` (integer, required): The link’s position in the HTML, the same in every copy, counting from 0.\n - `url` (string or null, required): Where the link led; null once it has gone with the message’s text and HTML.\n\nExample:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your invoice for September\",\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": true,\n \"opens\": 2,\n \"clicks\": 1,\n \"firstOpenAt\": \"2026-09-15T12:03:10.000Z\",\n \"firstHumanOpenAt\": \"2026-09-15T12:03:10.000Z\"\n }\n ],\n \"tracking\": {\n \"recordingOpens\": true,\n \"recordingClicks\": true,\n \"skipped\": null\n },\n \"attachments\": [\n {\n \"name\": \"invoice.pdf\",\n \"contentType\": \"application/pdf\",\n \"contentId\": null,\n \"size\": 48213\n }\n ],\n \"events\": [\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Reception\",\n \"at\": \"2026-09-15T12:00:00.000Z\",\n \"code\": 250,\n \"details\": null,\n \"classification\": null,\n \"tracking\": null\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Delivery\",\n \"at\": \"2026-09-15T12:00:02.000Z\",\n \"code\": 250,\n \"details\": \"2.0.0 OK\",\n \"classification\": null,\n \"tracking\": null\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Open\",\n \"at\": \"2026-09-15T12:03:10.000Z\",\n \"code\": null,\n \"details\": null,\n \"classification\": null,\n \"tracking\": {\n \"automated\": \"unlikely\",\n \"agent\": null,\n \"reasons\": [],\n \"client\": \"apple_mail\",\n \"device\": \"mobile\",\n \"first\": true,\n \"firstHuman\": true,\n \"link\": null\n }\n },\n {\n \"recipient\": \"anna@example.com\",\n \"type\": \"Click\",\n \"at\": \"2026-09-15T12:03:40.000Z\",\n \"code\": null,\n \"details\": null,\n \"classification\": null,\n \"tracking\": {\n \"automated\": \"unlikely\",\n \"agent\": null,\n \"reasons\": [],\n \"client\": \"apple_mail\",\n \"device\": \"mobile\",\n \"first\": true,\n \"firstHuman\": true,\n \"link\": {\n \"index\": 0,\n \"url\": \"https://example.se/invoices/2026-0912\"\n }\n }\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such message of this server. |"},{"uri":"sendora://reference/post-v1-messages-search","title":"POST /v1/messages/search","description":"Search the message log","text":"# POST /v1/messages/search\n\nSearch the message log\n\nThe messages of the server, newest first, one page at a time. A POST so that an address travels in the body and never in a URL. Messages older than the retention window are gone.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.messages.search(filters)`. Python: `sendora.messages.search(**filters)`. PHP: `$sendora->messages->search(...$filters)`.\n\n## Request body\n\n- `recipient` (string (at least 3, at most 254 characters)): Messages to this address, in to, cc or bcc. A whole address matches exactly. Anything else of three characters or more matches anywhere in an address, such as kommun.se.\n- `streamId` (string (uuid)): Messages on this stream of the server.\n- `broadcastId` (string (uuid)): Messages of this broadcast.\n- `tag` (string (at least 1, at most 100 characters))\n- `status` (one of 2): Messages with at least one recipient in this state, or in any of these states.\n- `from` (string (date-time)): Submitted at or after this time.\n- `to` (string (date-time)): Submitted before this time.\n- `limit` (integer (1 to 100)): Page size, 1 to 100.\n- `after` (string (uuid)): The `next` value of the previous page.\n\nThe forms of `status`:\n\n- Form 1: \"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\"\n- Form 2: array of \"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\", at least 1, at most 5\n\nExample:\n\n```json\n{\n \"recipient\": \"anna@example.com\",\n \"status\": \"bounced\",\n \"limit\": 20\n}\n```\n\n## Responses\n\n### 200 A page of the messages that match, newest first.\n\n- `messages` (array of object, required)\n - `messageId` (string (uuid), required)\n - `streamId` (string (uuid), required): The stream the message went on.\n - `broadcastId` (string (uuid) or null, required): The broadcast the message belongs to; null for a message sent on its own.\n - `status` (\"accepted\" | \"injected\" | \"failed\", required): `accepted` until handed to the mail server, `injected` after; `failed` if that never worked.\n - `from` (string, required)\n - `subject` (string, required)\n - `tag` (string or null, required)\n - `metadata` (map of string to string, required)\n - `submittedAt` (string (date-time), required)\n - `test` (boolean, required): True when the message went through a test server: its events were simulated and nobody received it.\n - `recipients` (array of object, required)\n - `address` (string, required)\n - `kind` (\"to\" | \"cc\" | \"bcc\", required)\n - `status` (\"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\", required): queued until the receiver answers; deferred while it keeps saying try later.\n - `tracked` (boolean, required): 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.\n - `opens` (integer, required): Opens recorded, machines’ included.\n - `clicks` (integer, required): Clicks recorded, machines’ included.\n - `firstOpenAt` (string (date-time) or null, required): The first open, or the first click that was likely a person’s. Null before that.\n - `firstHumanOpenAt` (string (date-time) or null, required): The same, counting only what was likely a person. Null before that.\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"messages\": [\n {\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"broadcastId\": null,\n \"status\": \"injected\",\n \"from\": \"no-reply@example.se\",\n \"subject\": \"Your invoice for September\",\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\",\n \"test\": false,\n \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\",\n \"tracked\": true,\n \"opens\": 2,\n \"clicks\": 1,\n \"firstOpenAt\": \"2026-09-15T12:03:10.000Z\",\n \"firstHumanOpenAt\": \"2026-09-15T12:03:10.000Z\"\n }\n ]\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-inbound-search","title":"POST /v1/inbound/search","description":"Search received messages","text":"# POST /v1/inbound/search\n\nSearch received messages\n\nThe messages the server has received, newest first, one page at a time. A POST so that an address travels in the body and never in a URL. The sender is matched by keyed hash, so the search itself stores no address. Messages older than 13 months are gone.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inbound.search(filters)`. Python: `sendora.inbound.search(**filters)`. PHP: `$sendora->inbound->search(...$filters)`.\n\n## Request body\n\n- `streamId` (string (uuid)): Messages received on this inbound stream of the server.\n- `from` (string (email)): Messages from this address, in the envelope or the From header.\n- `recipient` (string (email)): Messages sent to this address of yours, the envelope recipient.\n- `mailboxHash` (string (at most 255 characters)): Messages whose address carried this text after the plus sign.\n- `receivedFrom` (string (date-time)): Accepted at or after this time.\n- `receivedTo` (string (date-time)): Accepted before this time.\n- `limit` (integer (1 to 100)): Page size, 1 to 100.\n- `after` (string (uuid)): The `next` value of the previous page.\n\nExample:\n\n```json\n{\n \"from\": \"anna@example.com\",\n \"receivedFrom\": \"2026-09-15T00:00:00+02:00\",\n \"limit\": 20\n}\n```\n\n## Responses\n\n### 200 A page of the received messages that match, newest first.\n\n- `messages` (array of object, required)\n - `inboundMessageId` (string (uuid), required)\n - `streamId` (string (uuid), required): The inbound stream that received it.\n - `receivedAt` (string (date-time), required): When the message was accepted from the sending server.\n - `envelopeRecipient` (string, required): The address of yours the message was sent to.\n - `mailboxHash` (string or null, required): The text after the plus sign in that address, when the sender used one.\n - `sizeBytes` (integer, required)\n - `attachmentCount` (integer, required)\n - `hasText` (boolean, required)\n - `hasHtml` (boolean, required)\n - `parseIssue` (string or null, required): What the parser met; null when the message parsed clean. A hard issue empties the parsed parts, and the raw message stays: `no_headers`, `header_block_too_large`, `too_many_headers`, `too_many_parts`, `nesting_too_deep`, `missing_boundary`, `decoded_too_large`, `parse_timeout`, `parser_error`. A soft issue keeps them and says what was changed or dropped: `truncated_multipart`, `too_many_attachments`, `unknown_transfer_encoding`, `undecodable_container`, `filename_sanitised`, `control_chars_stripped`, `multiple_from`, `duplicate_header`, `malformed_header_dropped`, `address_list_truncated`, `header_value_truncated`. A message with several names the hard one, or the first soft one in this order.\n - `authentication` (object, required): What Sendora found when it checked the message; unchecked until the checks have run.\n - `spf` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the address in MAIL FROM.\n - `spfHelo` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the name the sending server gave in HELO.\n - `dkim` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarc` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarcPolicy` (\"none\" | \"quarantine\" | \"reject\" or null, required): What the sender’s domain asks for; told only when DMARC failed.\n - `arc` (\"none\" | \"pass\" | \"fail\" | \"unchecked\", required)\n - `checkedAt` (string (date-time) or null, required): When the checks ran; null until they have.\n - `contentAvailable` (boolean, required): False once the stream’s content window has passed; only the reference remains.\n - `contentExpiresAt` (string (date-time), required): When the content goes.\n - `from` (object or null, required): The From header; null without it or once the content is gone.\n - `address` (string, required)\n - `name` (string or null, required)\n - `subject` (string or null, required)\n - `date` (string (date-time) or null, required): The sender’s Date header as ISO 8601, when it was a real moment.\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"messages\": [\n {\n \"inboundMessageId\": \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"receivedAt\": \"2026-09-19T08:00:02.000Z\",\n \"envelopeRecipient\": \"7c9e6679742540de944be07fc1f90ae7@inbound.sendora.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 48213,\n \"attachmentCount\": 1,\n \"hasText\": true,\n \"hasHtml\": false,\n \"parseIssue\": null,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-19T08:00:03.000Z\"\n },\n \"contentAvailable\": true,\n \"contentExpiresAt\": \"2026-10-19T08:00:02.000Z\",\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"subject\": \"A question about my order\",\n \"date\": \"2026-09-19T08:00:00.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-inbound-id","title":"GET /v1/inbound/{id}","description":"Read a received message","text":"# GET /v1/inbound/{id}\n\nRead a received message\n\nThe message with its envelope, parties, headers, text, HTML and attachment descriptors; the attachment bytes and the raw message are downloads of their own. Once the stream’s content window has passed, the content fields are null and contentAvailable is false.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inbound.get(inboundMessageId)`. Python: `sendora.inbound.get(inbound_message_id)`. PHP: `$sendora->inbound->get($inboundMessageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n\n## Responses\n\n### 200 The received message with everything but the attachment bytes.\n\n- `inboundMessageId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream that received it.\n- `receivedAt` (string (date-time), required): When the message was accepted from the sending server.\n- `envelopeRecipient` (string, required): The address of yours the message was sent to.\n- `mailboxHash` (string or null, required): The text after the plus sign in that address, when the sender used one.\n- `sizeBytes` (integer, required)\n- `attachmentCount` (integer, required)\n- `hasText` (boolean, required)\n- `hasHtml` (boolean, required)\n- `parseIssue` (string or null, required): What the parser met; null when the message parsed clean. A hard issue empties the parsed parts, and the raw message stays: `no_headers`, `header_block_too_large`, `too_many_headers`, `too_many_parts`, `nesting_too_deep`, `missing_boundary`, `decoded_too_large`, `parse_timeout`, `parser_error`. A soft issue keeps them and says what was changed or dropped: `truncated_multipart`, `too_many_attachments`, `unknown_transfer_encoding`, `undecodable_container`, `filename_sanitised`, `control_chars_stripped`, `multiple_from`, `duplicate_header`, `malformed_header_dropped`, `address_list_truncated`, `header_value_truncated`. A message with several names the hard one, or the first soft one in this order.\n- `authentication` (object, required): What Sendora found when it checked the message; unchecked until the checks have run.\n - `spf` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the address in MAIL FROM.\n - `spfHelo` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the name the sending server gave in HELO.\n - `dkim` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarc` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarcPolicy` (\"none\" | \"quarantine\" | \"reject\" or null, required): What the sender’s domain asks for; told only when DMARC failed.\n - `arc` (\"none\" | \"pass\" | \"fail\" | \"unchecked\", required)\n - `checkedAt` (string (date-time) or null, required): When the checks ran; null until they have.\n- `contentAvailable` (boolean, required): False once the stream’s content window has passed; only the reference remains.\n- `contentExpiresAt` (string (date-time), required): When the content goes.\n- `from` (object or null, required): The From header; null without it or once the content is gone.\n - `address` (string, required)\n - `name` (string or null, required)\n- `subject` (string or null, required)\n- `date` (string (date-time) or null, required): The sender’s Date header as ISO 8601, when it was a real moment.\n- `envelope` (object or null, required): Null once the content is gone.\n - `sender` (string or null, required): The MAIL FROM address; null for a bounce.\n- `replyTo` (array of object, required)\n - `address` (string, required)\n - `name` (string or null, required)\n- `to` (array of object, required): Up to 100 entries; toCount is the whole number.\n - `address` (string, required)\n - `name` (string or null, required)\n- `toCount` (integer, required)\n- `cc` (array of object, required): Up to 100 entries; ccCount is the whole number.\n - `address` (string, required)\n - `name` (string or null, required)\n- `ccCount` (integer, required)\n- `messageIdHeader` (string or null, required)\n- `inReplyTo` (string or null, required)\n- `references` (array of string, required)\n- `headers` (array of object or null, required): Every header in order; null once the content is gone.\n - `name` (string, required)\n - `value` (string, required)\n- `text` (string or null, required): The plain-text body; null without one or once the content is gone.\n- `html` (string or null, required): The HTML body as received; null without one or once the content is gone.\n- `attachments` (array of object, required)\n - `attachmentId` (string (uuid), required)\n - `position` (integer, required): Its place among the message’s attachments, from 0.\n - `name` (string or null, required): The filename, sanitised; null once the content is gone.\n - `contentType` (string or null, required): The type the sender declared; every download is served as application/octet-stream.\n - `contentId` (string or null, required): The Content-ID an HTML body refers to with cid:.\n - `size` (integer, required): Bytes.\n - `inline` (boolean, required): True for a part shown in the body rather than offered as a file.\n\nExample:\n\n```json\n{\n \"inboundMessageId\": \"4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"receivedAt\": \"2026-09-19T08:00:02.000Z\",\n \"envelopeRecipient\": \"7c9e6679742540de944be07fc1f90ae7@inbound.sendora.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 48213,\n \"attachmentCount\": 1,\n \"hasText\": true,\n \"hasHtml\": false,\n \"parseIssue\": null,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-19T08:00:03.000Z\"\n },\n \"contentAvailable\": true,\n \"contentExpiresAt\": \"2026-10-19T08:00:02.000Z\",\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"subject\": \"A question about my order\",\n \"date\": \"2026-09-19T08:00:00.000Z\",\n \"envelope\": {\n \"sender\": \"anna@example.com\"\n },\n \"replyTo\": [],\n \"to\": [\n {\n \"address\": \"7c9e6679742540de944be07fc1f90ae7@inbound.sendora.se\",\n \"name\": null\n }\n ],\n \"toCount\": 1,\n \"cc\": [],\n \"ccCount\": 0,\n \"messageIdHeader\": \"<question-1@example.com>\",\n \"inReplyTo\": null,\n \"references\": [],\n \"headers\": [\n {\n \"name\": \"From\",\n \"value\": \"Anna Andersson <anna@example.com>\"\n },\n {\n \"name\": \"Subject\",\n \"value\": \"A question about my order\"\n }\n ],\n \"text\": \"Hi!\\n\",\n \"html\": null,\n \"attachments\": [\n {\n \"attachmentId\": \"9b7e2c41-3f6d-4a8e-b2c5-1d0f7e6a9c58\",\n \"position\": 0,\n \"name\": \"invoice.pdf\",\n \"contentType\": \"application/pdf\",\n \"contentId\": null,\n \"size\": 46102,\n \"inline\": false\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound message of this server. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |"},{"uri":"sendora://reference/get-v1-inbound-id-raw","title":"GET /v1/inbound/{id}/raw","description":"Download the raw message","text":"# GET /v1/inbound/{id}/raw\n\nDownload the raw message\n\nThe message byte for byte as it was received, as message/rfc822 under Content-Disposition: attachment. 410 once the content window has passed.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inbound.raw(inboundMessageId)`. Python: `sendora.inbound.raw(inbound_message_id)`. PHP: `$sendora->inbound->raw($inboundMessageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n\n## Responses\n\n### 200 The raw message.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound message of this server. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |\n| `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. |"},{"uri":"sendora://reference/get-v1-inbound-id-attachments-attachmentid","title":"GET /v1/inbound/{id}/attachments/{attachmentId}","description":"Download an attachment","text":"# GET /v1/inbound/{id}/attachments/{attachmentId}\n\nDownload an attachment\n\nThe attachment’s bytes as application/octet-stream whatever the sender declared, under Content-Disposition: attachment with its sanitised name. 410 once the content window has passed.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inbound.attachment(inboundMessageId, attachmentId)`. Python: `sendora.inbound.attachment(inbound_message_id, attachment_id)`. PHP: `$sendora->inbound->attachment($inboundMessageId, $attachmentId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n- `attachmentId` (path, required, string (uuid)): The attachment id, from the message.\n\n## Responses\n\n### 200 The attachment.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound message of this server. |\n| `content_unreadable` | 409 | The stored content of this received message cannot be opened. Sendora has been told. |\n| `content_expired` | 410 | The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months. |"},{"uri":"sendora://reference/get-v1-inbound-domains","title":"GET /v1/inbound/domains","description":"List the inbound domains","text":"# GET /v1/inbound/domains\n\nList the inbound domains\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.list()`. Python: `sendora.inbound_domains.list()`. PHP: `$sendora->inboundDomains->list()`.\n\n## Responses\n\n### 200 Every inbound domain of the server.\n\n- `domains` (array of object, required)\n - `inboundDomainId` (string (uuid), required)\n - `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n - `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n - `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n - `createdAt` (string (date-time), required)\n - `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n - `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n - `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n - `txt` (object, required): The TXT record that proves the claim; it carries a value only you were shown.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"domains\": [\n {\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-inbound-domains","title":"POST /v1/inbound/domains","description":"Claim a domain for an inbound stream","text":"# POST /v1/inbound/domains\n\nClaim a domain for an inbound stream\n\nClaims the domain for one of the server’s inbound streams and answers the two DNS records to add: an MX that brings the domain’s mail to Sendora and a TXT record that proves the claim. Mail to any address on the domain is accepted once both records are seen, which Sendora checks on its own and on request. A stream holds one domain; the first account to verify a name holds it.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.create({ streamId, domain })`. Python: `sendora.inbound_domains.create(stream_id=..., domain=...)`. PHP: `$sendora->inboundDomains->create(streamId: $streamId, domain: $domain)`.\n\n## Request body\n\n- `streamId` (string (uuid), required): An inbound stream of the server.\n- `domain` (string, required): The domain mail is sent from, such as example.se; lower-cased and IDNA-encoded.\n\nExample:\n\n```json\n{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\"\n}\n```\n\n## Responses\n\n### 201 A domain on an inbound stream and the two records it needs.\n\n- `inboundDomainId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n- `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n- `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n- `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `txt` (object, required): The TXT record that proves the claim; it carries a value only you were shown.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `domain_reserved` | 400 | The name is Sendora’s own, or a return-path host; no account can receive on it. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `inbound_domain_exists` | 409 | The stream already has a domain, or another account holds the name verified. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_archived` | 422 | The stream is archived and takes no new messages. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `inbound_domain_exists`\n\n- `inboundDomainId` (string (uuid) or null, required): The domain the stream already has, when that is the reason; null when another account holds the name verified.\n\nExample:\n\n```json\n{\n \"error\": \"inbound_domain_exists\",\n \"message\": \"The stream already has a domain; remove it first.\",\n \"inboundDomainId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-inbound-domains-id","title":"GET /v1/inbound/domains/{id}","description":"Read an inbound domain","text":"# GET /v1/inbound/domains/{id}\n\nRead an inbound domain\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.get(inboundDomainId)`. Python: `sendora.inbound_domains.get(inbound_domain_id)`. PHP: `$sendora->inboundDomains->get($inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 200 A domain on an inbound stream and the two records it needs.\n\n- `inboundDomainId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n- `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n- `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n- `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `txt` (object, required): The TXT record that proves the claim; it carries a value only you were shown.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound domain of this server. |"},{"uri":"sendora://reference/delete-v1-inbound-domains-id","title":"DELETE /v1/inbound/domains/{id}","description":"Remove an inbound domain","text":"# DELETE /v1/inbound/domains/{id}\n\nRemove an inbound domain\n\nMail to the domain is refused from then on, and the name is free for another claim. What was received stays on the stream.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.delete(inboundDomainId)`. Python: `sendora.inbound_domains.delete(inbound_domain_id)`. PHP: `$sendora->inboundDomains->delete($inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound domain of this server. |"},{"uri":"sendora://reference/post-v1-inbound-domains-id-verify","title":"POST /v1/inbound/domains/{id}/verify","description":"Check the records now","text":"# POST /v1/inbound/domains/{id}/verify\n\nCheck the records now\n\nLooks both records up at once and records the result; the answer carries the domain as it stands and what each lookup found. A TXT record that is right while another account holds the name verified reads as mismatch.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.inboundDomains.verify(inboundDomainId)`. Python: `sendora.inbound_domains.verify(inbound_domain_id)`. PHP: `$sendora->inboundDomains->verify($inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 200 The domain as it stands, with what each lookup found.\n\n- `inboundDomainId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n- `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n- `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n- `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `txt` (object, required): The TXT record that proves the claim; it carries a value only you were shown.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `check` (object, required)\n - `mx` (\"ok\" | \"missing\" | \"mismatch\" | \"dns_error\", required): dns_error is a resolver failure, such as a DNSSEC problem or a timeout, not an absent record.\n - `txt` (\"ok\" | \"missing\" | \"mismatch\" | \"dns_error\", required): dns_error is a resolver failure, such as a DNSSEC problem or a timeout, not an absent record.\n\nExample:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"check\": {\n \"mx\": \"missing\",\n \"txt\": \"missing\"\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such inbound domain of this server. |"},{"uri":"sendora://reference/get-v1-streams","title":"GET /v1/streams","description":"List the streams","text":"# GET /v1/streams\n\nList the streams\n\nEvery stream of the server, archived ones included, oldest first.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.streams.list()`. Python: `sendora.streams.list()`. PHP: `$sendora->streams->list()`.\n\n## Responses\n\n### 200 Every stream of the server, archived ones included, oldest first.\n\n- `streams` (array of object, required)\n - `streamId` (string (uuid), required): What a send names as streamId.\n - `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n - `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n - `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n - `createdAt` (string (date-time), required)\n - `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n - `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n - `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n - `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streams\": [\n {\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"kind\": \"transactional\",\n \"name\": \"transactional\",\n \"isDefault\": true,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n },\n {\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n },\n {\n \"streamId\": \"9b2f7c41-3e5d-4a8b-9c1e-2d3f4a5b6c7d\",\n \"kind\": \"inbound\",\n \"name\": \"Support\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": \"9b2f7c413e5d4a8b9c1e2d3f4a5b6c7d@inbound.sendora.se\",\n \"contentRetentionDays\": 14\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-streams","title":"POST /v1/streams","description":"Create a stream","text":"# POST /v1/streams\n\nCreate a stream\n\nAdds a stream to this server. A transactional or a broadcast stream is available to every account; an inbound stream receives mail at its own address, and a server has one at a time, and a test server none. Each sending stream has a suppression list of its own.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.streams.create({ kind, name })`. Python: `sendora.streams.create(kind=..., name=...)`. PHP: `$sendora->streams->create(kind: $kind, name: $name)`.\n\n## Request body\n\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional or broadcast for sending, open to every account; a server has one live inbound stream.\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n\nExample:\n\n```json\n{\n \"kind\": \"transactional\",\n \"name\": \"Notices\"\n}\n```\n\n## Responses\n\n### 201 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `stream_exists` | 409 | The server already has a stream with this name, archived or not. |\n| `inbound_stream_exists` | 409 | A server has one inbound stream at a time; archive it to make another. |\n| `stream_kind_not_allowed` | 422 | A test server has no inbound stream; receive on a live server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `stream_exists`\n\n- `streamId` (string (uuid), required): The stream that already carries the name.\n\nExample:\n\n```json\n{\n \"error\": \"stream_exists\",\n \"message\": \"This server already has a stream with that name.\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```\n\n### `inbound_stream_exists`\n\n- `streamId` (string (uuid), required): The server's live inbound stream.\n\nExample:\n\n```json\n{\n \"error\": \"inbound_stream_exists\",\n \"message\": \"This server already has an inbound stream. Archive it to make another; mail to the old address is then refused.\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-streams-id","title":"GET /v1/streams/{id}","description":"Read a stream","text":"# GET /v1/streams/{id}\n\nRead a stream\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.streams.get(streamId)`. Python: `sendora.streams.get(stream_id)`. PHP: `$sendora->streams->get($streamId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such stream of this server. |"},{"uri":"sendora://reference/patch-v1-streams-id","title":"PATCH /v1/streams/{id}","description":"Update a stream","text":"# PATCH /v1/streams/{id}\n\nUpdate a stream\n\nThe id stays. A new name must be free within the server. contentRetentionDays applies to an inbound stream only and sets how long received content is kept.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. 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)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters)): 1 to 100 characters.\n- `contentRetentionDays` (integer (1 to 30) or null): Days an inbound stream keeps received content, 1 to 30; null for the default of 30.\n\nExample:\n\n```json\n{\n \"name\": \"Driftinformation\"\n}\n```\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Driftinformation\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such stream of this server. |\n| `stream_exists` | 409 | The server already has a stream with this name, archived or not. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `stream_exists`\n\n- `streamId` (string (uuid), required): The stream that already carries the name.\n\nExample:\n\n```json\n{\n \"error\": \"stream_exists\",\n \"message\": \"This server already has a stream with that name.\",\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/post-v1-streams-id-archive","title":"POST /v1/streams/{id}/archive","description":"Archive a stream","text":"# POST /v1/streams/{id}/archive\n\nArchive a stream\n\nThe stream takes no new messages from now on; what it already holds is still delivered, and its log and suppression list stay readable. An archived inbound stream refuses mail to its address, and the server may create another. Archiving an archived stream changes nothing. The default stream cannot be archived.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.streams.archive(streamId)`. Python: `sendora.streams.archive(stream_id)`. PHP: `$sendora->streams->archive($streamId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": \"2026-09-16T13:00:00.000Z\",\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such stream of this server. |\n| `default_stream` | 409 | The default stream of a server cannot be archived. |"},{"uri":"sendora://reference/get-v1-server","title":"GET /v1/server","description":"Read this key's server","text":"# GET /v1/server\n\nRead this key's server\n\nThe server the key belongs to, whether it is live or a test server, every limit a send on it is held to with how much of it this minute and this month have used, the account's cap on received mail, and the sending domains a from address on it may use.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.server.get()`. Python: `sendora.server.get()`. PHP: `$sendora->server->get()`.\n\n## Responses\n\n### 200 The server this key belongs to, its mode, its limits with their use and the domains it may send from.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n- `limits` (array of object, required): Every limit a single send is held to: on a live server the account's per-minute limit and monthly cap, and the server's own where Sendora set them; on a test server the test cap its account's test servers share.\n - `scope` (\"account\" | \"server\" | \"test\", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.\n - `period` (\"minute\" | \"month\", required): The UTC minute, or the month the limit counts over: from the day and time of the first payment for a paying account's live server, otherwise the calendar month in UTC.\n - `limit` (integer, required): Emails allowed in the period, one per recipient.\n - `used` (integer, required): Emails the period has taken so far.\n - `resetsAt` (string (date-time), required): When the period ends and the count starts again.\n- `inboundCap` (object or null, required): The account's monthly cap on received mail and its use; null on a test server, which receives no mail.\n - `limit` (integer, required): Emails the account may receive in its month, over every inbound stream. A paying account's month runs from the day and time of its first payment, and any other account's is the calendar month in UTC. It is the account's monthly cap on sending until an administrator moves it in the dashboard.\n - `used` (integer, required): Emails received this month so far, one per recipient.\n - `resetsAt` (string (date-time), required): When the month ends and the count starts again.\n- `sendingDomains` (array of object, required): The account's sending domains a from address on this server may use now: every verified one, one in its 24-hour warning with failingSince set. Sending domains are the account's; an account key or the dashboard adds one.\n - `domain` (string, required): The domain, as the part of a from address after the @.\n - `failingSince` (string (date-time) or null, required): When a check first found one of its records missing or wrong; mail keeps going out, and the first check 24 hours after this that still misses one ends the verification. Null while every record is in place.\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"limits\": [\n {\n \"scope\": \"account\",\n \"period\": \"minute\",\n \"limit\": 300,\n \"used\": 4,\n \"resetsAt\": \"2026-09-24T10:01:00.000Z\"\n },\n {\n \"scope\": \"account\",\n \"period\": \"month\",\n \"limit\": 10000,\n \"used\": 812,\n \"resetsAt\": \"2026-10-01T00:00:00.000Z\"\n }\n ],\n \"inboundCap\": {\n \"limit\": 10000,\n \"used\": 214,\n \"resetsAt\": \"2026-10-01T00:00:00.000Z\"\n },\n \"sendingDomains\": [\n {\n \"domain\": \"example.se\",\n \"failingSince\": null\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/get-v1-servers","title":"GET /v1/servers","description":"List the servers","text":"# GET /v1/servers\n\nList the servers\n\nEvery server of the account, oldest first.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.list()`. Python: `account.servers.list()`. PHP: `$account->servers->list()`.\n\n## Responses\n\n### 200 Every server of the account, oldest first.\n\n- `servers` (array of object, required)\n - `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n - `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n - `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n - `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"servers\": [\n {\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\"\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-servers","title":"POST /v1/servers","description":"Create a server","text":"# POST /v1/servers\n\nCreate a server\n\nAdds a server to the account with its default transactional stream and its first key, whose value is answered this once. A server holds up to two live keys, so a second can be created before the first is revoked. Names are unique within the account, and the account holds up to its cap of servers.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.create({ name })`. Python: `account.servers.create(name=...)`. PHP: `$account->servers->create(name: $name)`.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n- `mode` (\"live\" | \"test\"): live unless told otherwise. A test server’s keys start sk_test_, and addresses at simulator.sendora.se act out a delivery, a bounce, a deferral or a complaint on it.\n\nExample:\n\n```json\n{\n \"name\": \"Invoicing\"\n}\n```\n\n## Responses\n\n### 201 The new server with its first key, its value shown this once.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n- `token` (object, required): The server’s first key, named default; its value is never shown again.\n - `tokenId` (string (uuid), required)\n - `name` (string, required): The name given at creation, for people to tell keys apart.\n - `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n - `createdAt` (string (date-time), required)\n - `revokedAt` (string (date-time) or null, required)\n - `token` (string, required): The key itself, shown this once.\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"token\": {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_limit` | 409 | The account has as many servers as it may; Sendora raises the cap on request. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `server_limit`\n\n- `maxServers` (integer, required): Servers the account may have at once.\n\nExample:\n\n```json\n{\n \"error\": \"server_limit\",\n \"message\": \"The account may have at most 10 servers.\",\n \"maxServers\": 10\n}\n```"},{"uri":"sendora://reference/get-v1-servers-id","title":"GET /v1/servers/{id}","description":"Read a server","text":"# GET /v1/servers/{id}\n\nRead a server\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.get(serverId)`. Python: `account.servers.get(server_id)`. PHP: `$account->servers->get($serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 200 A server of the account: an isolation boundary with its own keys, streams, log, suppressions and webhooks.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Invoicing\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |"},{"uri":"sendora://reference/patch-v1-servers-id","title":"PATCH /v1/servers/{id}","description":"Rename a server","text":"# PATCH /v1/servers/{id}\n\nRename a server\n\nThe id, the keys, the mode and everything the server holds stay. The new name must be free within the account. A body that names a mode is refused: a server is live or test from its creation.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.update(serverId, { name })`. Python: `account.servers.update(server_id, name=...)`. PHP: `$account->servers->update($serverId, name: $name)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n\nExample:\n\n```json\n{\n \"name\": \"Ekonomi\"\n}\n```\n\n## Responses\n\n### 200 A server of the account: an isolation boundary with its own keys, streams, log, suppressions and webhooks.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Ekonomi\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `mode_immutable` | 400 | A server’s mode is fixed when it is created; create a new server for the other mode. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |\n| `server_exists` | 409 | The account already has a server with that name. |\n| `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/delete-v1-servers-id","title":"DELETE /v1/servers/{id}","description":"Delete a server","text":"# DELETE /v1/servers/{id}\n\nDelete a server\n\nRemoves the server with its keys, streams, messages, suppressions, not-tracked list and webhooks. Refused while messages of the server are still being delivered; try again once they have left. What the server sent stays counted on the account’s usage.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.delete(serverId)`. Python: `account.servers.delete(server_id)`. PHP: `$account->servers->delete($serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |\n| `server_reserved` | 409 | This server sends Sendora’s own sign-in mail and cannot be renamed or removed. |\n| `server_in_flight` | 409 | Messages of the server are still being delivered; try again once they have left. |"},{"uri":"sendora://reference/get-v1-servers-id-tokens","title":"GET /v1/servers/{id}/tokens","description":"List the keys of a server","text":"# GET /v1/servers/{id}/tokens\n\nList the keys of a server\n\nEvery key of the server, revoked ones included.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.tokens.list(serverId)`. Python: `account.servers.tokens.list(server_id)`. PHP: `$account->servers->tokens->list($serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 200 Every key of the server, revoked ones included.\n\n- `tokens` (array of object, required)\n - `tokenId` (string (uuid), required)\n - `name` (string, required): The name given at creation, for people to tell keys apart.\n - `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n - `createdAt` (string (date-time), required)\n - `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokens\": [\n {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |"},{"uri":"sendora://reference/post-v1-servers-id-tokens","title":"POST /v1/servers/{id}/tokens","description":"Create a key for a server","text":"# POST /v1/servers/{id}/tokens\n\nCreate a key for a server\n\nCreates another live key for the server and answers its value once; it is never shown again. Every key of a server has the same rights. A server holds at most two live keys: the one in use and the one being rotated in.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.tokens.create(serverId, { name })`. Python: `account.servers.tokens.create(server_id, name=...)`. PHP: `$account->servers->tokens->create($serverId, name: $name)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n\nExample:\n\n```json\n{\n \"name\": \"Invoicing system\"\n}\n```\n\n## Responses\n\n### 201 The new key, its value shown this once.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n- `token` (string, required): The key itself, shown this once.\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account. |\n| `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. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `token_limit`\n\n- `max` (integer, required): Live keys the owner may hold at once.\n\nExample:\n\n```json\n{\n \"error\": \"token_limit\",\n \"message\": \"The server may hold at most 2 live keys; revoke one before creating another.\",\n \"max\": 2\n}\n```"},{"uri":"sendora://reference/get-v1-servers-id-tokens-tokenid","title":"GET /v1/servers/{id}/tokens/{tokenId}","description":"Read a key of a server","text":"# GET /v1/servers/{id}/tokens/{tokenId}\n\nRead a key of a server\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.tokens.get(serverId, tokenId)`. Python: `account.servers.tokens.get(server_id, token_id)`. PHP: `$account->servers->tokens->get($serverId, $tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n- `tokenId` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 200 A key of this server; the value itself is shown only when it is created.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account, or no such key of the server. |"},{"uri":"sendora://reference/delete-v1-servers-id-tokens-tokenid","title":"DELETE /v1/servers/{id}/tokens/{tokenId}","description":"Revoke a key of a server","text":"# DELETE /v1/servers/{id}/tokens/{tokenId}\n\nRevoke a key of a server\n\nThe key stops working at once. The last live key of a server cannot be revoked, so a server is never locked out; revoking a key that is already revoked is fine.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.servers.tokens.revoke(serverId, tokenId)`. Python: `account.servers.tokens.revoke(server_id, token_id)`. PHP: `$account->servers->tokens->revoke($serverId, $tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n- `tokenId` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such server of this account, or no such key of the server. |\n| `last_token` | 409 | This is the only live key of the server; create another before revoking it. |"},{"uri":"sendora://reference/get-v1-domains","title":"GET /v1/domains","description":"List the sending domains","text":"# GET /v1/domains\n\nList the sending domains\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.list()`. Python: `account.domains.list()`. PHP: `$account->domains->list()`.\n\n## Responses\n\n### 200 Every sending domain of the account.\n\n- `domains` (array of object, required)\n - `domainId` (string (uuid), required)\n - `domain` (string, required)\n - `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n - `createdAt` (string (date-time), required)\n - `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n - `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n - `returnPath` (object, required): The return-path record, which also satisfies SPF.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n - `dkim` (object, required): The DKIM public key record.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"domains\": [\n {\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"s1._domainkey.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-domains","title":"POST /v1/domains","description":"Add a sending domain","text":"# POST /v1/domains\n\nAdd a sending domain\n\nCreates the domain with its own DKIM key and answers the two DNS records to add: a CNAME for the return path and a TXT record for the DKIM key. The domain may send once both records are seen, which Sendora checks on its own and on request. Domains belong to the account: every server sends from them, and an account key manages them; a server key cannot.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.create({ domain })`. Python: `account.domains.create(domain=...)`. PHP: `$account->domains->create(domain: $domain)`.\n\n## Request body\n\n- `domain` (string, required): The domain mail is sent from, such as example.se; lower-cased and IDNA-encoded.\n\nExample:\n\n```json\n{\n \"domain\": \"example.se\"\n}\n```\n\n## Responses\n\n### 201 A sending domain and the two records it needs.\n\n- `domainId` (string (uuid), required)\n- `domain` (string, required)\n- `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n- `returnPath` (object, required): The return-path record, which also satisfies SPF.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `dkim` (object, required): The DKIM public key record.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"s1._domainkey.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `domain_exists` | 409 | The account already has that domain. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `domain_exists`\n\n- `domainId` (string (uuid), required): The id of the domain the account already has.\n\nExample:\n\n```json\n{\n \"error\": \"domain_exists\",\n \"message\": \"Your account already has that domain.\",\n \"domainId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-domains-id","title":"GET /v1/domains/{id}","description":"Read a sending domain","text":"# GET /v1/domains/{id}\n\nRead a sending domain\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.get(domainId)`. Python: `account.domains.get(domain_id)`. PHP: `$account->domains->get($domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 200 A sending domain and the two records it needs.\n\n- `domainId` (string (uuid), required)\n- `domain` (string, required)\n- `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n- `returnPath` (object, required): The return-path record, which also satisfies SPF.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `dkim` (object, required): The DKIM public key record.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"s1._domainkey.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such domain of this account. |"},{"uri":"sendora://reference/delete-v1-domains-id","title":"DELETE /v1/domains/{id}","description":"Remove a sending domain","text":"# DELETE /v1/domains/{id}\n\nRemove a sending domain\n\nMail from the domain is refused from then on; its DKIM key is gone.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.delete(domainId)`. Python: `account.domains.delete(domain_id)`. PHP: `$account->domains->delete($domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such domain of this account. |"},{"uri":"sendora://reference/post-v1-domains-id-verify","title":"POST /v1/domains/{id}/verify","description":"Check the records now","text":"# POST /v1/domains/{id}/verify\n\nCheck the records now\n\nLooks both records up at once and records the result; the answer carries the domain as it stands and what each lookup found. A lookup that fails at the resolver changes nothing. A check that begins a verified domain's 24-hour warning or ends its verification mails the account's administrators, as Sendora's own checks do.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.domains.verify(domainId)`. Python: `account.domains.verify(domain_id)`. PHP: `$account->domains->verify($domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 200 The domain as it stands, with what each lookup found.\n\n- `domainId` (string (uuid), required)\n- `domain` (string, required)\n- `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n- `returnPath` (object, required): The return-path record, which also satisfies SPF.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `dkim` (object, required): The DKIM public key record.\n - `type` (\"TXT\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `check` (object, required)\n - `returnPath` (\"ok\" | \"missing\" | \"mismatch\" | \"dns_error\", required): dns_error is a resolver failure, such as a DNSSEC problem or a timeout, not an absent record.\n - `dkim` (\"ok\" | \"missing\" | \"mismatch\" | \"dns_error\", required): dns_error is a resolver failure, such as a DNSSEC problem or a timeout, not an absent record.\n\nExample:\n\n```json\n{\n \"domainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"domain\": \"example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"failingSince\": null,\n \"returnPath\": {\n \"type\": \"CNAME\",\n \"host\": \"sendora-bounces.example.se\",\n \"value\": \"bounces.sendora.se\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"dkim\": {\n \"type\": \"TXT\",\n \"host\": \"s1._domainkey.example.se\",\n \"value\": \"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"check\": {\n \"returnPath\": \"missing\",\n \"dkim\": \"missing\"\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such domain of this account. |"},{"uri":"sendora://reference/get-v1-tracking-domains","title":"GET /v1/tracking-domains","description":"List the tracking domains","text":"# GET /v1/tracking-domains\n\nList the tracking domains\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.list()`. Python: `account.tracking_domains.list()`. PHP: `$account->trackingDomains->list()`.\n\n## Responses\n\n### 200 Every tracking domain of the account.\n\n- `trackingDomains` (array of object, required)\n - `trackingDomainId` (string (uuid), required)\n - `host` (string, required): The host, lower-cased and IDNA-encoded.\n - `sendingDomainId` (string (uuid) or null, required): The sending domain whose new mail will use the host; null once that domain is deleted.\n - `state` (\"pending\" | \"waiting_for_certificate\" | \"ready\" | \"failing\" | \"stopped\" | \"sending_domain_lost\", required): pending: the records have not been right yet, and the host goes at expiresAt. waiting_for_certificate: the records are right. ready: new mail from the sending domain uses the host. failing: a check found the records wrong, and new mail keeps using a host that was ready until 24 hours after failingSince. stopped: those 24 hours have passed. sending_domain_lost: the sending domain is deleted or has lost its verification, so new mail does not use the host.\n - `createdAt` (string (date-time), required)\n - `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n - `certificateReadyAt` (string (date-time) or null, required): When the certificate was proved to work; null before that.\n - `failingSince` (string (date-time) or null, required): When a check first found the records wrong on a host whose records have been right. Null unless it is in that warning or past it.\n - `expiresAt` (string (date-time) or null, required): When a host whose records have never been right goes; null once they have been.\n - `cname` (object, required): The CNAME record to add.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `check` (\"ok\" | \"missing\" | \"mismatch\" | \"proxied\" | \"dns_error\" or null, required): What the last check found; null before the first. mismatch is a record that points elsewhere, an A record included. proxied is a proxy in front of the name, such as Cloudflare’s, which must be switched off. dns_error is a resolver failure, not an absent record, and changes nothing.\n - `caa` (object, required): Whether CAA records let Let’s Encrypt issue the certificate.\n - `check` (\"ok\" | \"no_lets_encrypt\" | \"other_account\" | \"no_http_01\" | \"dns_error\" or null, required): What the last check of the CAA records on the host and its parents found; null before the first. dns_error is a resolver failure and changes nothing.\n - `record` (object or null, required): What lets the certificate be issued; null unless the check found a problem.\n - `type` (\"CAA\", required)\n - `host` (string, required): The name that holds the CAA records in the way.\n - `value` (string, required): The record to add beside them.\n\nExample:\n\n```json\n{\n \"trackingDomains\": [\n {\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": null\n },\n \"caa\": {\n \"check\": null,\n \"record\": null\n }\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-tracking-domains","title":"POST /v1/tracking-domains","description":"Add a tracking domain","text":"# POST /v1/tracking-domains\n\nAdd a tracking domain\n\nClaims a host under one of the account’s verified sending domains and answers the CNAME record to add. A host whose records are not right within 7 days goes. An account key manages tracking domains; a server key cannot.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.create({ host })`. Python: `account.tracking_domains.create(host=...)`. PHP: `$account->trackingDomains->create(host: $host)`.\n\n## Request body\n\n- `host` (string, required): The host tracked links and the open pixel use, such as links.example.se: a subdomain of a verified sending domain of the account. Lower-cased and IDNA-encoded.\n\nExample:\n\n```json\n{\n \"host\": \"links.example.se\"\n}\n```\n\n## Responses\n\n### 201 A tracking domain, the record it needs and what the last check found.\n\n- `trackingDomainId` (string (uuid), required)\n- `host` (string, required): The host, lower-cased and IDNA-encoded.\n- `sendingDomainId` (string (uuid) or null, required): The sending domain whose new mail will use the host; null once that domain is deleted.\n- `state` (\"pending\" | \"waiting_for_certificate\" | \"ready\" | \"failing\" | \"stopped\" | \"sending_domain_lost\", required): pending: the records have not been right yet, and the host goes at expiresAt. waiting_for_certificate: the records are right. ready: new mail from the sending domain uses the host. failing: a check found the records wrong, and new mail keeps using a host that was ready until 24 hours after failingSince. stopped: those 24 hours have passed. sending_domain_lost: the sending domain is deleted or has lost its verification, so new mail does not use the host.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `certificateReadyAt` (string (date-time) or null, required): When the certificate was proved to work; null before that.\n- `failingSince` (string (date-time) or null, required): When a check first found the records wrong on a host whose records have been right. Null unless it is in that warning or past it.\n- `expiresAt` (string (date-time) or null, required): When a host whose records have never been right goes; null once they have been.\n- `cname` (object, required): The CNAME record to add.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `check` (\"ok\" | \"missing\" | \"mismatch\" | \"proxied\" | \"dns_error\" or null, required): What the last check found; null before the first. mismatch is a record that points elsewhere, an A record included. proxied is a proxy in front of the name, such as Cloudflare’s, which must be switched off. dns_error is a resolver failure, not an absent record, and changes nothing.\n- `caa` (object, required): Whether CAA records let Let’s Encrypt issue the certificate.\n - `check` (\"ok\" | \"no_lets_encrypt\" | \"other_account\" | \"no_http_01\" | \"dns_error\" or null, required): What the last check of the CAA records on the host and its parents found; null before the first. dns_error is a resolver failure and changes nothing.\n - `record` (object or null, required): What lets the certificate be issued; null unless the check found a problem.\n - `type` (\"CAA\", required)\n - `host` (string, required): The name that holds the CAA records in the way.\n - `value` (string, required): The record to add beside them.\n\nExample:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": null\n },\n \"caa\": {\n \"check\": null,\n \"record\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `domain_reserved` | 400 | The name is Sendora’s own, or holds a domain’s return path. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `tracking_domain_exists` | 409 | The account already has that host, or a host on that sending domain. |\n| `tracking_host_taken` | 409 | Another account holds the host, and its sending domain is still verified. |\n| `tracking_domain_limit` | 409 | The account has as many tracking domains as it may have. |\n| `tracking_host_is_sending_domain` | 422 | The host is a sending domain itself. A tracking domain is a subdomain of one. |\n| `tracking_host_not_verified` | 422 | The host is not under a verified sending domain of this account. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `tracking_domain_exists`\n\n- `trackingDomainId` (string (uuid), required): The id of the tracking domain the account already has.\n\nExample:\n\n```json\n{\n \"error\": \"tracking_domain_exists\",\n \"message\": \"The account already has that host, or a host on that sending domain.\",\n \"trackingDomainId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-tracking-domains-id","title":"GET /v1/tracking-domains/{id}","description":"Read a tracking domain","text":"# GET /v1/tracking-domains/{id}\n\nRead a tracking domain\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.get(trackingDomainId)`. Python: `account.tracking_domains.get(tracking_domain_id)`. PHP: `$account->trackingDomains->get($trackingDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The tracking domain id.\n\n## Responses\n\n### 200 A tracking domain, the record it needs and what the last check found.\n\n- `trackingDomainId` (string (uuid), required)\n- `host` (string, required): The host, lower-cased and IDNA-encoded.\n- `sendingDomainId` (string (uuid) or null, required): The sending domain whose new mail will use the host; null once that domain is deleted.\n- `state` (\"pending\" | \"waiting_for_certificate\" | \"ready\" | \"failing\" | \"stopped\" | \"sending_domain_lost\", required): pending: the records have not been right yet, and the host goes at expiresAt. waiting_for_certificate: the records are right. ready: new mail from the sending domain uses the host. failing: a check found the records wrong, and new mail keeps using a host that was ready until 24 hours after failingSince. stopped: those 24 hours have passed. sending_domain_lost: the sending domain is deleted or has lost its verification, so new mail does not use the host.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `certificateReadyAt` (string (date-time) or null, required): When the certificate was proved to work; null before that.\n- `failingSince` (string (date-time) or null, required): When a check first found the records wrong on a host whose records have been right. Null unless it is in that warning or past it.\n- `expiresAt` (string (date-time) or null, required): When a host whose records have never been right goes; null once they have been.\n- `cname` (object, required): The CNAME record to add.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `check` (\"ok\" | \"missing\" | \"mismatch\" | \"proxied\" | \"dns_error\" or null, required): What the last check found; null before the first. mismatch is a record that points elsewhere, an A record included. proxied is a proxy in front of the name, such as Cloudflare’s, which must be switched off. dns_error is a resolver failure, not an absent record, and changes nothing.\n- `caa` (object, required): Whether CAA records let Let’s Encrypt issue the certificate.\n - `check` (\"ok\" | \"no_lets_encrypt\" | \"other_account\" | \"no_http_01\" | \"dns_error\" or null, required): What the last check of the CAA records on the host and its parents found; null before the first. dns_error is a resolver failure and changes nothing.\n - `record` (object or null, required): What lets the certificate be issued; null unless the check found a problem.\n - `type` (\"CAA\", required)\n - `host` (string, required): The name that holds the CAA records in the way.\n - `value` (string, required): The record to add beside them.\n\nExample:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": null\n },\n \"caa\": {\n \"check\": null,\n \"record\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such tracking domain of this account. |"},{"uri":"sendora://reference/delete-v1-tracking-domains-id","title":"DELETE /v1/tracking-domains/{id}","description":"Remove a tracking domain","text":"# DELETE /v1/tracking-domains/{id}\n\nRemove a tracking domain\n\nNew mail stops using the host at once. Links in sent mail keep working while the CNAME record points at Sendora, so keep the record for as long as those links matter.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.delete(trackingDomainId)`. Python: `account.tracking_domains.delete(tracking_domain_id)`. PHP: `$account->trackingDomains->delete($trackingDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The tracking domain id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such tracking domain of this account. |"},{"uri":"sendora://reference/post-v1-tracking-domains-id-verify","title":"POST /v1/tracking-domains/{id}/verify","description":"Check the records now","text":"# POST /v1/tracking-domains/{id}/verify\n\nCheck the records now\n\nLooks the CNAME and the CAA records up at once and records the result. A lookup that fails at the resolver changes nothing. A check that begins a warning mails the account’s administrators, as Sendora’s own checks do.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.trackingDomains.verify(trackingDomainId)`. Python: `account.tracking_domains.verify(tracking_domain_id)`. PHP: `$account->trackingDomains->verify($trackingDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The tracking domain id.\n\n## Responses\n\n### 200 A tracking domain, the record it needs and what the last check found.\n\n- `trackingDomainId` (string (uuid), required)\n- `host` (string, required): The host, lower-cased and IDNA-encoded.\n- `sendingDomainId` (string (uuid) or null, required): The sending domain whose new mail will use the host; null once that domain is deleted.\n- `state` (\"pending\" | \"waiting_for_certificate\" | \"ready\" | \"failing\" | \"stopped\" | \"sending_domain_lost\", required): pending: the records have not been right yet, and the host goes at expiresAt. waiting_for_certificate: the records are right. ready: new mail from the sending domain uses the host. failing: a check found the records wrong, and new mail keeps using a host that was ready until 24 hours after failingSince. stopped: those 24 hours have passed. sending_domain_lost: the sending domain is deleted or has lost its verification, so new mail does not use the host.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `certificateReadyAt` (string (date-time) or null, required): When the certificate was proved to work; null before that.\n- `failingSince` (string (date-time) or null, required): When a check first found the records wrong on a host whose records have been right. Null unless it is in that warning or past it.\n- `expiresAt` (string (date-time) or null, required): When a host whose records have never been right goes; null once they have been.\n- `cname` (object, required): The CNAME record to add.\n - `type` (\"CNAME\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `check` (\"ok\" | \"missing\" | \"mismatch\" | \"proxied\" | \"dns_error\" or null, required): What the last check found; null before the first. mismatch is a record that points elsewhere, an A record included. proxied is a proxy in front of the name, such as Cloudflare’s, which must be switched off. dns_error is a resolver failure, not an absent record, and changes nothing.\n- `caa` (object, required): Whether CAA records let Let’s Encrypt issue the certificate.\n - `check` (\"ok\" | \"no_lets_encrypt\" | \"other_account\" | \"no_http_01\" | \"dns_error\" or null, required): What the last check of the CAA records on the host and its parents found; null before the first. dns_error is a resolver failure and changes nothing.\n - `record` (object or null, required): What lets the certificate be issued; null unless the check found a problem.\n - `type` (\"CAA\", required)\n - `host` (string, required): The name that holds the CAA records in the way.\n - `value` (string, required): The record to add beside them.\n\nExample:\n\n```json\n{\n \"trackingDomainId\": \"3f2b8c1e-6a7d-4e9f-8b10-5c4d3e2f1a09\",\n \"host\": \"links.example.se\",\n \"sendingDomainId\": \"0d7f6a1e-4c0b-4b7e-9d3c-2a1f5e8b9c01\",\n \"state\": \"pending\",\n \"createdAt\": \"2026-11-05T12:00:00.000Z\",\n \"lastCheckedAt\": \"2026-11-05T12:10:00.000Z\",\n \"certificateReadyAt\": null,\n \"failingSince\": null,\n \"expiresAt\": \"2026-11-12T12:00:00.000Z\",\n \"cname\": {\n \"type\": \"CNAME\",\n \"host\": \"links.example.se\",\n \"value\": \"track.sendora.se\",\n \"check\": \"missing\"\n },\n \"caa\": {\n \"check\": \"ok\",\n \"record\": null\n }\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such tracking domain of this account. |"},{"uri":"sendora://reference/post-v1-erasures","title":"POST /v1/erasures","description":"Erase a person","text":"# POST /v1/erasures\n\nErase a person\n\nErases one person's data from every server of the account, as a request under the right to erasure asks. The content of every message to or from the address, sent and received, is deleted, and the address becomes a pseudonym in the message log. The answer is the pending erasure; Sendora carries it out within a minute, and reading it by its id shows the outcome. Erasing an address again changes nothing more. It blocks nothing: later mail to or from the address is sent and received as before.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.erasures.create({ address })`. Python: `account.erasures.create(address=...)`. PHP: `$account->erasures->create(address: $address)`.\n\n## Request body\n\n- `address` (string (email), required): The person's e-mail address.\n\nExample:\n\n```json\n{\n \"address\": \"anna@example.com\"\n}\n```\n\n## Responses\n\n### 202 A request to erase one person's data from the account.\n\n- `erasureId` (string (uuid), required)\n- `status` (\"pending\" | \"done\", required): pending until Sendora has erased the person, done after.\n- `createdAt` (string (date-time), required)\n- `completedAt` (string (date-time) or null, required): When the erasure was done; null while pending.\n- `pseudonym` (string or null, required): What now stands where the address was written in the message log; null while pending.\n- `messages` (integer or null, required): Sent messages to the address whose content was deleted; null while pending.\n- `receivedMessages` (integer or null, required): Received messages from, naming or to the address whose content was deleted; null while pending.\n\nExample:\n\n```json\n{\n \"erasureId\": \"6f1d2c3b-4a5e-4f60-8b7a-9c8d7e6f5a41\",\n \"status\": \"pending\",\n \"createdAt\": \"2026-10-04T12:00:00.000Z\",\n \"completedAt\": null,\n \"pseudonym\": null,\n \"messages\": null,\n \"receivedMessages\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-erasures-id","title":"GET /v1/erasures/{id}","description":"Read an erasure","text":"# GET /v1/erasures/{id}\n\nRead an erasure\n\nThe erasure and, once it is done, its outcome. A done erasure can be read for 30 days.\n\nTakes an account key (`ak_…`) as a bearer token. TypeScript: `account.erasures.get(erasureId)`. Python: `account.erasures.get(erasure_id)`. PHP: `$account->erasures->get($erasureId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The erasure id.\n\n## Responses\n\n### 200 A request to erase one person's data from the account.\n\n- `erasureId` (string (uuid), required)\n- `status` (\"pending\" | \"done\", required): pending until Sendora has erased the person, done after.\n- `createdAt` (string (date-time), required)\n- `completedAt` (string (date-time) or null, required): When the erasure was done; null while pending.\n- `pseudonym` (string or null, required): What now stands where the address was written in the message log; null while pending.\n- `messages` (integer or null, required): Sent messages to the address whose content was deleted; null while pending.\n- `receivedMessages` (integer or null, required): Received messages from, naming or to the address whose content was deleted; null while pending.\n\nExample:\n\n```json\n{\n \"erasureId\": \"6f1d2c3b-4a5e-4f60-8b7a-9c8d7e6f5a41\",\n \"status\": \"done\",\n \"createdAt\": \"2026-10-04T12:00:00.000Z\",\n \"completedAt\": \"2026-10-04T12:00:08.000Z\",\n \"pseudonym\": \"erased:3f7a9c1e5b2d8f4a6c0e9b7d5f3a1c8e6b4d2f0a9c7e5b3d1f8a6c4e2b0d9f7a\",\n \"messages\": 3,\n \"receivedMessages\": 1\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such erasure of this account. |"},{"uri":"sendora://reference/get-v1-suppressions","title":"GET /v1/suppressions","description":"List the suppressed addresses","text":"# GET /v1/suppressions\n\nList the suppressed addresses\n\nAddresses a stream of the server will not send to: hard bounces, spam complaints and entries added by hand, newest first, one page at a time. The default stream unless streamId names another; every stream has a list of its own. A send to any of them is refused with recipient_suppressed.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.suppressions.list(query)`. Python: `sendora.suppressions.list(stream_id=..., limit=..., after=...)`. PHP: `$sendora->suppressions->list(streamId: $streamId, limit: $limit, after: $after)`.\n\n## Parameters\n\n- `streamId` (query, string (uuid)): The stream whose list is meant; the default transactional stream when absent.\n- `limit` (query, integer (1 to 1000)): Page size, 1 to 1000.\n- `after` (query, string (uuid)): The `next` value of the previous page.\n\n## Responses\n\n### 200 A page of the stream's suppression list, newest first.\n\n- `suppressions` (array of object, required)\n - `streamId` (string (uuid), required): The stream whose list the address is on.\n - `address` (string, required): The recipient, lower-cased.\n - `reason` (\"hard_bounce\" | \"spam_complaint\" | \"manual\" | \"unsubscribe\", required): A hard bounce or a manual entry may be lifted here; a spam complaint or the recipient’s own unsubscribe only by Sendora support. manual is an address added by hand in the dashboard.\n - `messageId` (string (uuid) or null, required): The message whose bounce or complaint put it here.\n - `createdAt` (string (date-time), required)\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"suppressions\": [\n {\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"address\": \"anna@example.com\",\n \"reason\": \"hard_bounce\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-suppressions-delete","title":"POST /v1/suppressions/delete","description":"Lift a suppression","text":"# POST /v1/suppressions/delete\n\nLift a suppression\n\nRemoves a hard-bounced or manually added address from a stream’s list so the server may send to it again on that stream. A POST, because the address travels in the body and never in a URL. A spam complaint cannot be lifted this way.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.suppressions.delete({ address })`. Python: `sendora.suppressions.delete(address=...)`. PHP: `$sendora->suppressions->delete(address: $address)`.\n\n## Request body\n\n- `address` (string (email), required): The address to send to again.\n- `streamId` (string (uuid)): The stream whose list is meant; the default transactional stream when absent.\n\nExample:\n\n```json\n{\n \"address\": \"anna@example.com\"\n}\n```\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `spam_complaint_locked` | 403 | The recipient reported a message as spam; only Sendora support lifts that, at the recipient’s own request. |\n| `unsubscribe_locked` | 403 | The recipient unsubscribed themselves; only Sendora support lifts that, at the recipient’s own request. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such suppressed address of this server. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-tracking-opt-outs","title":"GET /v1/tracking-opt-outs","description":"List the not-tracked addresses","text":"# GET /v1/tracking-opt-outs\n\nList the not-tracked addresses\n\nAddresses the server never tracks, on any of its streams, newest first, one page at a time. Tracking is not open to accounts yet, so the list changes nothing until it is.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.trackingOptOuts.list(query)`. Python: `sendora.tracking_opt_outs.list(limit=..., after=...)`. PHP: `$sendora->trackingOptOuts->list(limit: $limit, after: $after)`.\n\n## Parameters\n\n- `limit` (query, integer (1 to 1000)): Page size, 1 to 1000.\n- `after` (query, string (uuid)): The `next` value of the previous page.\n\n## Responses\n\n### 200 A page of the server's not-tracked list, newest first.\n\n- `trackingOptOuts` (array of object, required)\n - `address` (string or null, required): The recipient, lower-cased. Null once an erasure has left only a keyed hash.\n - `source` (\"manual\" | \"recipient\", required): manual: added through the API or the dashboard. recipient: the recipient asked for it, so remove it only after new consent.\n - `messageId` (string (uuid) or null, required): The message that a recipient asked from. Null on a manual entry, and once that message or the address has gone.\n - `createdAt` (string (date-time), required)\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"trackingOptOuts\": [\n {\n \"address\": \"anna@example.com\",\n \"source\": \"manual\",\n \"messageId\": null,\n \"createdAt\": \"2026-11-06T12:00:00.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-tracking-opt-outs","title":"POST /v1/tracking-opt-outs","description":"Add a not-tracked address","text":"# POST /v1/tracking-opt-outs\n\nAdd a not-tracked address\n\nPuts an address on the server’s list. New mail to it goes untracked, and its later opens and clicks are not recorded, from mail already sent too. An address already on the list changes nothing, so a repeat is safe. Tracking is not open to accounts yet, so the list changes nothing until it is.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.trackingOptOuts.add({ address })`. Python: `sendora.tracking_opt_outs.add(address=...)`. PHP: `$sendora->trackingOptOuts->add(address: $address)`.\n\n## Request body\n\n- `address` (string (email), required): The address never to track.\n\nExample:\n\n```json\n{\n \"address\": \"anna@example.com\"\n}\n```\n\n## Responses\n\n### 200 The entry: a new one, or as it stood if the address was on the list.\n\n- `address` (string or null, required): The recipient, lower-cased. Null once an erasure has left only a keyed hash.\n- `source` (\"manual\" | \"recipient\", required): manual: added through the API or the dashboard. recipient: the recipient asked for it, so remove it only after new consent.\n- `messageId` (string (uuid) or null, required): The message that a recipient asked from. Null on a manual entry, and once that message or the address has gone.\n- `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"address\": \"anna@example.com\",\n \"source\": \"manual\",\n \"messageId\": null,\n \"createdAt\": \"2026-11-06T12:00:00.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-tracking-opt-outs-delete","title":"POST /v1/tracking-opt-outs/delete","description":"Remove a not-tracked address","text":"# POST /v1/tracking-opt-outs/delete\n\nRemove a not-tracked address\n\nTakes an address off the server’s list, so its mail can be tracked again. Remove an entry whose source is recipient only after new consent. A POST, because the address travels in the body and never in a URL. An entry an erasure reduced is found by its address too.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.trackingOptOuts.delete({ address })`. Python: `sendora.tracking_opt_outs.delete(address=...)`. PHP: `$sendora->trackingOptOuts->delete(address: $address)`.\n\n## Request body\n\n- `address` (string (email), required): The address to track again. An erased entry is found by it too.\n\nExample:\n\n```json\n{\n \"address\": \"anna@example.com\"\n}\n```\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such not-tracked address of this server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/get-v1-webhooks","title":"GET /v1/webhooks","description":"List the webhooks","text":"# GET /v1/webhooks\n\nList the webhooks\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.list()`. Python: `sendora.webhooks.list()`. PHP: `$sendora->webhooks->list()`.\n\n## Responses\n\n### 200 Every webhook of the server.\n\n- `webhooks` (array of object, required)\n - `webhookId` (string (uuid), required)\n - `url` (string, required): Where the events are posted.\n - `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"opened\" | \"clicked\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n - `streamId` (string (uuid) or null, required): The one stream whose message events it receives; null for every stream.\n - `enabled` (boolean, required): False while switched off in the dashboard; nothing is queued for it then.\n - `inboundContent` (\"full\" | \"reference\", required): How the inbound event carries a received message: the parsed message, or a reference.\n - `createdAt` (string (date-time), required)\n - `secrets` (array of object, required): The live secrets, oldest first; every delivery is signed with each of them, the newest first in Sendora-Signature.\n - `secretId` (string (uuid), required)\n - `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"webhooks\": [\n {\n \"webhookId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"spam_complaint\"\n ],\n \"streamId\": null,\n \"enabled\": true,\n \"inboundContent\": \"full\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"secrets\": [\n {\n \"secretId\": \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ]\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-webhooks","title":"POST /v1/webhooks","description":"Create a webhook","text":"# POST /v1/webhooks\n\nCreate a webhook\n\nRegisters a URL to receive events. The answer carries the signing secret once; every delivery is signed with it (HMAC-SHA256 over the body) so the receiver can verify Sendora sent it. A second secret can be created later to roll the first one.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.create({ url, events })`. Python: `sendora.webhooks.create(url=..., events=...)`. PHP: `$sendora->webhooks->create(url: $url, events: $events)`.\n\n## Request body\n\n- `url` (string, required): An https URL on a public host, without credentials or a fragment.\n- `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"opened\" | \"clicked\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", at least 1): The events to deliver; every event except opened and clicked when left out.\n- `streamId` (string (uuid)): Limits the message events to one stream of the server; every stream when left out. Cap events, on sending and on received mail, concern the server or the account and arrive either way.\n- `inboundContent` (\"full\" | \"reference\"): How the inbound event carries a received message: full sends the parsed message with the event; reference sends ids and sizes only, for you to fetch. full when left out.\n\nExample:\n\n```json\n{\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"spam_complaint\"\n ]\n}\n```\n\n## Responses\n\n### 201 The new webhook with its signing secret, shown this once.\n\n- `webhookId` (string (uuid), required)\n- `url` (string, required): Where the events are posted.\n- `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"opened\" | \"clicked\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n- `streamId` (string (uuid) or null, required): The one stream whose message events it receives; null for every stream.\n- `enabled` (boolean, required): False while switched off in the dashboard; nothing is queued for it then.\n- `inboundContent` (\"full\" | \"reference\", required): How the inbound event carries a received message: the parsed message, or a reference.\n- `createdAt` (string (date-time), required)\n- `secrets` (array of object, required): The live secrets, oldest first; every delivery is signed with each of them, the newest first in Sendora-Signature.\n - `secretId` (string (uuid), required)\n - `createdAt` (string (date-time), required)\n- `secret` (string, required): The signing secret, shown this once.\n\nExample:\n\n```json\n{\n \"webhookId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"spam_complaint\"\n ],\n \"streamId\": null,\n \"enabled\": true,\n \"inboundContent\": \"full\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"secrets\": [\n {\n \"secretId\": \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ],\n \"secret\": \"whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `webhook_exists` | 409 | The server already has a webhook for that URL. |\n| `stream_not_found` | 422 | The streamId names no stream of this server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `webhook_exists`\n\n- `webhookId` (string (uuid), required): The id of the webhook the server already has for that URL.\n\nExample:\n\n```json\n{\n \"error\": \"webhook_exists\",\n \"message\": \"This server already has a webhook for that URL.\",\n \"webhookId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\"\n}\n```"},{"uri":"sendora://reference/get-v1-webhooks-id","title":"GET /v1/webhooks/{id}","description":"Read a webhook","text":"# GET /v1/webhooks/{id}\n\nRead a webhook\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.get(webhookId)`. Python: `sendora.webhooks.get(webhook_id)`. PHP: `$sendora->webhooks->get($webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 200 A webhook of this server.\n\n- `webhookId` (string (uuid), required)\n- `url` (string, required): Where the events are posted.\n- `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"opened\" | \"clicked\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n- `streamId` (string (uuid) or null, required): The one stream whose message events it receives; null for every stream.\n- `enabled` (boolean, required): False while switched off in the dashboard; nothing is queued for it then.\n- `inboundContent` (\"full\" | \"reference\", required): How the inbound event carries a received message: the parsed message, or a reference.\n- `createdAt` (string (date-time), required)\n- `secrets` (array of object, required): The live secrets, oldest first; every delivery is signed with each of them, the newest first in Sendora-Signature.\n - `secretId` (string (uuid), required)\n - `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"webhookId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"url\": \"https://example.se/hooks/sendora\",\n \"events\": [\n \"delivered\",\n \"bounced\",\n \"spam_complaint\"\n ],\n \"streamId\": null,\n \"enabled\": true,\n \"inboundContent\": \"full\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"secrets\": [\n {\n \"secretId\": \"4a5b6c7d-8e9f-4a0b-8c1d-2e3f4a5b6c7d\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\"\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook of this server. |"},{"uri":"sendora://reference/delete-v1-webhooks-id","title":"DELETE /v1/webhooks/{id}","description":"Remove a webhook","text":"# DELETE /v1/webhooks/{id}\n\nRemove a webhook\n\nDeliveries still pending are dropped with it.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.delete(webhookId)`. Python: `sendora.webhooks.delete(webhook_id)`. PHP: `$sendora->webhooks->delete($webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook of this server. |"},{"uri":"sendora://reference/get-v1-webhooks-id-deliveries","title":"GET /v1/webhooks/{id}/deliveries","description":"List the deliveries of a webhook","text":"# GET /v1/webhooks/{id}/deliveries\n\nList the deliveries of a webhook\n\nEvery event handed to the webhook, newest first, one page at a time, with the outcome of the last attempt.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. 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)`.\n\n## Parameters\n\n- `limit` (query, integer (1 to 1000)): Page size, 1 to 1000.\n- `after` (query, string (uuid)): The `next` value of the previous page.\n- `status` (query, \"pending\" | \"delivered\" | \"dead\")\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 200 A page of the webhook's deliveries, newest first.\n\n- `deliveries` (array of object, required)\n - `deliveryId` (string (uuid), required)\n - `event` (\"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"opened\" | \"clicked\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n - `messageId` (string (uuid) or null, required): Null for an event about usage rather than a message.\n - `inboundMessageId` (string (uuid) or null, required): The received message an inbound event is about; null for every other event.\n - `status` (\"pending\" | \"delivered\" | \"dead\", required): pending is waiting for its next attempt; dead gave up and can be replayed.\n - `attempts` (integer, required)\n - `nextAttemptAt` (string (date-time) or null, required): When the next attempt is due, while pending.\n - `lastStatusCode` (integer or null, required): The HTTP status the endpoint last answered.\n - `lastError` (string or null, required): Why the last attempt failed, when it did.\n - `deliveredAt` (string (date-time) or null, required)\n - `createdAt` (string (date-time), required)\n- `next` (string (uuid) or null, required): Pass as `after` for the next page; null on the last.\n\nExample:\n\n```json\n{\n \"deliveries\": [\n {\n \"deliveryId\": \"9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c60\",\n \"event\": \"delivered\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"inboundMessageId\": null,\n \"status\": \"delivered\",\n \"attempts\": 1,\n \"nextAttemptAt\": null,\n \"lastStatusCode\": 200,\n \"lastError\": null,\n \"deliveredAt\": \"2026-09-15T12:00:03.000Z\",\n \"createdAt\": \"2026-09-15T12:00:02.000Z\"\n }\n ],\n \"next\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook of this server. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```"},{"uri":"sendora://reference/post-v1-webhooks-id-deliveries-deliveryid-replay","title":"POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay","description":"Replay a delivery","text":"# POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay\n\nReplay a delivery\n\nQueues the event for delivery again, whatever its state, with the same payload and a fresh attempt count.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.replay(webhookId, deliveryId)`. Python: `sendora.webhooks.replay(webhook_id, delivery_id)`. PHP: `$sendora->webhooks->replay($webhookId, $deliveryId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n- `deliveryId` (path, required, string (uuid)): The delivery id.\n\n## Responses\n\n### 202 One event on its way to the webhook, retried with backoff until delivered or dead.\n\n- `deliveryId` (string (uuid), required)\n- `event` (\"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"opened\" | \"clicked\" | \"cap_warning\" | \"cap_reached\" | \"inbound\" | \"inbound_cap_warning\" | \"inbound_cap_reached\", required)\n- `messageId` (string (uuid) or null, required): Null for an event about usage rather than a message.\n- `inboundMessageId` (string (uuid) or null, required): The received message an inbound event is about; null for every other event.\n- `status` (\"pending\" | \"delivered\" | \"dead\", required): pending is waiting for its next attempt; dead gave up and can be replayed.\n- `attempts` (integer, required)\n- `nextAttemptAt` (string (date-time) or null, required): When the next attempt is due, while pending.\n- `lastStatusCode` (integer or null, required): The HTTP status the endpoint last answered.\n- `lastError` (string or null, required): Why the last attempt failed, when it did.\n- `deliveredAt` (string (date-time) or null, required)\n- `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"deliveryId\": \"9e8d7c6b-5a4f-4e3d-9c2b-1a0f9e8d7c60\",\n \"event\": \"delivered\",\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"inboundMessageId\": null,\n \"status\": \"pending\",\n \"attempts\": 0,\n \"nextAttemptAt\": \"2026-09-15T12:05:00.000Z\",\n \"lastStatusCode\": 200,\n \"lastError\": null,\n \"deliveredAt\": null,\n \"createdAt\": \"2026-09-15T12:00:02.000Z\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook or delivery of this server. |"},{"uri":"sendora://reference/post-v1-webhooks-id-secrets","title":"POST /v1/webhooks/{id}/secrets","description":"Create a signing secret","text":"# POST /v1/webhooks/{id}/secrets\n\nCreate a signing secret\n\nAdds a second live secret to the webhook and answers its value once; it is never shown again. From then on every delivery carries one signature per live secret, the newest first, so the receiver can switch to the new secret and the old one can be deleted. A webhook holds at most two live secrets.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.createSecret(webhookId)`. Python: `sendora.webhooks.create_secret(webhook_id)`. PHP: `$sendora->webhooks->createSecret($webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 201 The new secret, its value shown this once.\n\n- `secretId` (string (uuid), required)\n- `createdAt` (string (date-time), required)\n- `secret` (string, required): The secret itself, shown this once.\n\nExample:\n\n```json\n{\n \"secretId\": \"5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"secret\": \"whsec_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook of this server. |\n| `secret_limit` | 409 | The webhook already holds two live secrets: one in use and one to roll to. Delete one before creating another. |\n\n### `secret_limit`\n\n- `max` (integer, required): Live secrets a webhook may hold at once.\n\nExample:\n\n```json\n{\n \"error\": \"secret_limit\",\n \"message\": \"The webhook may hold at most 2 live secrets; delete one before creating another.\",\n \"max\": 2\n}\n```"},{"uri":"sendora://reference/delete-v1-webhooks-id-secrets-secretid","title":"DELETE /v1/webhooks/{id}/secrets/{secretId}","description":"Delete a signing secret","text":"# DELETE /v1/webhooks/{id}/secrets/{secretId}\n\nDelete a signing secret\n\nDeliveries are no longer signed with it from the next one on. The last live secret of a webhook cannot be deleted, so its deliveries always carry a signature.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.webhooks.deleteSecret(webhookId, secretId)`. Python: `sendora.webhooks.delete_secret(webhook_id, secret_id)`. PHP: `$sendora->webhooks->deleteSecret($webhookId, $secretId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n- `secretId` (path, required, string (uuid)): The secret id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such webhook or secret of this server. |\n| `last_secret` | 409 | The only live secret of a webhook cannot be deleted; create another first. |"},{"uri":"sendora://reference/get-v1-tokens","title":"GET /v1/tokens","description":"List the keys","text":"# GET /v1/tokens\n\nList the keys\n\nEvery key of the server, revoked ones included.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.tokens.list()`. Python: `sendora.tokens.list()`. PHP: `$sendora->tokens->list()`.\n\n## Responses\n\n### 200 Every key of the server, revoked ones included.\n\n- `tokens` (array of object, required)\n - `tokenId` (string (uuid), required)\n - `name` (string, required): The name given at creation, for people to tell keys apart.\n - `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n - `createdAt` (string (date-time), required)\n - `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokens\": [\n {\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |"},{"uri":"sendora://reference/post-v1-tokens","title":"POST /v1/tokens","description":"Create a key","text":"# POST /v1/tokens\n\nCreate a key\n\nCreates another live key for this server and answers its value once; it is never shown again. Every key of a server has the same rights. A server holds at most two live keys: the one in use and the one being rotated in.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.tokens.create({ name })`. Python: `sendora.tokens.create(name=...)`. PHP: `$sendora->tokens->create(name: $name)`.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters), required): 1 to 100 characters.\n\nExample:\n\n```json\n{\n \"name\": \"Invoicing system\"\n}\n```\n\n## Responses\n\n### 201 The new key, its value shown this once.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n- `token` (string, required): The key itself, shown this once.\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `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. |\n\n### `invalid_request`\n\n- `issues` (array of object): One entry per invalid field; absent when a header is wrong.\n - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.\n - `message` (string, required)\n\nExample:\n\n```json\n{\n \"error\": \"invalid_request\",\n \"message\": \"The request is invalid: to.0: Invalid email address\",\n \"issues\": [\n {\n \"path\": \"to.0\",\n \"message\": \"Invalid email address\"\n }\n ]\n}\n```\n\n### `token_limit`\n\n- `max` (integer, required): Live keys the owner may hold at once.\n\nExample:\n\n```json\n{\n \"error\": \"token_limit\",\n \"message\": \"The server may hold at most 2 live keys; revoke one before creating another.\",\n \"max\": 2\n}\n```"},{"uri":"sendora://reference/get-v1-tokens-id","title":"GET /v1/tokens/{id}","description":"Read a key","text":"# GET /v1/tokens/{id}\n\nRead a key\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.tokens.get(tokenId)`. Python: `sendora.tokens.get(token_id)`. PHP: `$sendora->tokens->get($tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 200 A key of this server; the value itself is shown only when it is created.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"Invoicing system\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null\n}\n```\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such key of this server. |"},{"uri":"sendora://reference/delete-v1-tokens-id","title":"DELETE /v1/tokens/{id}","description":"Revoke a key","text":"# DELETE /v1/tokens/{id}\n\nRevoke a key\n\nThe key stops working at once. The last live key of a server cannot be revoked, so a server is never locked out; revoking a key that is already revoked is fine.\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.tokens.revoke(tokenId)`. Python: `sendora.tokens.revoke(token_id)`. PHP: `$sendora->tokens->revoke($tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n## Errors\n\nEvery error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\n| `unauthorized` | 401 | The key is missing, malformed or revoked. |\n| `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. |\n| `not_for_connections` | 403 | A connected app called an operation that no tool of the MCP server calls. An API key can. |\n| `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. |\n| `test_server_only` | 403 | A connected app may create a secret, or do what cannot be undone, on a test server only. |\n| `not_found` | 404 | No such key of this server. |\n| `last_token` | 409 | This is the only live key of the server; create another before revoking it. |"},{"uri":"sendora://reference/webhook-events","title":"Webhook events","description":"Every event a webhook receives, with its fields and an example.","text":"# Webhook events\n\nSendora posts each event as JSON to the webhooks that take it. A delivery may arrive more than once, so key your handling on `id`, and verify `Sendora-Signature` before you trust the body. [Webhooks](sendora://docs/webhooks) shows how.\n\n## Headers\n\n- `Sendora-Signature` (string): `t=` the Unix time of sending, then `v1=` an HMAC-SHA256 over `<t>.<body>` for each live secret of the webhook, the newest first. Verify one before you trust the body.\n- `Sendora-Event` (string): The event, the same as `event` in the body.\n- `Sendora-Delivery-Id` (string): The delivery, the same as `id` in the body and on every retry of it.\n- `User-Agent` (string): `Sendora-Webhooks/1`.\n\nAcknowledges the delivery. Any other answer, or none within 10 seconds, is retried.\n\n## Message events\n\nEvery event in this group carries these fields:\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `messageId` (string, required): The message the event is about.\n- `recipient` (string, required): The recipient the event is about.\n- `occurredAt` (string (date-time), required): When it happened.\n- `serverId` (string, required): The server the message was sent from.\n- `streamId` (string, required): The stream the message went on.\n- `broadcastId` (string or null, required): The broadcast the message belongs to; null for a message sent on its own.\n- `tag` (string or null, required): The tag you sent with the message.\n- `metadata` (map of string to string, required): The metadata you sent with the message.\n- `test` (boolean, required): True for a test server's message: the event was simulated and nothing was sent.\n\n### `delivered`\n\nThe receiver accepted the message for the recipient.\n\n- `event` (\"delivered\", required)\n- `details` (object, required)\n - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.\n - `response` (string or null, required): The receiver's response text.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"delivered\",\n \"details\": {\n \"code\": 250,\n \"response\": \"2.0.0 OK\"\n }\n}\n```\n\n### `bounced`\n\nThe receiver refused the message for good, or Sendora gave up; the recipient is suppressed when `hard`.\n\n- `event` (\"bounced\", required)\n- `details` (object, required)\n - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.\n - `response` (string or null, required): The receiver's response text.\n - `classification` (string or null, required): How the bounce was classified, such as InvalidRecipient.\n - `hard` (boolean, required): True when the address is now on the suppression list.\n - `expired` (boolean, required): True when the message expired after repeated deferrals rather than being refused.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"bounced\",\n \"details\": {\n \"code\": 550,\n \"response\": \"5.1.1 The email account that you tried to reach does not exist.\",\n \"classification\": \"InvalidRecipient\",\n \"hard\": true,\n \"expired\": false\n }\n}\n```\n\n### `deferred`\n\nThe receiver asked Sendora to try later; several may follow.\n\n- `event` (\"deferred\", required)\n- `details` (object, required)\n - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.\n - `response` (string or null, required): The receiver's response text.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"deferred\",\n \"details\": {\n \"code\": 451,\n \"response\": \"4.7.1 Greylisted, try again later\"\n }\n}\n```\n\n### `spam_complaint`\n\nThe recipient reported the message as spam; only Sendora support lifts that suppression.\n\n- `event` (\"spam_complaint\", required)\n- `details` (object, required)\n - `feedbackType` (string or null, required): The feedback type the receiver reported, such as abuse.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"spam_complaint\",\n \"details\": {\n \"feedbackType\": \"abuse\"\n }\n}\n```\n\n### `unsubscribed`\n\nThe recipient unsubscribed through the link or their mail client's one-click button; only Sendora support lifts that suppression.\n\n- `event` (\"unsubscribed\", required)\n- `details` (object, required)\n - `source` (\"link\" | \"one_click\", required): `link` for the page's button, `one_click` for a mail client's request.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": \"2c4e6a8b-0d1f-4a3b-8c5d-7e9f1a3b5c7d\",\n \"tag\": \"newsletter\",\n \"metadata\": {},\n \"test\": false,\n \"event\": \"unsubscribed\",\n \"details\": {\n \"source\": \"one_click\"\n }\n}\n```\n\n### `opened`\n\nThe pixel of a tracked copy loaded, for a person or a machine; `automated` says which. A webhook gets this event only if it names it. Tracking is not open to accounts yet, so this event is not sent until it is.\n\n- `event` (\"opened\", required)\n- `details` (object, required)\n - `automated` (\"likely\" | \"unlikely\" | \"unknown\", required): Whether a machine rather than a person did it: likely, unlikely, or unknown where nobody can tell, as with Apple's privacy protection.\n - `agent` (\"apple_privacy\" | \"gmail_prefetch\" | \"gmail_proxy\" | \"yahoo_proxy\" | \"outlook_prefetch\" | \"proton_proxy\" | \"scanner\" | \"crawler\" or null, required): The proxy or machine it came through, where Sendora knows it.\n - `reasons` (array of \"privacy_proxy\" | \"prefetch_signature\" | \"delivery_timing\" | \"link_burst\" | \"scanner_network\" | \"bot_user_agent\" | \"many_networks\", required): Why it was labelled a machine's.\n - `client` (\"apple_mail\" | \"gmail\" | \"outlook\" | \"yahoo\" | \"thunderbird\" | \"proton\" | \"browser\" | \"other\" or null, required): The mail program, where Sendora knows it.\n - `device` (\"desktop\" | \"mobile\" | \"tablet\" or null, required): The kind of device, where Sendora knows it.\n - `first` (boolean, required): The recipient's first event of this kind.\n - `firstHuman` (boolean, required): The recipient's first event of this kind that was likely a person's.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"opened\",\n \"details\": {\n \"automated\": \"unknown\",\n \"agent\": \"apple_privacy\",\n \"reasons\": [\n \"privacy_proxy\"\n ],\n \"client\": \"apple_mail\",\n \"device\": null,\n \"first\": true,\n \"firstHuman\": false\n }\n}\n```\n\n### `clicked`\n\nA tracked link was followed, by a person or a machine; `automated` says which. A webhook gets this event only if it names it. Tracking is not open to accounts yet, so this event is not sent until it is.\n\n- `event` (\"clicked\", required)\n- `details` (object, required)\n - `automated` (\"likely\" | \"unlikely\" | \"unknown\", required): Whether a machine rather than a person did it: likely, unlikely, or unknown where nobody can tell, as with Apple's privacy protection.\n - `agent` (\"apple_privacy\" | \"gmail_prefetch\" | \"gmail_proxy\" | \"yahoo_proxy\" | \"outlook_prefetch\" | \"proton_proxy\" | \"scanner\" | \"crawler\" or null, required): The proxy or machine it came through, where Sendora knows it.\n - `reasons` (array of \"privacy_proxy\" | \"prefetch_signature\" | \"delivery_timing\" | \"link_burst\" | \"scanner_network\" | \"bot_user_agent\" | \"many_networks\", required): Why it was labelled a machine's.\n - `client` (\"apple_mail\" | \"gmail\" | \"outlook\" | \"yahoo\" | \"thunderbird\" | \"proton\" | \"browser\" | \"other\" or null, required): The mail program, where Sendora knows it.\n - `device` (\"desktop\" | \"mobile\" | \"tablet\" or null, required): The kind of device, where Sendora knows it.\n - `first` (boolean, required): The recipient's first event of this kind.\n - `firstHuman` (boolean, required): The recipient's first event of this kind that was likely a person's.\n - `link` (object, required): The link that was followed.\n - `index` (integer, required): The link's position in the HTML, the same in every copy, counting from 0.\n - `url` (string or null, required): Where the link led; null once it has gone with the message's text and HTML.\n\nExample:\n\n```json\n{\n \"id\": \"9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c\",\n \"attempt\": 1,\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"recipient\": \"anna@example.com\",\n \"occurredAt\": \"2026-09-15T12:00:03.000Z\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\n \"broadcastId\": null,\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n },\n \"test\": false,\n \"event\": \"clicked\",\n \"details\": {\n \"automated\": \"unlikely\",\n \"agent\": null,\n \"reasons\": [],\n \"client\": \"browser\",\n \"device\": \"desktop\",\n \"first\": true,\n \"firstHuman\": true,\n \"link\": {\n \"index\": 0,\n \"url\": \"https://example.se/invoices/2026-0912\"\n }\n }\n}\n```\n\n## The monthly cap\n\nEvery event in this group carries these fields:\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `scope` (\"account\" | \"server\" | \"test\", required): Whose cap it is: `account` for the account's, `server` for a server's own, `test` for the cap the account's test servers share.\n- `serverId` (string or null, required): The server whose cap it is; null for the account's or the test servers' cap.\n- `cap` (integer, required): Emails the scope may send in the month.\n- `used` (integer, required): Emails sent in the month so far.\n- `periodStart` (string (date-time), required): When the month the counts are for began. A paying account's live servers count months from the day and time of its first payment, and the rest count calendar months in UTC.\n- `periodEnd` (string (date-time), required): When that month ends and the count starts again.\n- `occurredAt` (string (date-time), required): When it happened.\n\n### `cap_warning`\n\n80 percent of the monthly cap is used.\n\n- `event` (\"cap_warning\", required)\n\nExample:\n\n```json\n{\n \"id\": \"4d6f8a0b-2c4e-4f6a-8b0c-2d4e6f8a0b1c\",\n \"attempt\": 1,\n \"scope\": \"account\",\n \"serverId\": null,\n \"cap\": 50000,\n \"periodStart\": \"2026-09-01T00:00:00.000Z\",\n \"periodEnd\": \"2026-10-01T00:00:00.000Z\",\n \"used\": 40000,\n \"occurredAt\": \"2026-09-24T09:30:00.000Z\",\n \"event\": \"cap_warning\"\n}\n```\n\n### `cap_reached`\n\nA send was refused because the monthly cap is used up.\n\n- `event` (\"cap_reached\", required)\n\nExample:\n\n```json\n{\n \"id\": \"4d6f8a0b-2c4e-4f6a-8b0c-2d4e6f8a0b1c\",\n \"attempt\": 1,\n \"scope\": \"account\",\n \"serverId\": null,\n \"cap\": 50000,\n \"periodStart\": \"2026-09-01T00:00:00.000Z\",\n \"periodEnd\": \"2026-10-01T00:00:00.000Z\",\n \"used\": 50000,\n \"occurredAt\": \"2026-09-29T16:05:00.000Z\",\n \"event\": \"cap_reached\"\n}\n```\n\n## Received mail\n\n### `inbound`\n\nA message was received on an inbound stream: the reference, or the reference and the message itself, as the webhook asks.\n\nBoth forms carry these fields:\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `event` (\"inbound\", required)\n- `inboundMessageId` (string, required): The received message; GET /v1/inbound/{id} reads it.\n- `serverId` (string, required): The server whose stream received it.\n- `streamId` (string, required): The inbound stream that received it.\n- `receivedAt` (string (date-time), required): When the message was accepted from the sending server.\n- `envelopeRecipient` (string, required): The address of yours the message was sent to.\n- `mailboxHash` (string or null, required): The text after `+` in the local part of that address, when the sender used one.\n- `sizeBytes` (integer, required): The size of the message as received, in bytes.\n- `attachmentCount` (integer, required)\n- `hasText` (boolean, required)\n- `hasHtml` (boolean, required)\n- `authentication` (object, required): What Sendora found when it checked the message's authentication; `unchecked` until the checks have run.\n - `spf` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the address in MAIL FROM.\n - `spfHelo` (\"pass\" | \"fail\" | \"softfail\" | \"neutral\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required): SPF for the name the sending server gave in HELO.\n - `dkim` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarc` (\"pass\" | \"fail\" | \"none\" | \"temperror\" | \"permerror\" | \"unchecked\", required)\n - `dmarcPolicy` (\"none\" | \"quarantine\" | \"reject\" or null, required): What the sender's domain asks for; told only when DMARC failed.\n - `arc` (\"none\" | \"pass\" | \"fail\" | \"unchecked\", required)\n - `checkedAt` (string (date-time) or null, required): When the checks ran; null until they have.\n- `parseIssue` (string or null, required): What the parser met; null when the message parsed clean. A hard issue empties the parsed parts, and the raw message stays: `no_headers`, `header_block_too_large`, `too_many_headers`, `too_many_parts`, `nesting_too_deep`, `missing_boundary`, `decoded_too_large`, `parse_timeout`, `parser_error`. A soft issue keeps them and says what was changed or dropped: `truncated_multipart`, `too_many_attachments`, `unknown_transfer_encoding`, `undecodable_container`, `filename_sanitised`, `control_chars_stripped`, `multiple_from`, `duplicate_header`, `malformed_header_dropped`, `address_list_truncated`, `header_value_truncated`. A message with several names the hard one, or the first soft one in this order.\n\nForm 1: The reference to the received message, for a webhook with `inboundContent` reference.\n\nExample:\n\n```json\n{\n \"id\": \"6e8a0c2e-4a6c-4e8a-8c0e-2a4c6e8a0c2e\",\n \"attempt\": 1,\n \"event\": \"inbound\",\n \"inboundMessageId\": \"1b3d5f7a-9c1e-4b3d-8f7a-9c1e3b5d7f9a\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"8a0c2e4a-6c8e-4a0c-8e4a-6c8e0a2c4e6a\",\n \"receivedAt\": \"2026-09-15T12:00:00.000Z\",\n \"envelopeRecipient\": \"support@example.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 18432,\n \"attachmentCount\": 1,\n \"hasText\": true,\n \"hasHtml\": true,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-15T12:00:00.000Z\"\n },\n \"parseIssue\": null\n}\n```\n\nForm 2: The reference and the message itself, for a webhook with `inboundContent` full.\n\n- `envelope` (object or null, required): The envelope sender; null for a bounce.\n - `sender` (string or null, required)\n- `from` (object or null, required)\n - `address` (string, required)\n - `name` (string or null, required)\n- `replyTo` (array of object, required)\n - `address` (string, required)\n - `name` (string or null, required)\n- `to` (array of object, required): Up to 100 entries; `toCount` is the whole number.\n - `address` (string, required)\n - `name` (string or null, required)\n- `toCount` (integer, required)\n- `cc` (array of object, required): Up to 100 entries; `ccCount` is the whole number.\n - `address` (string, required)\n - `name` (string or null, required)\n- `ccCount` (integer, required)\n- `subject` (string or null, required)\n- `date` (string (date-time) or null, required): The sender's Date header, when it was a real moment.\n- `messageIdHeader` (string or null, required)\n- `inReplyTo` (string or null, required)\n- `references` (array of string, required)\n- `headers` (array of object, required)\n - `name` (string, required)\n - `value` (string, required)\n- `text` (string or null, required): The plain-text body; null when there is none, or over 1 MiB.\n- `html` (string or null, required): The HTML body; null when there is none, or over 2 MiB.\n- `attachments` (array of object, required): Each attachment, without its bytes.\n - `position` (integer, required): Its place among the message's attachments, from 0.\n - `name` (string, required)\n - `contentType` (string, required)\n - `contentId` (string or null, required): The Content-ID an HTML body refers to with `cid:`, when it has one.\n - `size` (integer, required)\n - `inline` (boolean, required): True for a part shown in the body rather than offered as a file.\n- `contentOmitted` (\"too_large\" | \"expired\" or null, required): `too_large` when text or HTML was left out for size; `expired` when the content window has passed and the reference is all that remains.\n\nExample:\n\n```json\n{\n \"id\": \"6e8a0c2e-4a6c-4e8a-8c0e-2a4c6e8a0c2e\",\n \"attempt\": 1,\n \"event\": \"inbound\",\n \"inboundMessageId\": \"1b3d5f7a-9c1e-4b3d-8f7a-9c1e3b5d7f9a\",\n \"serverId\": \"0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c\",\n \"streamId\": \"8a0c2e4a-6c8e-4a0c-8e4a-6c8e0a2c4e6a\",\n \"receivedAt\": \"2026-09-15T12:00:00.000Z\",\n \"envelopeRecipient\": \"support@example.se\",\n \"mailboxHash\": null,\n \"sizeBytes\": 18432,\n \"attachmentCount\": 1,\n \"hasText\": true,\n \"hasHtml\": true,\n \"authentication\": {\n \"spf\": \"pass\",\n \"spfHelo\": \"pass\",\n \"dkim\": \"pass\",\n \"dmarc\": \"pass\",\n \"dmarcPolicy\": null,\n \"arc\": \"none\",\n \"checkedAt\": \"2026-09-15T12:00:00.000Z\"\n },\n \"parseIssue\": null,\n \"envelope\": {\n \"sender\": \"anna@example.com\"\n },\n \"from\": {\n \"address\": \"anna@example.com\",\n \"name\": \"Anna Andersson\"\n },\n \"replyTo\": [],\n \"to\": [\n {\n \"address\": \"support@example.se\",\n \"name\": null\n }\n ],\n \"toCount\": 1,\n \"cc\": [],\n \"ccCount\": 0,\n \"subject\": \"A question about the invoice\",\n \"date\": \"2026-09-15T11:59:58.000Z\",\n \"messageIdHeader\": \"<20260915115958.a1b2@example.com>\",\n \"inReplyTo\": null,\n \"references\": [],\n \"headers\": [\n {\n \"name\": \"From\",\n \"value\": \"Anna Andersson <anna@example.com>\"\n },\n {\n \"name\": \"To\",\n \"value\": \"support@example.se\"\n },\n {\n \"name\": \"Subject\",\n \"value\": \"A question about the invoice\"\n }\n ],\n \"text\": \"Hi! I have a question about the invoice for September.\",\n \"html\": \"<p>Hi! I have a question about the invoice for September.</p>\",\n \"attachments\": [\n {\n \"position\": 0,\n \"name\": \"invoice.pdf\",\n \"contentType\": \"application/pdf\",\n \"contentId\": null,\n \"size\": 15360,\n \"inline\": false\n }\n ],\n \"contentOmitted\": null\n}\n```\n\n### `inbound_cap_warning`\n\n80 percent of the monthly cap on received mail is used.\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `cap` (integer, required): Emails the account may receive in the month.\n- `used` (integer, required): Emails received in the month so far.\n- `periodStart` (string (date-time), required): When the month the counts are for began. A paying account counts months from the day and time of its first payment, and any other account calendar months in UTC.\n- `periodEnd` (string (date-time), required): When that month ends and received mail is counted from 0 again.\n- `occurredAt` (string (date-time), required): When it happened.\n- `event` (\"inbound_cap_warning\", required)\n\nExample:\n\n```json\n{\n \"id\": \"6f8a0b2c-4e6f-4a8b-9c0d-2e4f6a8b0c1d\",\n \"attempt\": 1,\n \"cap\": 50000,\n \"periodStart\": \"2026-09-01T00:00:00.000Z\",\n \"periodEnd\": \"2026-10-01T00:00:00.000Z\",\n \"used\": 40000,\n \"occurredAt\": \"2026-09-24T09:30:00.000Z\",\n \"event\": \"inbound_cap_warning\"\n}\n```\n\n### `inbound_cap_reached`\n\nThe monthly cap on received mail is used up. Mail to the account is refused for now, and senders retry, until the month ends or the cap is raised.\n\n- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.\n- `attempt` (integer, required): The attempt number, from 1.\n- `cap` (integer, required): Emails the account may receive in the month.\n- `used` (integer, required): Emails received in the month so far.\n- `periodStart` (string (date-time), required): When the month the counts are for began. A paying account counts months from the day and time of its first payment, and any other account calendar months in UTC.\n- `periodEnd` (string (date-time), required): When that month ends and received mail is counted from 0 again.\n- `occurredAt` (string (date-time), required): When it happened.\n- `event` (\"inbound_cap_reached\", required)\n\nExample:\n\n```json\n{\n \"id\": \"6f8a0b2c-4e6f-4a8b-9c0d-2e4f6a8b0c1d\",\n \"attempt\": 1,\n \"cap\": 50000,\n \"periodStart\": \"2026-09-01T00:00:00.000Z\",\n \"periodEnd\": \"2026-10-01T00:00:00.000Z\",\n \"used\": 50000,\n \"occurredAt\": \"2026-09-29T16:05:00.000Z\",\n \"event\": \"inbound_cap_reached\"\n}\n```"}]}