@sendora/mcp 1.3.3 → 1.4.1
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.
- package/dist/errors.js +20 -10
- package/dist/generated/docs.json +1 -1
- package/dist/grant.js +95 -0
- package/dist/handler.js +137 -36
- package/dist/instructions.js +18 -2
- package/dist/listing.js +23 -0
- package/dist/server.js +2 -2
- package/dist/target.js +49 -0
- package/dist/tools/broadcasts.js +14 -13
- package/dist/tools/domains.js +3 -3
- package/dist/tools/inbound-domains.js +13 -13
- package/dist/tools/inbound.js +9 -9
- package/dist/tools/keys.js +11 -13
- package/dist/tools/messages.js +9 -9
- package/dist/tools/send.js +16 -8
- package/dist/tools/server.js +27 -14
- package/dist/tools/servers.js +29 -9
- package/dist/tools/streams.js +19 -14
- package/dist/tools/suppressions.js +39 -37
- package/dist/tools/webhooks.js +21 -21
- package/dist/version.js +1 -1
- package/package.json +5 -5
package/dist/generated/docs.json
CHANGED
|
@@ -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 payment is active. Activate payment under [Account › Billing](https://app.sendora.se/account/billing) once you are approved. 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~=1.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\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\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\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 }\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 },\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 }\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\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\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\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\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\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 without active 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## 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\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\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\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."},{"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\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\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\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\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}\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\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\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\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\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\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."},{"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 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\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\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\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\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\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\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\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\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\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\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\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 \"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\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\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 }\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 },\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 }\n ]\n}\n```\n\n## Search the log\n\nEvery filter is optional:\n\n| Filter | Matches |\n| ------------- | --------------------------------------------------- |\n| `recipient` | Messages to this address, 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 |\n| `from` | Messages submitted at or after this time |\n| `to` | Messages submitted before this time |\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\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 }\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\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, and the message, its recipients and its events 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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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## 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\nAttachments are ordinary MIME parts. Sendora also keeps `Reply-To`, `List-Unsubscribe`, `List-Unsubscribe-Post` and your own `X-` headers. It 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 without active payment. 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.\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\n## When an account cannot send\n\n- `payment_required` (402): the account has no active payment, for instance before a card is saved. Activate payment under [Account › Billing](https://app.sendora.se/account/billing) in the dashboard. Every send from a live server fails until then.\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, for instance after a failed payment. Support tells you why. A pause for an unpaid subscription lifts once you save a working card under Account › Billing.\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. |\n| `not_found` | 404 | No such broadcast of this server. |\n| `payment_required` | 402 | The account has no active subscription. |\n| `rate_limited` | 429 | More was sent within a minute than the limit allows. |\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| `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| `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`, and a month is a calendar month in UTC.\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\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- 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. 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| 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\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## 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\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":"Give a coding agent Sendora's docs and tools, in Claude Code, Cursor, VS Code, Codex or Claude Desktop.","text":"# MCP server\n\nGive a coding agent Sendora's docs and tools, in Claude Code, Cursor, VS Code, Codex or Claude Desktop.\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`, with your key as a bearer token. Without a key, `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- The agent needs a key for anything beyond the documentation. [Choose a key](#choose-a-key) says which.\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. A custom connector, in Claude Desktop or on claude.ai, cannot send a key of your own, so use the bundle.\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\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| `delete_test_server` | Delete a test server | account with changes on |\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\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### 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### 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`."},{"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. |\n| `not_found` | 404 | No such broadcast of this server. |\n| `payment_required` | 402 | The account has no active subscription. |\n| `rate_limited` | 429 | More was sent within a minute than the limit allows. |\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| `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| `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 and its sending\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, 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`, `streamId`, `tag`, `status` (`queued`, `delivered`,\n`deferred`, `bounced`, `expired`), `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\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` and `failed` |\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| Call | Answers |\n| ---------------------- | ---------------------------------------------------------------------------------------------------- |\n| `sendora.server.get()` | The server with `mode`, `limits`, each with `used` and `resetsAt`, 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. Every event carries `id`, the delivery id,\nand `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\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`. Narrow 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 account has no active subscription. |\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| `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`) is repeated only after a\n`rate_limited` or `sending_disabled` answer, never after a lost\nconnection or a server error, so nothing is created twice. Nothing else is\nretried. `new Sendora({ maxRetries: 0 })` turns retries off; `timeoutMs`\nsets 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` and `domains`, 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 and its sending 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, 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`, `stream_id`,\n`broadcast_id`, `tag`, `status` (`queued`, `delivered`, `deferred`,\n`bounced`, `expired`), `from_` and `to` as an aware `datetime` or ISO\n8601, `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\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` and `failed` |\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| Call | Answers |\n| ---------------------- | ------------------------------------------------------------------------------------------------------- |\n| `sendora.server.get()` | `OwnServer` with `mode`, `limits`, each with `used` and `resets_at`, 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. 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\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\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\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 account has no active subscription. |\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| `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`) 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` and `domains`,\n 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://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)`.\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 account has no active subscription. |\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| `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. |\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 a calendar month, in UTC.\n- `used` (integer, required): Emails sent this month, the refused send not counted.\n- `resetsAt` (string (date-time), required): When the month counter resets.\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)`.\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. | `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 account has no active subscription. | |\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 account has no active subscription. |\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| `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=...)`.\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\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)`.\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 account has no active subscription. |\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| `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. |\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 a calendar month, in UTC.\n- `used` (integer, required): Emails sent this month, the refused send not counted.\n- `resetsAt` (string (date-time), required): When the month counter resets.\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 and the counts of messages released and failed.\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)`.\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\": \"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\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_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)`.\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_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 every event on its timeline. 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)`.\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- `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.\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\", required): Reception when the mail server took it, Delivery, TransientFailure, Bounce, OOB for a bounce report received by mail, Feedback for a spam complaint.\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\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 }\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 },\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 }\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_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)`.\n\n## Request body\n\n- `recipient` (string (email)): Messages to this address, in to, cc or bcc.\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` (\"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\"): Messages with at least one recipient in this state.\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\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- `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 }\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\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)`.\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| `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)`.\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_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)`.\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_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)`.\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_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()`.\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. |"},{"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=...)`.\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| `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)`.\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_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)`.\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_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)`.\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_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()`.\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. |"},{"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=...)`.\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| `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)`.\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_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=...)`.\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_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)`.\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_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, 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()`.\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 calendar month in UTC the limit counts over.\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- `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 \"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. |"},{"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()`.\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. |"},{"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=...)`.\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| `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)`.\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_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=...)`.\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_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)`.\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_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)`.\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_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=...)`.\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_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)`.\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_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)`.\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_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()`.\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. |"},{"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=...)`.\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| `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)`.\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_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)`.\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_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)`.\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_found` | 404 | No such 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=...)`.\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\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)`.\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_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=...)`.\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| `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=...)`.\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_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()`.\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\", 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. |"},{"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=...)`.\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\", 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 concern the server 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\", 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| `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)`.\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\", 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_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)`.\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_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=...)`.\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\", 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_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)`.\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\", 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_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)`.\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_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)`.\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_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()`.\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. |"},{"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=...)`.\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| `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)`.\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_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)`.\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_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): The month the counts are for: its first moment, in UTC.\n- `periodEnd` (string (date-time), required): The first moment of the next month, in UTC.\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```"}]}
|
|
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 payment is active. Activate payment under [Account › Billing](https://app.sendora.se/account/billing) once you are approved. 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~=1.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\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\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\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 }\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 },\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 }\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\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\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\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\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\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 without active 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## 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\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\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\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."},{"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\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\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\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\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}\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\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\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\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\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\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."},{"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 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\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\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\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\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\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\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\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\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\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\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\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 \"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\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\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 }\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 },\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 }\n ]\n}\n```\n\n## Search the log\n\nEvery filter is optional:\n\n| Filter | Matches |\n| ------------- | --------------------------------------------------- |\n| `recipient` | Messages to this address, 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 |\n| `from` | Messages submitted at or after this time |\n| `to` | Messages submitted before this time |\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\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 }\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\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, and the message, its recipients and its events 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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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\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## 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\nAttachments are ordinary MIME parts. Sendora also keeps `Reply-To`, `List-Unsubscribe`, `List-Unsubscribe-Post` and your own `X-` headers. It 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 without active payment. 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.\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\n## When an account cannot send\n\n- `payment_required` (402): the account has no active payment, for instance before a card is saved. Activate payment under [Account › Billing](https://app.sendora.se/account/billing) in the dashboard. Every send from a live server fails until then.\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, for instance after a failed payment. Support tells you why. A pause for an unpaid subscription lifts once you save a working card under Account › Billing.\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. |\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 account has no active subscription. |\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| `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`, and a month is a calendar month in UTC.\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\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- 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. 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| 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\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## 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\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":"Give a coding agent Sendora's docs and tools, in Claude Code, Cursor, VS Code, Codex or Claude Desktop.","text":"# MCP server\n\nGive a coding agent Sendora's docs and tools, in Claude Code, Cursor, VS Code, Codex or Claude Desktop.\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`, with your key as a bearer token. Without a key, `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- The agent needs a key for anything beyond the documentation. [Choose a key](#choose-a-key) says which.\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. A custom connector, in Claude Desktop or on claude.ai, cannot send a key of your own, so use the bundle.\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\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| `delete_test_server` | Delete a test server | account with changes on |\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\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### 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### 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`."},{"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. |\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 account has no active subscription. |\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| `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 and its sending\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, 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`, `streamId`, `tag`, `status` (`queued`, `delivered`,\n`deferred`, `bounced`, `expired`), `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\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` and `failed` |\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| Call | Answers |\n| ---------------------- | ---------------------------------------------------------------------------------------------------- |\n| `sendora.server.get()` | The server with `mode`, `limits`, each with `used` and `resetsAt`, 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. Every event carries `id`, the delivery id,\nand `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\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`. Narrow 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 account has no active subscription. |\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| `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`) is repeated only after a\n`rate_limited` or `sending_disabled` answer, never after a lost\nconnection or a server error, so nothing is created twice. Nothing else is\nretried. `new Sendora({ maxRetries: 0 })` turns retries off; `timeoutMs`\nsets 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` and `domains`, 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 and its sending 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, 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`, `stream_id`,\n`broadcast_id`, `tag`, `status` (`queued`, `delivered`, `deferred`,\n`bounced`, `expired`), `from_` and `to` as an aware `datetime` or ISO\n8601, `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\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` and `failed` |\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| Call | Answers |\n| ---------------------- | ------------------------------------------------------------------------------------------------------- |\n| `sendora.server.get()` | `OwnServer` with `mode`, `limits`, each with `used` and `resets_at`, 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. 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\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\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\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 account has no active subscription. |\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| `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`) 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` and `domains`,\n 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://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)`.\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 account has no active subscription. |\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. |\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 a calendar month, in UTC.\n- `used` (integer, required): Emails sent this month, the refused send not counted.\n- `resetsAt` (string (date-time), required): When the month counter resets.\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)`.\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. | `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 account has no active subscription. | |\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 account has no active subscription. |\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=...)`.\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)`.\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 account has no active subscription. |\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. |\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 a calendar month, in UTC.\n- `used` (integer, required): Emails sent this month, the refused send not counted.\n- `resetsAt` (string (date-time), required): When the month counter resets.\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 and the counts of messages released and failed.\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)`.\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\": \"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\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)`.\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 every event on its timeline. 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)`.\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- `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.\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\", required): Reception when the mail server took it, Delivery, TransientFailure, Bounce, OOB for a bounce report received by mail, Feedback for a spam complaint.\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\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 }\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 },\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 }\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)`.\n\n## Request body\n\n- `recipient` (string (email)): Messages to this address, in to, cc or bcc.\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` (\"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\"): Messages with at least one recipient in this state.\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\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- `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 }\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)`.\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)`.\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)`.\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)`.\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()`.\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=...)`.\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)`.\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)`.\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)`.\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()`.\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=...)`.\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)`.\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=...)`.\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)`.\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, 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()`.\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 calendar month in UTC the limit counts over.\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- `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 \"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()`.\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=...)`.\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)`.\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=...)`.\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)`.\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)`.\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=...)`.\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)`.\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)`.\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()`.\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=...)`.\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)`.\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)`.\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)`.\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/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=...)`.\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)`.\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=...)`.\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=...)`.\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()`.\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\", 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=...)`.\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\", 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 concern the server 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\", 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)`.\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\", 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)`.\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=...)`.\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\", 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)`.\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\", 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)`.\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)`.\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()`.\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=...)`.\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)`.\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)`.\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): The month the counts are for: its first moment, in UTC.\n- `periodEnd` (string (date-time), required): The first moment of the next month, in UTC.\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```"}]}
|