@sendora/mcp 1.1.1 → 1.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"documents":[{"uri":"sendora://docs/quickstart","title":"Quickstart","description":"Get a server key, send one message, and read it in the message log.","text":"# Quickstart\n\nGet a server key, send one message, and read it in the message log.\n\nThree steps from an approved account to a delivered message. The code on this page is run against the real API by Sendora's own test suite, in both languages, so it works as written. The tabs on a code block pick the language for every page.\n\n## 1. Get a server key\n\nSign in to the dashboard and create a server under [Servers](https://app.sendora.se/servers). It comes with its first key, shown once; keep it in your secret store. A server holds up to two live keys, so a new one can be created before the old one is revoked. [Keys](sendora://docs/keys) explains the two kinds of key, the server key that sends and the account key that manages, and how to rotate one.\n\nWith the 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, and never ship it to a browser. The client throws at construction when the key is missing.\n\nOver HTTP:\n\nEvery request carries the key as a bearer token:\n\n```bash\nAuthorization: Bearer sk_…\n```\n\nYour sending domain must be verified before the first send: the account's [Domains](https://app.sendora.se/domains) page shows the two DNS records to add, a CNAME for the return path and a TXT record for the DKIM key.\n\n## 2. Send a message\n\nThe `from` address must be on a verified domain of the account. `to`, `cc` and `bcc` take plain addresses or objects with a display name; at least one of `text` and `html` is required.\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_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\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\"\n}\n```\n\nThe answer comes at once. `messageId` is what the message log, the events and the webhooks refer to; `accepted` means the message is stored and on its way to the mail server. Delivery, deferral, bounce or complaint arrive later as events.\n\nSending the same request again under the same idempotency key returns the first answer instead of sending twice; see [Sending](sendora://docs/sending) for keys and for batches.\n\n## 3. Read the message\n\nThe message log holds every message with its recipients and its timeline, for the time on [Limits](sendora://docs/limits#retention). Read one by its id:\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/messages/{messageId} \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\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 \"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`recipients[].status` moves from `queued` to `delivered`, `deferred` or `bounced` as the receiving server answers. The `events` list is the timeline: `Reception` when the mail server took it, `Delivery` when the receiver accepted it, `Bounce` when it refused it.\n\nThe same log is on the server's **Messages** tab under [Servers](https://app.sendora.se/servers) in the dashboard, searchable by recipient, tag, status and time.\n\n## What next\n\n- Register a [webhook](sendora://docs/webhooks) to receive `delivered`, `bounced`, `deferred` and `spam_complaint` events as they happen; every delivery is signed.\n- Systems that only speak SMTP can submit on port 465 or 587 with the server key as both user name and password; see [SMTP submission](sendora://docs/smtp).\n- The [API reference](sendora://index) has a page for every route with its fields and error codes, generated from the running code, with the SDK call beside each route."},{"uri":"sendora://docs/sending","title":"Sending","description":"Single sends, batches, attachments, custom headers, tags, metadata and idempotency.","text":"# Sending\n\nSingle sends, batches, attachments, custom headers, tags, metadata and idempotency.\n\nA message is sent one at a time or in a batch. Both take the same message and answer the same result per message. The [reference](sendora://reference/post-v1-email) lists every field, [Limits](sendora://docs/limits#messages) every size and count a message and a request are held to, and this page explains how they fit together.\n\n## The message\n\n- `from` must be an address on a [verified sending domain](sendora://docs/domains) of your account. `to`, `cc` and `bcc` take plain addresses or objects with `email` and a display `name`, and the recipients of the three are counted together.\n- `subject` is required. At least one of `text` and `html` is required; send both when you can, and receivers pick what they show.\n- `streamId` names the [stream](sendora://docs/streams) of the server the message goes on. Without it the message goes on the server's default transactional stream.\n- The message's size is counted as built: text and HTML as written, attachments encoded.\n\nSendora sets `From`, `To`, `Date`, `Message-ID`, `Return-Path` and the DKIM signature itself and refuses those headers from you. Everything the receiver needs to reply, filter or display is yours to set through `headers`.\n\n## Attachments, headers, tags and metadata\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_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\nResponse:\n\n```json\n{\n \"messageId\": \"4e1c3f2a-9b6d-4e5c-9a0f-3d4e5f6a7b82\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\"\n}\n```\n\n- `attachments`: the content travels base64-encoded without line breaks; `contentType` defaults to `application/octet-stream`; `contentId` makes an attachment addressable from the HTML as `cid:` for inline images. Attachment contents are deleted from Sendora once the mail server has taken the message; the names, types and sizes stay on the message.\n- `headers`: custom headers, such as `Reply-To` or your own `X-` headers. Headers Sendora sets itself, and the `Content-`, `Resent-` and `ARC-` families, are refused.\n- `tag`: a label returned with the message, in the log's search and in every webhook about it. One tag per message; use it for the kind of message, such as `invoice` or `welcome`.\n- `metadata`: key-value pairs of your own, keys of letters, digits, `_`, `.` and `-`. They come back with the message and in every webhook, so an order id or customer id here saves you a lookup when an event arrives.\n\nWith the SDK:\n\nThe SDK takes attachment content as bytes, a `Uint8Array` or a Node `Buffer`, and encodes it; a base64 string is passed through as it is.\n\n## Idempotency\n\nNetworks fail between your request and our answer. Name the request with an idempotency key, any printable text that identifies it on your side, and a retry within the time Sendora remembers it gets the first answer again instead of sending a second message; [Limits](sendora://docs/limits#requests) gives the key's length and that time.\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_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\nResponse:\n\n```json\n{\n \"messageId\": \"5f2d4a3b-0c7e-4f6d-8b1a-4e5f6a7b8c93\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-15T12:00:00.000Z\"\n}\n```\n\nWith the SDK:\n\nEvery send through the SDK carries a key: yours when you give `idempotencyKey`, otherwise a random one, so the SDK's own retries after a lost connection can never send twice. Give your own when a retry may come from another process, such as a job runner.\n\nOver HTTP:\n\nThe key is the `Idempotency-Key` header; the replayed answer carries `Idempotent-Replayed: true`.\n\nThe same key with a different message is refused with `idempotency_key_mismatch`: a key names one request, and a changed request is a new one. Keys are scoped to the server the token belongs to.\n\n## Batches\n\nA batch is a list of messages under one required idempotency key. Each message is accepted or refused on its own, and the answer lists one result per message in request order: `accepted` with its id, or `error` with the same code and message the single send would have given. A batch never fails halfway because one message is invalid.\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email/batch \\\n -H \"Authorization: Bearer $SENDORA_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\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 \"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 \"replayed\": false\n }\n ]\n}\n```\n\nA retry of the batch under the same key sends nothing twice: every message accepted before is answered again with `replayed: true`, and only the messages that failed are attempted again. Refusals that concern the whole account, such as a paused account, one without an active subscription or sending stopped by Sendora, refuse the whole batch before any message is looked at. The per-minute limit and the monthly cap are counted message by message, in order: the messages up to the limit are accepted, and those after it answer `rate_limited` or `monthly_cap_reached` in their results.\n\n## What happens next\n\nAn accepted message is stored, encrypted, and handed to the mail server within seconds. From then on its `recipients[].status` moves as receivers answer, and its timeline fills with events; see [Messages and events](sendora://docs/messages). To be told rather than to poll, register a [webhook](sendora://docs/webhooks)."},{"uri":"sendora://docs/streams","title":"Streams","description":"The streams of a server, transactional, broadcast and inbound, what they separate, and how to send on one.","text":"# Streams\n\nThe streams of a server, transactional, broadcast and inbound, what they separate, and how to send on one.\n\nEvery message goes on a stream of its server. A sending stream is a kind of mail with a suppression list of its own, so that a recipient who bounced or opted out of one kind still gets the others; an inbound stream is where the server receives mail. Every server starts with one default transactional stream, and a message that names no stream goes there.\n\n## Three kinds\n\n- `transactional`: mail one person expects because of something they did or have with you: receipts, invoices, sign-in links, notices. Every account can add transactional streams.\n- `broadcast`: the same message to many people who asked for it: a newsletter, service information, a notice to every customer. Any active account can create one; the recipients must have asked for the mail, as the acceptable use rules say, and a stream whose complaint rate climbs is paused until you and Sendora have looked at the list.\n- `inbound`: mail the server receives. A server has one live inbound stream at a time; a second answers `inbound_stream_exists` until the first is archived. The stream carries an `inboundAddress`, its id on `inbound.sendora.se`, and `contentRetentionDays`, how long received content is kept, within the range on [Limits](sendora://docs/limits#inbound-mail); the addresses, subjects and times stay as long as a sent message's record. It sends nothing: a message, a batch item or an SMTP submission naming it is refused with `stream_not_sendable`, and it has no suppression list. What arrives, how to read it and the webhook event are in the [inbound guide](sendora://docs/inbound).\n\nAdd a sending stream when the mail differs in kind or in who should be able to stop it, not per customer or per message. A municipality might keep `Notices` for case notices and `Receipts` for payment receipts; each has its own list of addresses that bounced.\n\nA message on a broadcast stream carries an unsubscribe link of Sendora's: write `{{ unsubscribe_url }}` in the text and in the HTML where the link goes, and every recipient gets a copy of their own, addressed to them alone, with their link there and in the `List-Unsubscribe` headers that mail clients turn into a button. A message without the placeholder in every part it has is refused with `unsubscribe_placeholder_missing`, and a `List-Unsubscribe` header of your own with `list_unsubscribe_reserved`. A recipient who unsubscribes lands on that stream's [suppression list](sendora://docs/suppressions) and your [webhook](sendora://docs/webhooks) hears `unsubscribed`.\n\n## Create a stream\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/streams \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"kind\": \"transactional\",\n \"name\": \"Notices\"\n}'\n```\n\nResponse:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null\n}\n```\n\nThe name is yours and unique within the server, archived streams included; a taken name answers `stream_exists` with the id of the stream that has it. The `streamId` is what a send names, and it never changes. A sending stream's [suppression list](sendora://docs/suppressions) starts empty, and a [webhook](sendora://docs/webhooks) can be limited to the stream.\n\n## Send on a stream\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_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\nResponse:\n\n```json\n{\n \"messageId\": \"9a7b6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d\",\n \"status\": \"accepted\",\n \"submittedAt\": \"2026-09-16T12:00:00.000Z\"\n}\n```\n\nWithout `streamId` the message goes on the default stream. A stream of another server, or an archived one, is refused with `stream_not_found` or `stream_archived`, and an inbound stream with `stream_not_sendable`. Over [SMTP](sendora://docs/smtp) the header `X-Sendora-Stream` carries the id. A broadcast stream that Sendora has paused after complaints, over the rate on [Limits](sendora://docs/limits#broadcasts), answers `stream_paused` and shows `pausedAt` until support has gone through the list with you and resumed it.\n\n## List, rename, archive\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/streams \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\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 },\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 }\n ]\n}\n```\n\nEvery message in the [log](sendora://docs/messages) carries its `streamId`, and the search filters by it. In the dashboard, each server under [Servers](https://app.sendora.se/servers) opens on its Streams tab: the streams with their last 30 days, and a page per stream with tabs of its own: its figures and, when Sendora has paused it, why; its messages; its broadcasts on a broadcast stream; its suppression list; and its settings, where it is renamed and archived. Renaming keeps the id, and the same call sets an inbound stream's `contentRetentionDays`; see [update a stream](sendora://reference/patch-v1-streams-id). Archiving, with [archive a stream](sendora://reference/post-v1-streams-id-archive), stops new messages on the stream while what it already holds is still delivered, and its log stays readable. The default stream cannot be archived.\n\nWith the SDK:\n\n`sendora.streams.update(streamId, { name })` renames, `sendora.streams.update(streamId, { contentRetentionDays })` sets an inbound stream's content window, and `sendora.streams.archive(streamId)` archives; all answer the stream."},{"uri":"sendora://docs/broadcasts","title":"Broadcasts","description":"One message to many on a broadcast stream, sent as a whole, followed as a whole, cancelled as a whole.","text":"# Broadcasts\n\nOne message to many on a broadcast stream, sent as a whole, followed as a whole, cancelled as a whole.\n\nA broadcast is the same message to many people who asked for it: a newsletter, service information, a notice to every customer. You send it once, with the content and a list of messages, on a [broadcast stream](sendora://docs/streams). Sendora stores it in one go, sends every message with the recipient's own unsubscribe link, and tells you how far it has come. Any active account can create a broadcast stream and send on it; what a broadcast may be sent to is in the acceptable use rules, and a stream whose complaint rate climbs is paused. Every broadcast is listed on its stream's page there with its status and progress, and its own page shows the counts, what the messages met and a cancel while it runs.\n\n## Send a broadcast\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts \\\n -H \"Authorization: Bearer $SENDORA_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\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}\n```\n\nThe content is given once: `from`, `subject`, `text` and `html`, headers, attachments, `tag` and `metadata`. Each entry of `messages` is one message with its own `to`, `cc` and `bcc`, and may carry `metadata` and `headers` of its own, laid over the broadcast's. A broadcast is held to a number of messages and a size, and each message in it to a single send's size and recipients, all on [Limits](sendora://docs/limits#broadcasts); a larger list is several broadcasts under one tag. The `Idempotency-Key` is required, so a retry answers the same broadcast and never sends twice.\n\nWrite `{{ unsubscribe_url }}` in the text and in the HTML where the link goes; every recipient gets a copy addressed to them alone, with their link there and in the `List-Unsubscribe` headers. Without the placeholder the broadcast is refused with `unsubscribe_placeholder_missing`, and a `List-Unsubscribe` header of your own with `list_unsubscribe_reserved`.\n\nAddresses on the stream's [suppression list](sendora://docs/suppressions) are dropped, counted in `suppressed`, and never refuse the rest; a message with no address left is not stored, and a list with nothing left is refused whole with `recipient_suppressed`. The whole list counts against the monthly cap at once: a broadcast that would pass it is refused with `monthly_cap_reached` before anything is stored. The per-minute limits do not apply to a broadcast. A transactional stream answers `stream_not_broadcast` and an archived one `stream_archived`.\n\n## Personalise\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts \\\n -H \"Authorization: Bearer $SENDORA_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\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}\n```\n\nWrite `{{ key }}` in the subject, the text or the HTML, and give each message its `substitutions`: strings under keys of letters, digits and underscore, as many and as long as [Limits](sendora://docs/limits#broadcasts) allows. Sendora fills them in per message, escaped in the HTML part, before the unsubscribe link; the subject in the log is the rendered one. A key the content names that a message does not give is refused whole with `substitution_missing`, naming the message's position and the keys. Nothing else is rendered: no conditions, no loops, no filters.\n\n## Follow it\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/broadcasts/{broadcastId} \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\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` is the number of messages stored; `released` and `failed` move as Sendora hands them to the mail server, and `status` goes from `accepted` through `processing` to `completed` once nothing is left, with `completedAt` set. Every message of the broadcast is a message in the [log](sendora://docs/messages) with its own timeline, carrying the `broadcastId`; the search filters by it, and every [webhook](sendora://docs/webhooks) event of such a message names it too, so delivery, bounces, complaints and unsubscribes are followed per message as for any other. The list, newest first, is [list the broadcasts](sendora://reference/get-v1-broadcasts).\n\n## Cancel\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/broadcasts/{broadcastId}/cancel \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\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 holds every message that has not been handed to the mail server yet; those count as `failed`, and the broadcast is `cancelled` with `completedAt` set. A message already on its way is delivered. A completed or cancelled broadcast answers `broadcast_not_open`.\n\nWith the SDK:\n\n`sendora.broadcasts.send(broadcast)` answers the accepted broadcast, `sendora.broadcasts.get(broadcastId)` its progress, `sendora.broadcasts.list()` the newest first and `sendora.broadcasts.cancel(broadcastId)` the cancelled broadcast; a refused broadcast throws `SendoraError` with the code above."},{"uri":"sendora://docs/domains","title":"Domains","description":"Verify a sending domain with two DNS records, and what happens to SPF and DMARC.","text":"# Domains\n\nVerify a sending domain with two DNS records, and what happens to SPF and DMARC.\n\nYour account sends only from domains it has verified, and every server of the account sends from them. Verification is two DNS records, added once, and Sendora checks them for you. You never touch your own SPF record.\n\nDomains belong to the account, so they are managed with an account key (`ak_…`), the one an administrator creates in the dashboard under [Account › API keys](https://app.sendora.se/account/tokens); a server key only sends from them. See [Keys](sendora://docs/keys).\n\n## Add the domain\n\nThe answer carries the two records to add. The DKIM key pair is generated for this domain when it is added; the private key never leaves Sendora.\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\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\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. Because the record is a CNAME, the SPF record that authorises Sendora's sending addresses is ours to keep current, and your own SPF record stays as it is.\n- `dkim`: a TXT record under `<selector>._domainkey.<your domain>` holding the public key. Every message is signed with it, 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. Adding a domain the account already has answers `domain_exists` with 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\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/domains/{domainId}/verify \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\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: `ok`, `missing`, `mismatch` when a record exists with another value, or `dns_error` when the resolver could not get a trustworthy answer, which includes a failed DNSSEC validation. The 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, which the next check finds 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## Every domain of the account\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/domains \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\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\nRemoving a domain 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":"Two kinds of API key, what each may do, how many you can hold, and how to rotate one.","text":"# Keys\n\nTwo kinds of API key, what each may do, how many you can hold, and how to rotate one.\n\nEvery request carries an API key as a bearer token. There are two kinds, and each does what the other cannot.\n\n## Two kinds of key\n\n- A **server key** starts with `sk_` and belongs to one server. It sends, reads the message log, and manages the server's streams, suppressions, webhooks and its own keys. It is what your application holds, and what an SMTP client signs in with. A [test server](sendora://docs/test-servers)'s keys start with `sk_test_`.\n- An **account key** starts with `ak_` and belongs to the account. It creates, renames and deletes servers, manages every server's keys, and manages the sending domains the servers send from. It never sends and never reads mail. An administrator creates one in the dashboard under [Account › API keys](https://app.sendora.se/account/tokens); the account holds up to two.\n\nA key of the wrong kind answers `403 wrong_token_kind`, and the message names the kind the operation needs.\n\nWith the SDK:\n\nThe SDK has a client for each: `Sendora` takes a server key, `SendoraAccount` an account key. Keep both in the environment on the server, as `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN` below, and never ship either to a browser.\n\n## A server comes with its first key\n\nCreating a server, in the dashboard or with the account key, creates its default transactional stream and its first key, whose value is in the answer this once:\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: 'Fakturering',\n});\n\nconsole.log(server.serverId);\nconsole.log(server.token.token);\n```\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\": \"Fakturering\"\n}'\n```\n\nResponse:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Fakturering\",\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. A server is never without a key, so this is the one place a server and a key are made together.\n\n## Up to two live keys\n\nA server holds at most two live keys, and so does the account: the one in use and the one being rotated in. A third answers `409 token_limit`. Every key of a server has the same rights, so the name is for people to tell them apart.\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: 'Fakturasystemet' },\n);\n\nconsole.log(key.token);\n```\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\": \"Fakturasystemet\"\n}'\n```\n\nResponse:\n\n```json\n{\n \"tokenId\": \"9f2d7e6c-5b40-4e8b-8a1c-3b0c9e2f6d4a\",\n \"name\": \"Fakturasystemet\",\n \"prefix\": \"sk_c3d4e5f6g7\",\n \"createdAt\": \"2026-09-20T12:05:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x\"\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 { 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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/servers/{serverId}/tokens \\\n -H \"Authorization: Bearer $SENDORA_ACCOUNT_TOKEN\"\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\": \"Fakturasystemet\",\n \"prefix\": \"sk_c3d4e5f6g7\",\n \"createdAt\": \"2026-09-20T12:05:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\nA server key manages its own server's keys the same way under `/v1/tokens`; the account key does it for any server of the account under `/v1/servers/{serverId}/tokens`.\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\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\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\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\n## If a key leaks\n\nRevoke it. If it is a server's only key, create another first and move your systems to it, then revoke the leaked one; 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 a key you cannot account for is a key to revoke."},{"uri":"sendora://docs/test-servers","title":"Test servers","description":"A server that does everything but deliver, for staging and CI; the simulator's addresses, the test limits, and going live.","text":"# Test servers\n\nA server that does everything but deliver, for staging and CI; the simulator's addresses, the test limits, and going live.\n\nA test server takes mail exactly as a live server does and delivers none of it. The key, the From domain, the suppression list and the limits are checked, the message is stored in the log and the webhooks fire, but the message never reaches a mail server, so nothing can reach a recipient. Use one for your staging environment and your CI, and keep your live server for mail that must arrive.\n\n## Create one\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, give `mode: \"test\"`:\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\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\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 says which kind of server it belongs to wherever it is written down. The mode is fixed when the server is created: a rename that names a mode answers `400 mode_immutable`. The server shows a **Test** label in the dashboard, and every page under it says that nothing is sent from it.\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 carry `test: true`:\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_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\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\nSMTP submission works the same way: sign in with the test server's key as the password, and the reply says the message is a test.\n\n## The simulator's addresses\n\nEvery address a test server sends to is marked delivered, unless it is at `simulator.sendora.se`, where the part before the `@` chooses 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 the stream |\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, and a `+label` after the name is kept: `hardbounce+signup-test@simulator.sendora.se` bounces like `hardbounce@`. A suppression is written for the whole address, label included, as on a live server, so give each run its own label and a bounce in one run never stops the next. A live server refuses these addresses before anything is stored:\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/email \\\n -H \"Authorization: Bearer $SENDORA_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\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 never counts toward your plan or the account's monthly cap. All the account's test servers share their own limits, on [Limits](sendora://docs/limits#test-servers), and a refusal names 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. It has no inbound stream: creating one answers `422 stream_kind_not_allowed`, so receive on a live server.\n\n## Retention\n\nA test server keeps less than a live one: the text and HTML of a message for a short time, and the rest of the message, its events and its webhook deliveries somewhat longer, as [Limits](sendora://docs/limits#retention) gives them. Attachments' content goes as soon as the simulator has the message, as on a live server once the mail server has it.\n\n## Going live\n\nCreate a live server and its key, and move your production configuration to it; a test server never becomes live.\n\nWith the SDK:\n\n`isTestKey` tells the kinds of key apart, so your application can refuse the wrong one when it starts:\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\nOver HTTP:\n\nA test key starts with `sk_test_`, and any other server key with `sk_`. Check the prefix when your application starts, and refuse a test key where mail must arrive.\n\nThe API also tells a key which server it belongs to: its `mode`, every limit a send on it is held to with how much of it this minute and this month have used, and the account's sending domains a From address may use.\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/server \\\n -H \"Authorization: Bearer $SENDORA_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 \"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, the timeline of a message, searching the log, and how long things are kept.","text":"# Messages and events\n\nStatuses, the timeline of a message, searching the log, and how long things are kept.\n\nEvery message you send is in the log with its recipients, its status and a timeline of everything the mail server and the receivers said about it. The server's **Messages** tab under [Servers](https://app.sendora.se/servers) in the dashboard shows the same log; the API gives it to you.\n\n## Statuses\n\nA message has a status of its own and one per recipient.\n\n- Message: `accepted` once stored, `injected` once the mail server has taken it, `failed` if that never happened.\n- Recipient: `queued` until the receiver answers, `delivered` when it accepted the mail, `deferred` while it keeps asking us to try later, `bounced` when it refused, `expired` when it kept deferring until Sendora gave up, after three days.\n\n## The timeline\n\nEach recipient's timeline is a list of events with the receiver's SMTP code and text where there is one:\n\n- `Reception`: the mail server took the message from Sendora.\n- `Delivery`: the receiver accepted it.\n- `TransientFailure`: the receiver said try later; Sendora retries.\n- `Bounce`: the receiver refused it for good, with the reason in `details` and, where Sendora can tell, a `classification` such as `InvalidRecipient`.\n- `OOB`: a bounce report that arrived later by mail on the return-path address.\n- `Feedback`: a spam complaint reported by the receiver through a feedback loop.\n- `Expiration`: the retries ran out.\n\nThe other event types are operational and rare. Read a message with its timeline by id:\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/messages/{messageId} \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\n```\n\nResponse:\n\n```json\n{\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\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 \"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\nA recipient address travels in the request body and never in a URL. Every filter is optional; the page is newest first, in the sizes on [Limits](sendora://docs/limits#pages), with `next` to pass back as `after` for the following page.\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/messages/search \\\n -H \"Authorization: Bearer $SENDORA_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\nResponse:\n\n```json\n{\n \"messages\": [\n {\n \"messageId\": \"5f432ffd-c005-499b-aa3a-5b8a088ea20e\",\n \"streamId\": \"3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d\",\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 \"recipients\": [\n {\n \"address\": \"anna@example.com\",\n \"kind\": \"to\",\n \"status\": \"delivered\"\n }\n ]\n }\n ],\n \"next\": null\n}\n```\n\n`recipient` matches messages to that address in any of to, cc and bcc; `streamId` matches messages on one [stream](sendora://docs/streams); `status` matches messages with at least one recipient in that state; `from` and `to` bound the submission time. Every message carries the `streamId` it went on.\n\nWith the SDK:\n\n`messages.searchAll` takes the same filters and walks every page for you, for `for await`; `from` and `to` also take a `Date`.\n\n## How long things are kept\n\n- The text and HTML of a message are deleted first, and sooner on a [test server](sendora://docs/test-servers).\n- The content of its attachments is deleted as soon as the mail server has the message, or the simulator on a test server, or once the message has failed; their names, types and sizes stay with the message.\n- The message itself, its recipients and its events are kept longer, so a dispute or an audit a year later still has the record, and on a test server for less.\n\n[Limits](sendora://docs/limits#retention) gives each period.\n\n- When a recipient asks you to erase their data, write to support: the content of every message to them is deleted at once and their address is replaced by a pseudonym in the log.\n\nSendora never returns a message's content; what you sent is yours to keep."},{"uri":"sendora://docs/inbound","title":"Inbound","description":"Receiving mail on an inbound stream or on a domain of your own, the webhook event, and reading a received message, its attachments and its raw form.","text":"# Inbound\n\nReceiving mail on an inbound stream or on a domain of your own, the webhook event, and reading a received message, its attachments and its raw form.\n\nA server receives mail on its [inbound stream](sendora://docs/streams). The stream has an address of its own, `<stream id>@inbound.sendora.se`, and can receive on a domain of yours; anything sent there is accepted, parsed, checked for SPF, DKIM, DMARC and ARC, stored under your account's key and handed to your [webhook](sendora://docs/webhooks) as the event `inbound`. Mail the address does not know is refused at the door, so nothing is queued or bounced later.\n\n## The address\n\nCreate the stream with `kind: \"inbound\"` and read `inboundAddress` from the answer. The local part is the stream id, and anything after a plus sign in it is yours: `<stream id>+ticket-42@inbound.sendora.se` is delivered to the same stream with `mailboxHash: \"ticket-42\"` on the message, so one address can route by ticket, by customer or by form.\n\nA message up to the size on [Limits](sendora://docs/limits#inbound-mail) is accepted. Its content, the text, the HTML, the headers, the attachments and the raw message, is kept for the stream's `contentRetentionDays`; what remains after that is the reference: who it was to, when it arrived, its sizes and its verdicts, kept as long as a sent message's record.\n\n## Your own domain\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/domains \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\"\n}'\n```\n\nResponse:\n\n```json\n{\n \"inboundDomainId\": \"6c2a9e1d-3f4b-4a8c-9d0e-1b2c3d4e5f60\",\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"domain\": \"post.example.se\",\n \"verified\": false,\n \"createdAt\": \"2026-09-19T09:00:00.000Z\",\n \"lastCheckedAt\": null,\n \"unverifiedAt\": null,\n \"mx\": {\n \"type\": \"MX\",\n \"host\": \"post.example.se\",\n \"value\": \"10 inbound.sendora.se.\",\n \"verified\": false,\n \"verifiedAt\": null\n },\n \"txt\": {\n \"type\": \"TXT\",\n \"host\": \"_sendora-inbound.post.example.se\",\n \"value\": \"sendora-inbound=7f3a9c2e5b1d4f6a8c0e2b4d6f8a1c3e\",\n \"verified\": false,\n \"verifiedAt\": null\n }\n}\n```\n\nA stream can receive on a domain of yours instead: every address on it, whatever the local part, lands on the stream, and `support+ticket-42@post.example.se` carries `mailboxHash: \"ticket-42\"` as the hosted address does. Claim the domain with the stream id and add the two records the answer gives to the domain's DNS: an `MX` at the domain itself that names `inbound.sendora.se`, at any priority, and a `TXT` record at `_sendora-inbound.<domain>` with the token the answer shows, which proves the domain is yours to claim. Sendora looks the records up on its own within minutes, and on request:\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/domains/{inboundDomainId}/verify \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\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` for a resolver failure. Mail is accepted once both records are seen, within a minute of the check. A stream holds one domain, a domain that is Sendora's own answers `domain_reserved`, and the first account to verify a name holds it: a claim to a name another account holds verified answers `inbound_domain_exists`. A verified domain whose records disappear keeps receiving through two checks and loses its verification on the third; from then mail to it is deferred for a grace period, so a DNS mistake can be fixed without a bounce, and refused after that until the records are back; [Limits](sendora://docs/limits#inbound-mail) gives the period. Removing the domain refuses mail to it at once and frees the name.\n\n## The event\n\nA webhook with `inbound` among its events gets one delivery per received message. With `inboundContent: 'full'`, the default, the event carries the message: the sender, the parties, the subject, the headers, the text and the HTML, and the attachments described; with `reference` it carries the reference alone and you read the rest through the API. The [webhooks guide](sendora://docs/webhooks) lists the fields.\n\n## Search received mail\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/inbound/search \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"streamId\": \"b7d2e4f6-1a3c-4d5e-9f80-2c4e6a8b0d1f\",\n \"limit\": 20\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 (matched by a keyed hash, so the search stores no address), the recipient address of yours, the `mailboxHash`, and the received window. The page is newest first; pass `next` as `after` for the following page. Each item carries `from`, `subject` and `date` while the content is there, and `contentAvailable`.\n\n## Read a message\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/inbound/4d1f8b2e-9c3a-4e7b-8f21-6a5d0c9e7b31 \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\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\nThe message answers its envelope, `from`, `replyTo`, `to` and `cc` (each as long as [Limits](sendora://docs/limits#inbound-mail) allows, with the whole number in `toCount` and `ccCount`), `subject`, `date`, `messageIdHeader`, `inReplyTo`, `references`, every header, `text`, `html` and the attachments described with an `attachmentId` each. `authentication` holds the verdicts: `spf` for the envelope sender and `spfHelo` for the sending server's name, `dkim`, `dmarc` with the domain's `dmarcPolicy` when DMARC failed, and `arc`; `checkedAt` says when they were computed. `parseIssue` names what the parser met, from a closed set, or is null; a message the parser could not read still answers its envelope, times, sizes and verdicts. Once the content window has passed, `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` answers the message byte for byte as it was received, as `message/rfc822`; `GET /v1/inbound/{id}/attachments/{attachmentId}` answers an attachment's bytes as `application/octet-stream`, whatever the sender declared, with the sanitised name in `Content-Disposition`. Both come with headers that keep a browser from rendering or running them. After the content window both answer `410 content_expired`.\n\nWith the SDK:\n\n`sendora.inbound.raw(inboundMessageId)` and `sendora.inbound.attachment(inboundMessageId, attachmentId)` answer the bytes as a `Uint8Array`, with the same errors and retries as every other call.\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 arende.eml\n```\n\n## What stays and what goes\n\nThe sender's data lives only in the sealed content and goes with the stream's window; an address a person asks you to erase is erased from received mail too, through the same request as for sent mail. The dashboard shows the stream's received mail under the server's **Streams** tab in [Servers](https://app.sendora.se/servers), and every download from there is audited."},{"uri":"sendora://docs/webhooks","title":"Webhooks","description":"Events pushed to your endpoint, how to verify the signature, and how retries, dead letters and replays work.","text":"# Webhooks\n\nEvents pushed to your endpoint, how to verify the signature, and how retries, dead letters and replays work.\n\nA webhook is an HTTPS URL of yours that Sendora posts events to as they happen. Register as many as you need per server; choose the events you want or take them all, and limit one to a single [stream](sendora://docs/streams) with `streamId` when another system should hear about that stream alone.\n\n## Register\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/webhooks \\\n -H \"Authorization: Bearer $SENDORA_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\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 \"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 answer carries `secret` once, the webhook's first signing secret; store it beside the token. The URL must use HTTPS and point at a public host; a URL that resolves to a private address is refused at delivery time. A URL the server already has answers `webhook_exists` with the id of the existing webhook, whatever its stream. With `streamId`, message events of that stream alone are delivered; cap events concern the server and reach every webhook that subscribes to them.\n\n## Events\n\nMessage events carry the message id, the `streamId` it went on, the recipient, the tag and metadata you sent, `test`, which is true only for a test server's message whose events were simulated and never sent, and details from the receiver:\n\n- `delivered`: the receiver accepted the message for that recipient. Details: the SMTP `code` and `response`.\n- `bounced`: the receiver refused it for good, or Sendora gave up. Details: `code`, `response`, the `classification` such as `InvalidRecipient`, `hard` when the recipient was added to the server's [suppression list](sendora://docs/suppressions), and `expired` when the retries ran out.\n- `deferred`: the receiver asked us to try later; Sendora keeps retrying and you may see several of these. Details: `code` and `response`.\n- `spam_complaint`: the recipient reported the message as spam through the receiver's feedback loop. The recipient is suppressed and only Sendora support can lift that. Details: the `feedbackType`.\n- `unsubscribed`: the recipient used the unsubscribe link of a message on a [broadcast stream](sendora://docs/streams), or their mail client's one-click button. The recipient is on that stream's suppression list and only Sendora support can lift that; take them off your own list. The event is sent a minute after the click, since the page lets the recipient undo it for that long. Details: the `source`, `link` or `one_click`.\n\nTwo events concern usage rather than a message: `cap_warning` when 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-ceiling), and `cap_reached` when a send was refused because it was used up. They carry the scope, the cap, the usage and the period.\n\nA message received on an [inbound stream](sendora://docs/streams) is the event `inbound`. It always carries the reference: `inboundMessageId`, `streamId`, `receivedAt`, `envelopeRecipient` (the address of yours it was sent to) and `mailboxHash` (the text after `+` in it), `sizeBytes`, `attachmentCount`, `hasText`, `hasHtml`, the `authentication` verdicts (`spf`, `spfHelo`, `dkim`, `dmarc`, `dmarcPolicy`, `arc` and `checkedAt`) and `parseIssue`. A webhook with `inboundContent: 'full'`, the default, also gets the message: `envelope.sender`, `from`, `replyTo`, `to` and `cc` (each as long as [Limits](sendora://docs/limits#inbound-mail) allows, with `toCount` and `ccCount`), `subject`, `date`, `messageIdHeader`, `inReplyTo`, `references`, `headers`, `text`, `html` and the `attachments` descriptors. `contentOmitted` is `too_large` when `text` or `html` was left out for size and `expired` when the stream's content window has passed and only the reference remains; with `inboundContent: 'reference'` none of these fields is sent. A webhook subscribes to the events it was created with, so one created before this event existed does not receive it.\n\nEvery request body is JSON with `id`, the delivery id, and `attempt`, the attempt number, followed by the event's fields. Answer any 2xx status quickly and do your work afterwards; anything else, or no answer within the timeout, is a failed attempt.\n\n## Verify the signature\n\nEvery request carries `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`: one `v1` per live secret of the webhook, the newest first, each an HMAC-SHA256 with that secret over `<t>.<raw body>`. Your secret matches one of them, and one match is enough. Verify against the raw body before you parse anything, and refuse timestamps older than a few minutes so a captured request cannot be replayed:\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\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 older than a few minutes, 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\nWith the SDK:\n\n`verifyWebhook` compares through WebCrypto, constant time by construction, refuses a signature older than five minutes (`stale_signature`) or a header none of whose signatures matches (`invalid_signature`), and answers the event typed by its `event`, so `details` narrows with it. With a client at hand, `sendora.webhooks.receive(request, secret)` does the same from a Fetch `Request` in one call. Sendora's own tests run the verifier against signatures the dispatcher produces.\n\nOver HTTP:\n\nCompare with a constant-time comparison in your language, never with plain string equality, and accept the request when any `v1` matches. The recipe is the whole contract: any language with an HMAC-SHA256 can verify it.\n\n## Roll the secret\n\nA webhook holds up to two live secrets, so a new one can be rolled in without a gap: create it, and every delivery is signed with both until you delete the old one.\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/webhooks/{webhookId}/secrets \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\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\nMove your receiver to the new secret; deliveries verify with either meanwhile. Then delete the one your receiver no longer holds, by its id from the webhook's `secrets`:\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\ncurl:\n\n```bash\ncurl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId}/secrets/{secretId} \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\n```\n\nA third secret answers `secret_limit`, and the last live secret cannot be deleted (`last_secret`), so every delivery always carries a signature. The webhook's page in the dashboard, under the server's [Webhooks](https://app.sendora.se/servers) tab, does the same.\n\n## Retries, dead letters and replay\n\nA failed attempt is retried with growing delays, about a day in all, on the schedule in [Limits](sendora://docs/limits#webhooks). After that the delivery is `dead` and stays listed until you replay it. Deliveries are attempted in order per webhook but arrive at least once, not exactly once, and not always in order, so key your handling on `id`.\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/webhooks/{webhookId}/deliveries?status=dead&limit=50 \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\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 \"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 with the same payload, whatever its state; the attempt count keeps counting. The [reference](sendora://reference/post-v1-webhooks-id-deliveries-deliveryid-replay) has the call.\n\n## Remove\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\ncurl:\n\n```bash\ncurl -X DELETE https://api.sendora.se/v1/webhooks/{webhookId} \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\n```\n\nDeliveries still pending for the webhook are dropped with it."},{"uri":"sendora://docs/suppressions","title":"Suppressions","description":"Addresses a server no longer sends to, why they got there, and how to lift one.","text":"# Suppressions\n\nAddresses a server no longer sends to, why they got 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. The routes on this page take `streamId` and mean the default transactional stream without it. A send to any of them is refused with `recipient_suppressed`, naming the addresses and the stream, before anything leaves Sendora. The list protects your reputation and ours: mail to an address that bounced or complained is what gets senders blocked.\n\n## How an address gets there\n\n- `hard_bounce`: a receiver refused the address for good, for instance because the mailbox does not exist. The `bounced` event tells you which message.\n- `spam_complaint`: the recipient reported a message as spam and the receiver told us through its feedback loop.\n- `manual`: you added the address yourself, on the stream's **Suppressions** tab in the dashboard, under its server's Streams tab ([Servers](https://app.sendora.se/servers)), which also shows the list and lifts an entry.\n- `unsubscribe`: the recipient used the unsubscribe link of a message on a [broadcast stream](sendora://docs/streams), or their mail client's one-click button. The `unsubscribed` [webhook](sendora://docs/webhooks) tells you.\n\nSoft bounces and deferrals never suppress anything.\n\n## Read the list\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\ncurl:\n\n```bash\ncurl https://api.sendora.se/v1/suppressions?limit=100 \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\"\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#pages), with `next` to pass back as `after`. Every entry carries the `streamId` it belongs to; `streamId` on the request reads another stream's list, and a stream that is not the server's answers `stream_not_found`.\n\n## Lift a suppression\n\nA hard bounce or a manual entry may be lifted by you, for instance when the recipient has confirmed a corrected mailbox. The address travels in the request body and never in a URL; `streamId` in the body names the stream, the default one without it.\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\ncurl:\n\n```bash\ncurl -X POST https://api.sendora.se/v1/suppressions/delete \\\n -H \"Authorization: Bearer $SENDORA_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"address\": \"bounced@example.com\"\n}'\n```\n\nA spam complaint or a recipient's own unsubscribe cannot be lifted this way: the request answers `spam_complaint_locked` or `unsubscribe_locked`. Write to support with the recipient's own request, and it is lifted by hand.\n\n## Erasure requests\n\nWhen a recipient asks you to erase their data, write to support with the address. Every message to them loses its content at once, the address becomes a pseudonym in the log, a hard-bounce suppression is removed, and a spam-complaint suppression keeps only a keyed hash of the address, so the recipient stays protected from further mail without their address being stored."},{"uri":"sendora://docs/smtp","title":"SMTP submission","description":"Send from a system that only speaks SMTP, with the same server key, rules and log as the API.","text":"# SMTP submission\n\nSend from a system that only speaks SMTP, with the same server key, rules and log as the API.\n\nSystems that cannot call an HTTP API, from an ERP to a printer, can hand Sendora mail over SMTP. The message goes through the same checks as an API send, lands in the same log with the same events, and counts against the same limits.\n\n## Connect\n\n- Host `smtp.sendora.se`, port 465 with TLS from the start or port 587 with STARTTLS. TLS is required; a client that tries to authenticate before it is refused.\n- Authenticate with a server key (`sk_…`) as both user name and password, with `PLAIN` or `LOGIN`.\n- The `From` address must be on a verified domain of the server; `To`, `Cc` and `Bcc` are counted together, and the message is held to the recipients and size on [Limits](sendora://docs/limits#messages), as over the API.\n\n```typescript\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\n## Tags, metadata and headers\n\nSet `X-Sendora-Stream` for the [stream](sendora://docs/streams) the message goes on, `X-Sendora-Tag` for the message's tag and `X-Sendora-Metadata-<key>` for each metadata entry; `X-Sendora-Metadata-Customer: 4711` becomes `{ \"customer\": \"4711\" }`. Attachments are ordinary MIME parts. Of your other headers, `Reply-To`, `List-Unsubscribe`, `List-Unsubscribe-Post` and your own `X-` headers are kept; the rest, such as `Message-ID`, `Received` or a `DKIM-Signature`, are dropped, and Sendora writes its own, where the API would refuse them.\n\n## What you hear back\n\nAn accepted message is answered `250 2.0.0 OK: queued as <messageId>`; the id is the same one the log and the webhooks use. A refusal is answered at once, with a 5xx code when trying again will not help and a 4xx code when it may:\n\n- `550 5.7.1` for a From domain that is not verified, a paused or unapproved account, or an account without a subscription.\n- `550 5.1.1` naming suppressed recipients.\n- `550 5.6.0` for a message the API would refuse as invalid, with the first reasons in the text.\n- `552 5.3.4` for a message over the size limit, `452 4.5.3` for more recipients than a message may have.\n- `451 4.7.0` when the per-minute limit or the monthly cap is reached, with when to try again in the text.\n- `451 4.3.2` while sending is disabled for everyone, and `451 4.3.0` for a temporary failure on our side.\n\nThere is no accept-then-report: what SMTP accepts is what the API would have accepted, and the message is already in the log when the `250` arrives."},{"uri":"sendora://docs/errors","title":"Errors","description":"The error body, every code the API answers, what the per-minute limit and the monthly ceiling answer, and what pauses an account.","text":"# Errors\n\nThe error body, every code the API answers, what the per-minute limit and the monthly ceiling answer, and what pauses an account.\n\nEvery error answers a JSON body with an `error` code for your program and a `message` for a person. A validation error adds `issues`, one per field, with a dotted `path` such as `to.0.email`. A refusal that carries more, such as the suppressed recipients or when a limit resets, adds those fields; the [reference](sendora://index) shows each response.\n\nWith the SDK:\n\nThe SDK throws `SendoraError` for every failed call, with the same `code`, the HTTP `status`, and the body's extra fields on the error: `issues`, `retryAfter`, `scope`, `limit`, `suppressed`, `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. Rate limits, a paused sending and server errors are retried on their own within a few seconds; a longer `retryAfter` is thrown at once for you to schedule.\n\n## The codes\n\nThe table is generated from the API's own routes, so it is complete.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\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 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 the List-Unsubscribe pair is written by Sendora and refused from the customer. |\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 tenant, 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| `tenant_not_active` | 403 | The account is not approved to send. |\n| `tenant_paused` | 403 | The account is paused by Sendora. |\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 bearer token 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. |\n\n## Limits\n\nTwo limits apply to every account, counted per recipient, and a server can be given tighter ones of its own; [Limits](sendora://docs/limits#sending-rates-and-the-monthly-ceiling) gives their numbers and every other limit.\n\n- Over the per-minute limit, a send answers `rate_limited` with the seconds until the window turns; the message is not stored, so send it again then.\n- The monthly cap is the account's ceiling, which an administrator moves under [Account › Usage](https://app.sendora.se/account/usage) in the dashboard. When it is used up, a send answers `monthly_cap_reached` with the cap, the usage and when it resets. Before that a `cap_warning` webhook goes out once per month, and a `cap_reached` webhook once when a send was refused.\n\nThe per-minute limit and a server's own limits are settings Sendora holds; write to support to change them. The [pricing](https://sendora.se/pricing) page states what a month costs.\n\n[Test servers](sendora://docs/test-servers) have limits of their own, shared by all the account's test servers. Their refusals carry the scope `test`, and test mail never counts toward the account's ceiling.\n\n## When an account cannot send\n\n- `payment_required` (402): the account has no active subscription. Activate payment under [Billing](https://app.sendora.se/billing) in the dashboard; sends are refused until then.\n- `tenant_not_active` (403): the account is not approved yet. Every account is reviewed by hand before it may send.\n- `tenant_paused` (403): Sendora has paused the account, or its subscription is unpaid. Support tells you why.\n- `sending_disabled` (503): sending is disabled for everyone for the moment; retry after the seconds given.\n\nOver SMTP the same refusals arrive as SMTP replies; see [SMTP submission](sendora://docs/smtp)."},{"uri":"sendora://docs/limits","title":"Limits","description":"Every limit an account meets and its number, from the sending rates and the monthly ceiling to messages, broadcasts, SMTP, inbound mail, retention, webhooks, pages, sign-in and the proxy.","text":"# Limits\n\nEvery limit an account meets and its number, from the sending rates and the monthly ceiling to messages, broadcasts, SMTP, inbound mail, retention, webhooks, pages, sign-in and the proxy.\n\nEvery number on this page is read from the service's code by a test, so the page and the service say the same. The other guides link here instead of repeating them, and [Errors](sendora://docs/errors) says what each refusal looks like.\n\n## Sending rates and the monthly ceiling\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 ceiling | 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 ceiling | 10 times the ceiling approval set, unless Sendora sets another |\n| Warning | A `cap_warning` webhook at 80 percent of the ceiling, once a month |\n\nAn administrator moves the ceiling under [Account › Usage](https://app.sendora.se/account/usage), anywhere up to the highest; write to support to go past that. An account approved without an application starts at the ceiling of the smallest volume. Sendora can give a server a per-minute limit and a monthly cap of its own, only ever tighter than the account's. A broadcast counts against the ceiling whole when it is accepted, and the per-minute limit does not apply to it.\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 ceiling, and its refusals carry 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: text and HTML as written, attachments encoded |\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` answer 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\nA message the parser cannot hold to these is kept whole, its raw form downloadable for the content window, with `parseIssue` saying why:\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\nPast the header, part, size or time limits the parsed text, HTML and attachments are empty; past the last three the message is parsed and the rest is left out.\n\n## Retention\n\n| What | Kept for |\n| -------------------------------------------------------------------------- | ------------------------------------------------------- |\n| A message's text and HTML | 30 days |\n| Its attachments' content | Until the 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## Pages\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## The proxy\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 documentation and tools. The install for Claude Code, Cursor, VS Code, Codex and Claude Desktop, what each key lets it do, and every tool.","text":"# MCP server\n\nGive a coding agent Sendora's documentation and tools. The install for Claude Code, Cursor, VS Code, Codex and Claude Desktop, what each key lets it do, and every tool.\n\nSendora's MCP server gives a coding agent, such as Claude Code, Cursor or VS Code, Sendora's documentation and tools for your servers while it builds your integration. It reads the guides and the API reference, sends a test mail and reads what happened to it, and sets up streams, webhooks and sending domains. Without a key it offers the documentation alone.\n\nIt runs two ways, with the same tools:\n\n- Locally, as `npx -y @sendora/mcp`, over stdio. It needs Node 20.19 or newer, and the documentation is built into the package, so it answers offline.\n- Hosted, at `https://mcp.sendora.se/mcp`, with the key as a bearer token, and nothing to install. Without a key, `https://mcp.sendora.se/docs` serves the documentation.\n\n## Choose a key\n\nThe server does what its key allows, and lists only the tools the key may use:\n\n- A test server's key, `sk_test_…`, lets it do everything on that server, and nothing is delivered. Start here: a [test server](sendora://docs/test-servers) takes mail as a live one does, and the simulator's addresses act out bounces, deferrals and complaints.\n- A live server's key, `sk_…`, gets the tools that read the message log, received mail and the server's settings, and those that change something once you switch changes on.\n- The account key, `ak_…`, gets the tools that read the account's servers and sending domains, and with changes on those that add a sending domain or delete a test server.\n- With no key, it reads the documentation, so you can build before your account is approved.\n\n[Keys](sendora://docs/keys) says where each key is made. Put the one you choose in your environment as `SENDORA_API_TOKEN`, from your shell's profile or your secret manager: the configurations for Claude Code, Cursor and Codex read it from there, and VS Code and Claude Desktop ask for it and keep it in their own secret storage. Either way, no file you commit holds it.\n\n## Install\n\nOne click, for the local server:\n\n- [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=sendora&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBzZW5kb3JhL21jcCJdLCJlbnYiOnsiU0VORE9SQV9BUElfVE9LRU4iOiIke2VudjpTRU5ET1JBX0FQSV9UT0tFTn0ifX0=) adds the local entry shown under Cursor, with the key from `SENDORA_API_TOKEN`.\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) adds the local entry shown under VS Code to your profile, and asks for the key when the server first starts.\n- [Download the bundle for Claude Desktop](https://sendora.se/downloads/sendora-mcp.mcpb), then open it.\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 its full minute for an outcome:\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\nIn `.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\nIn `.vscode/mcp.json`. 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\nIn `~/.codex/config.toml`. Codex passes a local server a few system variables, such as `PATH` and `HOME`, and of the rest only those you name. It gives a tool a minute unless told otherwise, so both entries set `tool_timeout_sec` to let `send_email` wait its full minute. Locally:\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\nEdit the file rather than use `codex mcp add --env`, which writes the key's value into it.\n\n### Claude Desktop\n\n[Download the bundle](https://sendora.se/downloads/sendora-mcp.mcpb) and open it, and Claude Desktop asks whether to install it; it runs on the Node that Claude Desktop brings. Paste your key into **Sendora key**, which Claude Desktop keeps in your operating system's secure storage, or leave it empty for the documentation alone. Tick **Allow changes** only when you want changes on. Each release comes with a new bundle here, which you install over the old one.\n\nA 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 answers 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\nIf the agent has only `search_docs` and `read_doc`, no key reached the server: set `SENDORA_API_TOKEN` where the client runs, and start the client again. If the client shows the server as failed, the key did not reach it or the API refused it: check that the variable is set there and that the key is still live in the dashboard.\n\n## Changes on\n\nA live server's key and the account key get only the reading tools until you switch changes on 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, and a test key needs no switch. With changes on, a live server's key sends real mail to real people; the server tells the agent to confirm with you first, and your client asks before each call it has not been allowed to make. Changes on never shows the model a new key or signing secret, and never revokes a key, deletes a webhook, archives a stream, or sends or cancels a broadcast on a live server.\n\nThe switch decides which tools the server lists; it does not limit the key. An agent that runs commands or reads files on your machine can reach a key kept in its environment there too, so give an agent a live key only as you would give it the key itself. VS Code's prompt and Claude Desktop's bundle keep the key out of your environment.\n\n## What each key lists\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\nA tool a key may not use is not listed, so the agent cannot call it. `delete_test_server`, the account key's one tool that cannot be undone, refuses a live server; a live server is deleted in the dashboard. A new key or a webhook's signing secret reaches the model only on a test server: with a live key, the agent sends you to the dashboard to make one.\n\n## Tools\n\nThe table is read from the server's own list whenever this page is built, so it names every tool. **Keys** says which keys list each one: `test` a test server's key, `live` a live server's, `account` the account key, and \"with changes on\" where the switch must be on.\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 server from the same sources as these pages, at each release: the guides as `sendora://docs/…`, the API reference as `sendora://reference/…`, the error codes as `sendora://errors`, and the SDK's README and rules for agents as `sendora://sdk/readme` and `sendora://sdk/skill`. `sendora://index` lists them all; `search_docs` searches them and `read_doc` reads one whole.\n\nThe server has three prompts, which a client offers as commands:\n\n- `set_up_sendora`: install `@sendora/sdk`, 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## What to ask\n\nWith a test server's key:\n\n- \"Send a test mail to any address you like, and tell me whether it was delivered.\"\n- \"Show me what a hard bounce looks like on our test server.\"\n- \"One of our webhook deliveries failed; send it again.\"\n- \"How much test mail have we sent this month, and what is the limit?\"\n\nWith a live server's key:\n\n- \"Why did the last message to `anna@example.com` bounce?\"\n- \"Which addresses can't we send to?\"\n- \"Summarise the last mail we received.\"\n\nWith no key at all:\n\n- \"Add a password-reset mail to this app with `@sendora/sdk`.\"\n- \"What does `monthly_cap_reached` mean, and what should we do about it?\"\n\n## Received mail\n\nA live server's key lists the tools for the mail your inbound stream receives. Whoever sent a mail wrote its subject, its names, its headers and its text, and an agent reading them also has your terminal, your files and your other MCP servers. So of what the sender chose, `search_inbound` answers only two addresses, the From address and the address the mail was sent to. `read_inbound_message` gives the rest in its text answer between two markers that carry a random value in each answer, after a notice that everything between them is data written by the sender and not instructions. Its structured answer, which some clients give the model instead, as Codex does, holds the same fields under `untrusted` beside that notice, without the markers.\n\nThis lessens prompt injection without preventing it. A model can still do what a mail asks of it: 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 chose both: on Sendora's own address the `+label`, on a domain of yours the whole local part. Read what the agent proposes before you approve it, keep changes off unless the task needs them, and give an agent that reads received mail no more tools than the task needs. If you think a key reached someone it should not have, revoke it in the dashboard.\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. A server under another name is not Sendora's, and it could read every key and message it is given."},{"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| `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 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 the List-Unsubscribe pair is written by Sendora and refused from the customer. |\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 tenant, 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| `tenant_not_active` | 403 | The account is not approved to send. |\n| `tenant_paused` | 403 | The account is paused by Sendora. |\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 bearer token 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});\n```\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, their keys and domains\n\nAn account key (`ak_…`) manages the account and never sends. An\nadministrator creates one in the dashboard under Account › API keys; the\naccount holds up to two live ones. `SendoraAccount` takes the same\noptions as `Sendora`.\n\n```ts\nimport { Sendora, SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN });\n\nconst server = await account.servers.create({ name: 'Fakturering' });\nconst sendora = new Sendora({ token: server.token.token });\n```\n\nA server is created with its default transactional stream and its first\nkey, whose value is in the answer this once. A server holds up to two\nlive keys and never falls below one, so rotation is create the new key,\nswitch to it, revoke the old one.\n\n### Test servers\n\nA server created with `mode: 'test'` goes through everything a live one\ndoes, validation, the suppression list, the log and the webhooks, but\nnothing it accepts is delivered. Its keys start `sk_test_`. Addresses at\n`simulator.sendora.se` (`hardbounce@`, `softbounce@`, `deferred@`,\n`complaint@`, with any `+label`) produce those outcomes; any other\naddress is marked delivered. A server's mode is fixed when it is\ncreated, so going live means a new server and a new key.\n\n```ts\nimport { isTestKey, Sendora, SendoraAccount } from '@sendora/sdk';\n\nconst account = new SendoraAccount({ token: process.env.SENDORA_ACCOUNT_TOKEN });\nconst ci = await account.servers.create({ name: 'CI', mode: 'test' });\n\nconst token = process.env.SENDORA_API_TOKEN ?? '';\nif (process.env.NODE_ENV === 'production' && isTestKey(token)) {\n throw new Error('SENDORA_API_TOKEN is a test key; nothing would be delivered');\n}\nconst sendora = new Sendora({ token });\n```\n\nTest mail is free and counts against its own monthly cap and per-minute\nlimit, shared by the account's test servers. A test server has no\ninbound stream (`stream_kind_not_allowed`), and a rename that names a\nmode is `mode_immutable`.\n\n| Call | Answers |\n| --------------------------------------------------- | ---------------------------------------------------------------------------------- |\n| `account.servers.create({ name, mode })` | The server with its first key as `token`, once; a taken name is `server_exists` |\n| `account.servers.list()` | `{ servers }`, oldest first |\n| `account.servers.get(serverId)` | One server |\n| `account.servers.update(serverId, { name })` | The renamed server |\n| `account.servers.delete(serverId)` | Nothing; `server_in_flight` while its messages are still being delivered |\n| `account.servers.tokens.create(serverId, { name })` | The key with its `token` value, once; a third live key is `token_limit` with `max` |\n| `account.servers.tokens.list(serverId)` | `{ tokens }`, revoked ones included |\n| `account.servers.tokens.get(serverId, tokenId)` | One key, without its value |\n| `account.servers.tokens.revoke(serverId, tokenId)` | Nothing; the last live key is refused (`last_token`) |\n\n```ts\nconst { token } = await account.servers.tokens.create(serverId, { name: 'Fakturasystemet' });\n```\n\nSending domains belong to the account, and every server sends from them.\nA domain sends once both DNS records are seen. A lookup that fails at the\nresolver (`dns_error`) changes nothing, and a verified domain whose record\ngoes missing keeps sending for 24 hours while the account's administrators\nare told, with `failingSince` saying when that began.\n\n| Call | Answers |\n| ------------------------------------ | ---------------------------------------------------------------------------- |\n| `account.domains.create({ domain })` | The domain with `returnPath` (CNAME) and `dkim` (TXT) to add |\n| `account.domains.list()` | `{ domains }` |\n| `account.domains.get(domainId)` | One domain with the state of its records |\n| `account.domains.verify(domainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |\n| `account.domains.delete(domainId)` | Nothing; mail from it is refused from then on |\n\n```ts\nconst domain = await account.domains.create({ domain: 'example.se' });\nconsole.log(`${domain.returnPath.type} ${domain.returnPath.host} -> ${domain.returnPath.value}`);\nconsole.log(`${domain.dkim.type} ${domain.dkim.host} -> ${domain.dkim.value}`);\n```\n\nA 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| `tenant_paused` | 403 | Sendora has paused the account. |\n| `tenant_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. |\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 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 Answer 2xx quickly and do the work afterwards. Deliveries arrive at\n 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 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 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://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. SDK: `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 characters)): The plain-text part. At least one of text and html is required.\n- `html` (string (at least 1 characters)): The HTML part.\n- `headers` (map of string to string): Custom headers by name. Headers Sendora sets itself, such as From and DKIM-Signature, are refused.\n- `attachments` (array of object, at most 20): At most 20. The built message, attachments encoded, may not exceed 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\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\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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 402 The account has no active subscription.\n\n- `payment_required`: The account has no active subscription.\n\n### 403 Default Response\n\n- `wrong_token_kind`: 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- `tenant_paused`: The account is paused by Sendora.\n- `tenant_not_active`: The account is not approved to send.\n\n### 413 The body exceeds 10 MB.\n\n- `request_too_large`: The body exceeds 10 MB.\n\n### 422 Default Response\n\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 the List-Unsubscribe pair is written by Sendora and refused from the customer.\n- `recipient_suppressed`: One or more recipients are on the suppression list of the stream.\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.\n- `idempotency_key_mismatch`: The Idempotency-Key was already used for a different request.\n\n### 429 Default Response\n\n- `rate_limited`: More was sent within a minute than the limit allows.\n- `monthly_cap_reached`: The monthly cap of the tenant, the server or the test servers is used up.\n\n### 503 Sending is disabled for everyone for the moment; retry after the seconds in Retry-After.\n\n- `sending_disabled`: Sending is disabled for everyone for the moment; retry after the seconds in Retry-After."},{"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. SDK: `sendora.email.sendBatch(messages)`.\n\n## Parameters\n\n- `idempotency-key` (header, required, string): Any text of 1 to 255 printable characters that identifies this batch; the same key with a different item is refused for that item.\n\n## Request body\n\nA array of object, at least 1, at most 100.\n\nExample:\n\n```json\n[\n {\n \"from\": {\n \"email\": \"no-reply@example.se\",\n \"name\": \"Example AB\"\n },\n \"to\": [\n \"anna@example.com\"\n ],\n \"subject\": \"Your invoice for September\",\n \"text\": \"Hi Anna, your invoice is attached.\",\n \"attachments\": [\n {\n \"name\": \"invoice.pdf\",\n \"content\": \"JVBERi0xLjcK\",\n \"contentType\": \"application/pdf\"\n }\n ],\n \"tag\": \"invoice\",\n \"metadata\": {\n \"invoiceId\": \"2026-0912\"\n }\n }\n]\n```\n\n## Responses\n\n### 200 Every message answered on its own; the batch as a whole never fails halfway.\n\n- `results` (array of one of 18, required): One result per message, in request order.\n\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### 400 Default Response\n\n- `invalid_request`: The body or a header does not match what the route takes.\n- `idempotency_key_required`: Batch sends need an Idempotency-Key header.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 402 The account has no active subscription.\n\n- `payment_required`: The account has no active subscription.\n\n### 403 Default Response\n\n- `wrong_token_kind`: 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- `tenant_paused`: The account is paused by Sendora.\n- `tenant_not_active`: The account is not approved to send.\n\n### 413 The body exceeds 10 MB.\n\n- `request_too_large`: The body exceeds 10 MB.\n\n### 503 Sending is disabled for everyone for the moment; retry after the seconds in Retry-After.\n\n- `sending_disabled`: Sending is disabled for everyone for the moment; retry after the seconds in Retry-After."},{"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. SDK: `sendora.broadcasts.list(query)`.\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 Default Response\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 (-9007199254740991 to 9007199254740991), required): Messages stored, after suppressed addresses were dropped.\n - `released` (integer (-9007199254740991 to 9007199254740991), required): Messages handed to the mail server so far.\n - `failed` (integer (-9007199254740991 to 9007199254740991), required): Messages that could not be handed over, cancelled ones included.\n - `suppressed` (integer (-9007199254740991 to 9007199254740991), 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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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-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. SDK: `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 characters)): 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 characters)): The HTML part, with {{ unsubscribe_url }} where the link goes.\n- `headers` (map of string to string): Custom headers on every message. Headers Sendora sets itself, the List-Unsubscribe pair included, are refused.\n- `attachments` (array of object, at most 20): At most 20, sent with every message. Each message as built, attachments encoded, may not exceed 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\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; the worker 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 (-9007199254740991 to 9007199254740991), required): Messages stored; each is a message in the log.\n- `suppressed` (integer (-9007199254740991 to 9007199254740991), 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\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### 400 Default Response\n\n- `invalid_request`: The body or a header does not match what the route takes.\n- `idempotency_key_required`: Batch sends need an Idempotency-Key header.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 402 The account has no active subscription.\n\n- `payment_required`: The account has no active subscription.\n\n### 403 Default Response\n\n- `wrong_token_kind`: 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- `tenant_paused`: The account is paused by Sendora.\n- `tenant_not_active`: The account is not approved to send.\n\n### 413 The body exceeds 10 MB.\n\n- `request_too_large`: The body exceeds 10 MB.\n\n### 422 Default Response\n\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_broadcast`: A broadcast goes on a broadcast stream; the streamId names a transactional one.\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 the List-Unsubscribe pair is written by Sendora and refused from the customer.\n- `substitution_missing`: The subject, text or HTML names a {{ key }} that a message does not give.\n- `recipient_suppressed`: One or more recipients are on the suppression list of the stream.\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.\n- `idempotency_key_mismatch`: The Idempotency-Key was already used for a different request.\n\n### 429 The monthly cap of the tenant, the server or the test servers is used up.\n\n- `monthly_cap_reached`: The monthly cap of the tenant, the server or the test servers is used up.\n\n### 503 Sending is disabled for everyone for the moment; retry after the seconds in Retry-After.\n\n- `sending_disabled`: Sending is disabled for everyone for the moment; retry after the seconds in Retry-After."},{"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. SDK: `sendora.broadcasts.get(broadcastId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The broadcast id.\n\n## Responses\n\n### 200 A broadcast 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 (-9007199254740991 to 9007199254740991), required): Messages stored, after suppressed addresses were dropped.\n- `released` (integer (-9007199254740991 to 9007199254740991), required): Messages handed to the mail server so far.\n- `failed` (integer (-9007199254740991 to 9007199254740991), required): Messages that could not be handed over, cancelled ones included.\n- `suppressed` (integer (-9007199254740991 to 9007199254740991), 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such broadcast of this server.\n\n- `not_found`: 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. SDK: `sendora.broadcasts.cancel(broadcastId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The broadcast id.\n\n## Responses\n\n### 200 A broadcast and how far it has come.\n\n- `broadcastId` (string (uuid), required)\n- `streamId` (string (uuid), required): The broadcast stream the messages go on.\n- `status` (\"accepted\" | \"processing\" | \"completed\" | \"cancelled\", required): accepted until the first message leaves, processing while messages are leaving, completed when every message has left or failed, cancelled once cancelled.\n- `from` (string, required)\n- `subject` (string, required)\n- `tag` (string or null, required)\n- `metadata` (map of string to string, required)\n- `total` (integer (-9007199254740991 to 9007199254740991), required): Messages stored, after suppressed addresses were dropped.\n- `released` (integer (-9007199254740991 to 9007199254740991), required): Messages handed to the mail server so far.\n- `failed` (integer (-9007199254740991 to 9007199254740991), required): Messages that could not be handed over, cancelled ones included.\n- `suppressed` (integer (-9007199254740991 to 9007199254740991), 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such broadcast of this server.\n\n- `not_found`: No such broadcast of this server.\n\n### 409 The broadcast is completed or cancelled already; nothing is left to cancel.\n\n- `broadcast_not_open`: 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. SDK: `sendora.messages.get(messageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The message id.\n\n## Responses\n\n### 200 The message with its attachments described and its timeline.\n\n- `messageId` (string (uuid), required)\n- `streamId` (string (uuid), required): The stream the message went on.\n- `broadcastId` (string (uuid) or null, required): The broadcast the message belongs to; null for a message sent on its own.\n- `status` (\"accepted\" | \"injected\" | \"failed\", required): accepted until handed to the mail server, injected after; failed if that never worked.\n- `from` (string, required)\n- `subject` (string, required)\n- `tag` (string or null, required)\n- `metadata` (map of string to string, required)\n- `submittedAt` (string (date-time), required)\n- `test` (boolean, required): True when the message went through a test server: its events were simulated and nobody received it.\n- `recipients` (array of object, required)\n - `address` (string, required)\n - `kind` (\"to\" | \"cc\" | \"bcc\", required)\n - `status` (\"queued\" | \"delivered\" | \"deferred\" | \"bounced\" | \"expired\", required): queued until the receiver answers; deferred while it keeps saying try later.\n- `attachments` (array of object, required)\n - `name` (string, required)\n - `contentType` (string, required)\n - `contentId` (string or null, required)\n - `size` (integer (-9007199254740991 to 9007199254740991), 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 (-9007199254740991 to 9007199254740991) 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such message of this server.\n\n- `not_found`: 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. SDK: `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 Default Response\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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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-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. SDK: `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 Default Response\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 (-9007199254740991 to 9007199254740991), required)\n - `attachmentCount` (integer (-9007199254740991 to 9007199254740991), required)\n - `hasText` (boolean, required)\n - `hasHtml` (boolean, required)\n - `parseIssue` (string or null, required): What the parser met, from a closed set; null when the message parsed clean.\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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 422 The streamId names no stream of this server.\n\n- `stream_not_found`: The streamId names no stream of this server."},{"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. SDK: `sendora.inbound.get(inboundMessageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n\n## Responses\n\n### 200 The received message with everything but the attachment bytes.\n\n- `inboundMessageId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream that received it.\n- `receivedAt` (string (date-time), required): When the message was accepted from the sending server.\n- `envelopeRecipient` (string, required): The address of yours the message was sent to.\n- `mailboxHash` (string or null, required): The text after the plus sign in that address, when the sender used one.\n- `sizeBytes` (integer (-9007199254740991 to 9007199254740991), required)\n- `attachmentCount` (integer (-9007199254740991 to 9007199254740991), required)\n- `hasText` (boolean, required)\n- `hasHtml` (boolean, required)\n- `parseIssue` (string or null, required): What the parser met, from a closed set; null when the message parsed clean.\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 (-9007199254740991 to 9007199254740991), 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 (-9007199254740991 to 9007199254740991), 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 (-9007199254740991 to 9007199254740991), 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 (-9007199254740991 to 9007199254740991), 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such inbound message of this server.\n\n- `not_found`: No such inbound message of this server.\n\n### 409 The stored content of this received message cannot be opened. Sendora has been told.\n\n- `content_unreadable`: 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. SDK: `sendora.inbound.raw(inboundMessageId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n\n## Responses\n\n### 200 The raw message.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such inbound message of this server.\n\n- `not_found`: No such inbound message of this server.\n\n### 409 The stored content of this received message cannot be opened. Sendora has been told.\n\n- `content_unreadable`: The stored content of this received message cannot be opened. Sendora has been told.\n\n### 410 The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months.\n\n- `content_expired`: 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. SDK: `sendora.inbound.attachment(inboundMessageId, attachmentId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound message id.\n- `attachmentId` (path, required, string (uuid)): The attachment id, from the message.\n\n## Responses\n\n### 200 The attachment.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such inbound message of this server.\n\n- `not_found`: No such inbound message of this server.\n\n### 409 The stored content of this received message cannot be opened. Sendora has been told.\n\n- `content_unreadable`: The stored content of this received message cannot be opened. Sendora has been told.\n\n### 410 The content of this received message is gone; the stream’s content window has passed. The message itself stays 13 months.\n\n- `content_expired`: 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. SDK: `sendora.inboundDomains.list()`.\n\n## Responses\n\n### 200 Default Response\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 token 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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. SDK: `sendora.inboundDomains.create({ streamId, 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 token 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### 400 Default Response\n\n- `invalid_request`: The body or a header does not match what the route takes.\n- `domain_reserved`: The name is Sendora’s own, or a return-path host; no account can receive on it.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 409 The stream already has a domain, or another account holds the name verified.\n\n- `inbound_domain_exists`: The stream already has a domain, or another account holds the name verified.\n\n### 422 Default Response\n\n- `stream_not_found`: The streamId names no stream of this server.\n- `stream_archived`: The stream is archived and takes no new messages."},{"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. SDK: `sendora.inboundDomains.get(inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 200 A domain on an inbound stream and the two records it needs.\n\n- `inboundDomainId` (string (uuid), required)\n- `streamId` (string (uuid), required): The inbound stream the domain delivers to.\n- `domain` (string, required): The domain, lower-cased and IDNA-encoded.\n- `verified` (boolean, required): True while both records are seen; only then is mail to the domain accepted.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `unverifiedAt` (string (date-time) or null, required): When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back.\n- `mx` (object, required): The MX record that brings the domain’s mail to Sendora, at any priority.\n - `type` (\"MX\", required)\n - `host` (string, required): The name to create the record under.\n - `value` (string, required): The value the record must hold.\n - `verified` (boolean, required)\n - `verifiedAt` (string (date-time) or null, required)\n- `txt` (object, required): The TXT record that proves the claim; it carries a token 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such inbound domain of this server.\n\n- `not_found`: 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. SDK: `sendora.inboundDomains.delete(inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such inbound domain of this server.\n\n- `not_found`: 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. SDK: `sendora.inboundDomains.verify(inboundDomainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The inbound domain id.\n\n## Responses\n\n### 200 Default Response\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 token 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such inbound domain of this server.\n\n- `not_found`: 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. SDK: `sendora.streams.list()`.\n\n## Responses\n\n### 200 Default Response\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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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. SDK: `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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 403 Default Response\n\n- `wrong_token_kind`: 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- `tenant_not_active`: The account is not approved to send.\n\n### 409 Default Response\n\n- `stream_exists`: The server already has a stream with this name, archived or not.\n- `inbound_stream_exists`: A server has one inbound stream at a time; archive it to make another.\n\n### 422 A test server has no inbound stream; receive on a live server.\n\n- `stream_kind_not_allowed`: A test server has no inbound stream; receive on a live server."},{"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. SDK: `sendora.streams.get(streamId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such stream of this server.\n\n- `not_found`: 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. SDK: `sendora.streams.update(streamId, { name, contentRetentionDays })`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Request body\n\n- `name` (string (at least 1, at most 100 characters)): 1 to 100 characters.\n- `contentRetentionDays` (integer (1 to 30) or null): Days an inbound stream keeps received content, 1 to 30; null for the default of 30.\n\nExample:\n\n```json\n{\n \"name\": \"Driftinformation\"\n}\n```\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Driftinformation\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": null,\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such stream of this server.\n\n- `not_found`: No such stream of this server.\n\n### 409 The server already has a stream with this name, archived or not.\n\n- `stream_exists`: The server already has a stream with this name, archived or not."},{"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. SDK: `sendora.streams.archive(streamId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The stream id.\n\n## Responses\n\n### 200 A stream of this server.\n\n- `streamId` (string (uuid), required): What a send names as streamId.\n- `kind` (\"transactional\" | \"broadcast\" | \"inbound\", required): transactional for mail the recipient expects; broadcast for mail to many; inbound for mail the server receives.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the server.\n- `isDefault` (boolean, required): The stream a send without a streamId goes on; one per server, transactional.\n- `createdAt` (string (date-time), required)\n- `archivedAt` (string (date-time) or null, required): Set once archived; the stream then takes no new messages.\n- `pausedAt` (string (date-time) or null, required): Set while Sendora has paused the stream after complaints; it takes no messages until support has resumed it.\n- `inboundAddress` (string or null, required): The address of an inbound stream, its id on inbound.sendora.se; mail sent there is received on the stream. Null on every other kind.\n- `contentRetentionDays` (integer (1 to 30) or null, required): How many days an inbound stream keeps received content, 1 to 30; null means the default of 30. Null on every other kind.\n\nExample:\n\n```json\n{\n \"streamId\": \"7c9e6679-7425-40de-944b-e07fc1f90ae7\",\n \"kind\": \"transactional\",\n \"name\": \"Notices\",\n \"isDefault\": false,\n \"createdAt\": \"2026-09-16T12:00:00.000Z\",\n \"archivedAt\": \"2026-09-16T13:00:00.000Z\",\n \"pausedAt\": null,\n \"inboundAddress\": null,\n \"contentRetentionDays\": null\n}\n```\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such stream of this server.\n\n- `not_found`: No such stream of this server.\n\n### 409 The default stream of a server cannot be archived.\n\n- `default_stream`: 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. SDK: `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 ceiling, and the server's own where Sendora set them; on a test server the test cap its account's test servers share.\n - `scope` (\"tenant\" | \"server\" | \"test\", required): Whose limit it is: the account's, the server's own, or the test 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 (-9007199254740991 to 9007199254740991), required): Emails allowed in the period, one per recipient.\n - `used` (integer (-9007199254740991 to 9007199254740991), 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\": \"Fakturering\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"limits\": [\n {\n \"scope\": \"tenant\",\n \"period\": \"minute\",\n \"limit\": 300,\n \"used\": 4,\n \"resetsAt\": \"2026-09-24T10:01:00.000Z\"\n },\n {\n \"scope\": \"tenant\",\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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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. SDK: `account.servers.list()`.\n\n## Responses\n\n### 200 Default Response\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\": \"Fakturering\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\"\n }\n ]\n}\n```\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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. SDK: `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\": \"Fakturering\"\n}\n```\n\n## Responses\n\n### 201 Default Response\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\": \"Fakturering\",\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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 409 Default Response\n\n- `server_exists`: The account already has a server with that name.\n- `server_limit`: The account has as many servers as it may; Sendora raises the cap on request."},{"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. SDK: `account.servers.get(serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 200 A server of the account: an isolation boundary with its own keys, streams, log, suppressions and webhooks.\n\n- `serverId` (string (uuid), required): The id every server key, stream and message of the server refers to.\n- `name` (string (at least 1, at most 100 characters), required): The name given at creation, unique within the account.\n- `mode` (\"live\" | \"test\", required): live delivers; test goes through everything but delivery, so nothing reaches a recipient. Fixed when the server is created.\n- `createdAt` (string (date-time), required)\n\nExample:\n\n```json\n{\n \"serverId\": \"7c1e4d2a-0b9f-4a3e-8d6c-5e2f1a9b8c70\",\n \"name\": \"Fakturering\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\"\n}\n```\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such server of this account.\n\n- `not_found`: 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. SDK: `account.servers.update(serverId, { 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### 400 Default Response\n\n- `invalid_request`: The body or a header does not match what the route takes.\n- `mode_immutable`: A server’s mode is fixed when it is created; create a new server for the other mode.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such server of this account.\n\n- `not_found`: No such server of this account.\n\n### 409 Default Response\n\n- `server_exists`: The account already has a server with that name.\n- `server_reserved`: This server sends Sendora’s own sign-in mail and cannot be renamed or removed."},{"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. SDK: `account.servers.delete(serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such server of this account.\n\n- `not_found`: No such server of this account.\n\n### 409 Default Response\n\n- `server_reserved`: This server sends Sendora’s own sign-in mail and cannot be renamed or removed.\n- `server_in_flight`: 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. SDK: `account.servers.tokens.list(serverId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n\n## Responses\n\n### 200 Default Response\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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such server of this account.\n\n- `not_found`: 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. SDK: `account.servers.tokens.create(serverId, { 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\": \"Fakturasystemet\"\n}\n```\n\n## Responses\n\n### 201 Default Response\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\": \"Fakturasystemet\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n}\n```\n\n### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such server of this account.\n\n- `not_found`: No such server of this account.\n\n### 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- `token_limit`: The server or the account already holds two live keys: one in use and one to rotate to. Revoke one before creating another."},{"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. SDK: `account.servers.tokens.get(serverId, tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n- `tokenId` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 200 A key of this server; the value itself is shown only when it is created.\n\n- `tokenId` (string (uuid), required)\n- `name` (string, required): The name given at creation, for people to tell keys apart.\n- `prefix` (string, required): The first characters of the key, to match it with a value in hand.\n- `createdAt` (string (date-time), required)\n- `revokedAt` (string (date-time) or null, required)\n\nExample:\n\n```json\n{\n \"tokenId\": \"3b0c9e2f-6d4a-4e8b-8a1c-9f2d7e6c5b40\",\n \"name\": \"default\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"revokedAt\": null\n}\n```\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such server of this account, or no such key of the server.\n\n- `not_found`: 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. SDK: `account.servers.tokens.revoke(serverId, tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The server id.\n- `tokenId` (path, required, string (uuid)): The key id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such server of this account, or no such key of the server.\n\n- `not_found`: No such server of this account, or no such key of the server.\n\n### 409 This is the only live key of the server; create another before revoking it.\n\n- `last_token`: 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. SDK: `account.domains.list()`.\n\n## Responses\n\n### 200 Default Response\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 (D-006).\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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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 (D-226).\n\nTakes an account key (`ak_…`) as a bearer token. SDK: `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 (D-006).\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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 409 The account already has that domain.\n\n- `domain_exists`: The account already has that domain."},{"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. SDK: `account.domains.get(domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 200 A sending domain and the two records it needs.\n\n- `domainId` (string (uuid), required)\n- `domain` (string, required)\n- `verified` (boolean, required): True while both records count as found; only then may mail go out. A lookup that fails at the resolver never changes it, and a record found missing on a verified domain starts a 24-hour warning (failingSince) through which the domain stays verified.\n- `createdAt` (string (date-time), required)\n- `lastCheckedAt` (string (date-time) or null, required): When the records were last looked up.\n- `failingSince` (string (date-time) or null, required): When a check first found a record of this verified domain missing or wrong. Mail keeps going out, the records are looked up hourly, and the first check 24 hours after this that still misses one ends the verification. Null unless the domain is verified and in that warning; verified and each record tell the rest.\n- `returnPath` (object, required): The return-path record, which also satisfies SPF (D-006).\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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such domain of this account.\n\n- `not_found`: 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. SDK: `account.domains.delete(domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such domain of this account.\n\n- `not_found`: 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. SDK: `account.domains.verify(domainId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The domain id.\n\n## Responses\n\n### 200 Default Response\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 (D-006).\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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such domain of this account.\n\n- `not_found`: No such domain 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. SDK: `sendora.suppressions.list(query)`.\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 Default Response\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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 422 Default Response\n\n- `stream_not_found`: The streamId names no stream of this server.\n- `stream_not_sendable`: An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream."},{"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. SDK: `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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 403 Default Response\n\n- `wrong_token_kind`: 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`: The recipient reported a message as spam; only Sendora support lifts that, at the recipient’s own request.\n- `unsubscribe_locked`: The recipient unsubscribed themselves; only Sendora support lifts that, at the recipient’s own request.\n\n### 404 No such suppressed address of this server.\n\n- `not_found`: No such suppressed address of this server.\n\n### 422 Default Response\n\n- `stream_not_found`: The streamId names no stream of this server.\n- `stream_not_sendable`: An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream."},{"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. SDK: `sendora.webhooks.list()`.\n\n## Responses\n\n### 200 Default Response\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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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. SDK: `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 the customer 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 Default Response\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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 409 The server already has a webhook for that URL.\n\n- `webhook_exists`: The server already has a webhook for that URL.\n\n### 422 The streamId names no stream of this server.\n\n- `stream_not_found`: The streamId names no stream of this server."},{"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. SDK: `sendora.webhooks.get(webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 200 A webhook of this server.\n\n- `webhookId` (string (uuid), required)\n- `url` (string, required): Where the events are posted.\n- `events` (array of \"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"cap_warning\" | \"cap_reached\" | \"inbound\", 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such webhook of this server.\n\n- `not_found`: 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. SDK: `sendora.webhooks.delete(webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such webhook of this server.\n\n- `not_found`: 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. SDK: `sendora.webhooks.deliveries(webhookId, query)`.\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 Default Response\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 (-9007199254740991 to 9007199254740991), required)\n - `nextAttemptAt` (string (date-time) or null, required): When the next attempt is due, while pending.\n - `lastStatusCode` (integer (-9007199254740991 to 9007199254740991) 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### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such webhook of this server.\n\n- `not_found`: No such webhook of this server."},{"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. SDK: `sendora.webhooks.replay(webhookId, deliveryId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n- `deliveryId` (path, required, string (uuid)): The delivery id.\n\n## Responses\n\n### 202 One event on its way to the webhook, retried with backoff until delivered or dead.\n\n- `deliveryId` (string (uuid), required)\n- `event` (\"delivered\" | \"bounced\" | \"deferred\" | \"spam_complaint\" | \"unsubscribed\" | \"cap_warning\" | \"cap_reached\" | \"inbound\", 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 (-9007199254740991 to 9007199254740991), required)\n- `nextAttemptAt` (string (date-time) or null, required): When the next attempt is due, while pending.\n- `lastStatusCode` (integer (-9007199254740991 to 9007199254740991) 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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such webhook or delivery of this server.\n\n- `not_found`: 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. SDK: `sendora.webhooks.createSecret(webhookId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n\n## Responses\n\n### 201 Default Response\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### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such webhook of this server.\n\n- `not_found`: No such webhook of this server.\n\n### 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`: The webhook already holds two live secrets: one in use and one to roll to. Delete one before creating another."},{"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. SDK: `sendora.webhooks.deleteSecret(webhookId, secretId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The webhook id.\n- `secretId` (path, required, string (uuid)): The secret id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such webhook or secret of this server.\n\n- `not_found`: No such webhook or secret of this server.\n\n### 409 The only live secret of a webhook cannot be deleted; create another first.\n\n- `last_secret`: 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 tokens","text":"# GET /v1/tokens\n\nList the tokens\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. SDK: `sendora.tokens.list()`.\n\n## Responses\n\n### 200 Default Response\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\": \"Fakturasystemet\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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 token","text":"# POST /v1/tokens\n\nCreate a token\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. SDK: `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\": \"Fakturasystemet\"\n}\n```\n\n## Responses\n\n### 201 Default Response\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\": \"Fakturasystemet\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v\"\n}\n```\n\n### 400 The body or a header does not match what the route takes.\n\n- `invalid_request`: The body or a header does not match what the route takes.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 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- `token_limit`: The server or the account already holds two live keys: one in use and one to rotate to. Revoke one before creating another."},{"uri":"sendora://reference/get-v1-tokens-id","title":"GET /v1/tokens/{id}","description":"Read a token","text":"# GET /v1/tokens/{id}\n\nRead a token\n\nTakes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. SDK: `sendora.tokens.get(tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The token 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\": \"Fakturasystemet\",\n \"prefix\": \"sk_a1b2c3d4e5\",\n \"createdAt\": \"2026-09-15T12:00:00.000Z\",\n \"revokedAt\": null\n}\n```\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such token of this server.\n\n- `not_found`: No such token of this server."},{"uri":"sendora://reference/delete-v1-tokens-id","title":"DELETE /v1/tokens/{id}","description":"Revoke a token","text":"# DELETE /v1/tokens/{id}\n\nRevoke a token\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. SDK: `sendora.tokens.revoke(tokenId)`.\n\n## Parameters\n\n- `id` (path, required, string (uuid)): The token id.\n\n## Responses\n\n### 204 Removed. The body is empty.\n\n### 401 The bearer token is missing, malformed or revoked.\n\n- `unauthorized`: The bearer token is missing, malformed or revoked.\n\n### 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- `wrong_token_kind`: 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### 404 No such token of this server.\n\n- `not_found`: No such token of this server.\n\n### 409 This is the only live key of the server; create another before revoking it.\n\n- `last_token`: This is the only live key of the server; create another before revoking it."}]}
1
+ {"documents":[{"uri":"sendora://docs/quickstart","title":"Quickstart","description":"Get a server key, send one message, and read it in the message log.","text":"# Quickstart\n\nGet a server key, send one message, and read it in the message log.\n\nThree steps from an approved account to a delivered message. The code on this page is run against the real API by Sendora's own test suite, in every language, so it works as written. The tabs on a code block pick the language for every page.\n\n## 1. Get a server key\n\nSign in to the dashboard and create a server under [Servers](https://app.sendora.se/servers). It comes with its first key, shown once; keep it in your secret store. A server holds up to two live keys, so a new one can be created before the old one is revoked. [Keys](sendora://docs/keys) explains the two kinds of key, the server key that sends and the account key that manages, and how to rotate one.\n\nOver HTTP:\n\nEvery request carries the key as a bearer token:\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, and never ship it to a browser. The client throws at construction when the key is missing.\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\nUntil 1.0.0, a new minor version may break code written for the one before it, so pin the minor version you test with, using the `~=` operator in your requirements.\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` at construction when there is none.\n\nYour sending domain must be verified before the first send: the account's [Domains](https://app.sendora.se/domains) page shows the two DNS records to add, a CNAME for the return path and a TXT record for the DKIM key.\n\n## 2. Send a message\n\nThe `from` address must be on a verified domain of the account. `to`, `cc` and `bcc` take plain addresses or objects with a display name; at least one of `text` and `html` is required.\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 answer comes at once. `messageId` is what the message log, the events and the webhooks refer to; `accepted` means the message is stored and on its way to the mail server. Delivery, deferral, bounce or complaint arrive later as events.\n\nSending the same request again under the same idempotency key returns the first answer instead of sending twice; see [Sending](sendora://docs/sending) for keys and for batches.\n\n## 3. Read the message\n\nThe message log holds 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\n`recipients[].status` moves from `queued` to `delivered`, `deferred` or `bounced` as the receiving server answers. The `events` list is the timeline: `Reception` when the mail server took it, `Delivery` when the receiver accepted it, `Bounce` when it refused it.\n\nThe same log is on the server's **Messages** tab under [Servers](https://app.sendora.se/servers) in the dashboard, searchable by recipient, tag, status and time.\n\n## What next\n\n- Register a [webhook](sendora://docs/webhooks) to receive `delivered`, `bounced`, `deferred` and `spam_complaint` events as they happen; every delivery is signed.\n- Systems that only speak SMTP can submit on port 465 or 587 with the server key as both user name and password; see [SMTP submission](sendora://docs/smtp).\n- The [API reference](sendora://index) has a page for every route with its fields and error codes, generated from the running code, with the TypeScript and Python calls beside each route."},{"uri":"sendora://docs/sending","title":"Sending","description":"Single sends, batches, attachments, custom headers, tags, metadata and idempotency.","text":"# Sending\n\nSingle sends, batches, attachments, custom headers, tags, metadata and idempotency.\n\nA message is sent one at a time or in a batch. Both take the same message and answer the same result per message. The [reference](sendora://reference/post-v1-email) lists every field, [Limits](sendora://docs/limits#messages) every size and count a message and a request are held to, and this page explains how they fit together.\n\n## The message\n\n- `from` must be an address on a [verified sending domain](sendora://docs/domains) of your account. `to`, `cc` and `bcc` take plain addresses or objects with `email` and a display `name`, and the recipients of the three are counted together.\n- `subject` is required. At least one of `text` and `html` is required; send both when you can, and receivers pick what they show.\n- `streamId` names the [stream](sendora://docs/streams) of the server the message goes on. Without it the message goes on the server's default transactional stream.\n- The message's size is counted as built: text and HTML as written, attachments encoded.\n\nSendora sets `From`, `To`, `Date`, `Message-ID`, `Return-Path` and the DKIM signature itself and refuses those headers from you. Everything the receiver needs to reply, filter or display is yours to set through `headers`.\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`: the content travels base64-encoded without line breaks; `contentType` defaults to `application/octet-stream`; `contentId` makes an attachment addressable from the HTML as `cid:` for inline images. Attachment contents are deleted from Sendora once the mail server has taken the message; the names, types and sizes stay on the message.\n- `headers`: custom headers, such as `Reply-To` or your own `X-` headers. Headers Sendora sets itself, and the `Content-`, `Resent-` and `ARC-` families, are refused.\n- `tag`: a label returned with the message, in the log's search and in every webhook about it. One tag per message; use it for the kind of message, such as `invoice` or `welcome`.\n- `metadata`: key-value pairs of your own, keys of letters, digits, `_`, `.` and `-`. They come back with the message and in every webhook, so an order id or customer 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; a base64 string is passed through as it is.\n\nWith the Python SDK:\n\nThe SDK takes attachment content as `bytes` and encodes it; a base64 string is passed through as it is.\n\n## Idempotency\n\nNetworks fail between your request and our answer. Name the request with an idempotency key, any printable text that identifies it on your side, and a retry within the time Sendora remembers it gets the first answer again instead of sending a second message; [Limits](sendora://docs/limits#requests) gives the key's length and that time.\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\nThe key is the `Idempotency-Key` header; the replayed answer carries `Idempotent-Replayed: true`.\n\nWith the TypeScript SDK:\n\nEvery send through the SDK carries a key: yours when you give `idempotencyKey`, otherwise a random one, so the SDK's own retries after a lost connection can never send twice. Give your own when a retry may come from another process, such as a job runner.\n\nWith the Python SDK:\n\nEvery send through the SDK carries a key: yours when you give `idempotency_key`, otherwise a random one, so the SDK's own retries after a lost connection can never send twice. Give your own when a retry may come from another process, such as a job runner.\n\nThe same key with a different message is refused with `idempotency_key_mismatch`: a key names one request, and a changed request is a new one. Keys are scoped to the server the token belongs to.\n\n## Batches\n\nA batch is a list of messages under one required idempotency key. Each message is accepted or refused on its own, and the answer lists one result per message in request order: `accepted` with its id, or `error` with the same code and message the single send would have given. A batch never fails halfway because one message is invalid.\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\nA retry of the batch under the same key sends nothing twice: every message accepted before is answered again with `replayed: true`, and only the messages that failed are attempted again. Refusals that concern the whole account, such as a paused account, one without an active subscription or sending stopped by Sendora, refuse the whole batch before any message is looked at. The per-minute limit and the monthly cap are counted message by message, in order: the messages up to the limit are accepted, and those after it answer `rate_limited` or `monthly_cap_reached` in their results.\n\n## What happens next\n\nAn accepted message is stored, encrypted, and handed to the mail server within seconds. From then on its `recipients[].status` moves as receivers answer, and its timeline fills with events; see [Messages and events](sendora://docs/messages). To be told rather than to poll, register a [webhook](sendora://docs/webhooks)."},{"uri":"sendora://docs/streams","title":"Streams","description":"The streams of a server, transactional, broadcast and inbound, what they separate, and how to send on one.","text":"# Streams\n\nThe streams of a server, transactional, broadcast and inbound, what they separate, and how to send on one.\n\nEvery message goes on a stream of its server. A sending stream is a kind of mail with a suppression list of its own, so that a recipient who bounced or opted out of one kind still gets the others; an inbound stream is where the server receives mail. Every server starts with one default transactional stream, and a message that names no stream goes there.\n\n## Three kinds\n\n- `transactional`: mail one person expects because of something they did or have with you: receipts, invoices, sign-in links, notices. Every account can add transactional streams.\n- `broadcast`: the same message to many people who asked for it: a newsletter, service information, a notice to every customer. Any active account can create one; the recipients must have asked for the mail, as the acceptable use rules say, and a stream whose complaint rate climbs is paused until you and Sendora have looked at the list.\n- `inbound`: mail the server receives. A server has one live inbound stream at a time; a second answers `inbound_stream_exists` until the first is archived. The stream carries an `inboundAddress`, its id on `inbound.sendora.se`, and `contentRetentionDays`, how long received content is kept, within the range on [Limits](sendora://docs/limits#inbound-mail); the addresses, subjects and times stay as long as a sent message's record. It sends nothing: a message, a batch item or an SMTP submission naming it is refused with `stream_not_sendable`, and it has no suppression list. What arrives, how to read it and the webhook event are in the [inbound guide](sendora://docs/inbound).\n\nAdd a sending stream when the mail differs in kind or in who should be able to stop it, not per customer or per message. A municipality might keep `Notices` for case notices and `Receipts` for payment receipts; each has its own list of addresses that bounced.\n\nA message on a broadcast stream carries an unsubscribe link of Sendora's: write `{{ unsubscribe_url }}` in the text and in the HTML where the link goes, and every recipient gets a copy of their own, addressed to them alone, with their link there and in the `List-Unsubscribe` headers that mail clients turn into a button. A message without the placeholder in every part it has is refused with `unsubscribe_placeholder_missing`, and a `List-Unsubscribe` header of your own with `list_unsubscribe_reserved`. A recipient who unsubscribes lands on that stream's [suppression list](sendora://docs/suppressions) and your [webhook](sendora://docs/webhooks) hears `unsubscribed`.\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 unique within the server, archived streams included; a taken name answers `stream_exists` with the id of the stream that has it. The `streamId` is what a send names, and it never changes. A sending stream's [suppression list](sendora://docs/suppressions) starts empty, and a [webhook](sendora://docs/webhooks) can be limited to the stream.\n\n## Send on a stream\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\nWithout `streamId` the message goes on the default stream. A stream of another server, or an archived one, is refused with `stream_not_found` or `stream_archived`, and an inbound stream with `stream_not_sendable`. Over [SMTP](sendora://docs/smtp) the header `X-Sendora-Stream` carries the id. A broadcast stream that Sendora has paused after complaints, over the rate on [Limits](sendora://docs/limits#broadcasts), answers `stream_paused` and shows `pausedAt` until support has gone through the list with you and resumed it.\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. In the dashboard, each server under [Servers](https://app.sendora.se/servers) opens on its Streams tab: the streams with their last 30 days, and a page per stream with tabs of its own: its figures and, when Sendora has paused it, why; its messages; its broadcasts on a broadcast stream; its suppression list; and its settings, where it is renamed and archived. Renaming keeps the id, and the same call sets an inbound stream's `contentRetentionDays`; see [update a stream](sendora://reference/patch-v1-streams-id). Archiving, with [archive a stream](sendora://reference/post-v1-streams-id-archive), stops new messages on the stream while what it already holds is still delivered, and its log stays readable. The default stream cannot be archived.\n\nWith the TypeScript SDK:\n\n`sendora.streams.update(streamId, { name })` renames, `sendora.streams.update(streamId, { contentRetentionDays })` sets an inbound stream's content window, and `sendora.streams.archive(streamId)` archives; all answer the stream.\n\nWith the Python SDK:\n\n`sendora.streams.update(stream_id, name=)` renames, `sendora.streams.update(stream_id, content_retention_days=)` sets an inbound stream's content window, and `sendora.streams.archive(stream_id)` archives; all return the stream."},{"uri":"sendora://docs/broadcasts","title":"Broadcasts","description":"One message to many on a broadcast stream, sent as a whole, followed as a whole, cancelled as a whole.","text":"# Broadcasts\n\nOne message to many on a broadcast stream, sent as a whole, followed as a whole, cancelled as a whole.\n\nA broadcast is the same message to many people who asked for it: a newsletter, service information, a notice to every customer. You send it once, with the content and a list of messages, on a [broadcast stream](sendora://docs/streams). Sendora stores it in one go, sends every message with the recipient's own unsubscribe link, and tells you how far it has come. Any active account can create a broadcast stream and send on it; what a broadcast may be sent to is in the acceptable use rules, and a stream whose complaint rate climbs is paused. Every broadcast is listed on its stream's page there with its status and progress, and its own page shows the counts, what the messages met and a cancel while it runs.\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\nThe content is given once: `from`, `subject`, `text` and `html`, headers, attachments, `tag` and `metadata`. Each entry of `messages` is one message with its own `to`, `cc` and `bcc`, and may carry `metadata` and `headers` of its own, laid over the broadcast's. A broadcast is held to a number of messages and a size, and each message in it to a single send's size and recipients, all on [Limits](sendora://docs/limits#broadcasts); a larger list is several broadcasts under one tag. The `Idempotency-Key` is required, so a retry answers the same broadcast and never sends twice.\n\nWrite `{{ unsubscribe_url }}` in the text and in the HTML where the link goes; every recipient gets a copy addressed to them alone, with their link there and in the `List-Unsubscribe` headers. Without the placeholder the broadcast is refused with `unsubscribe_placeholder_missing`, and a `List-Unsubscribe` header of your own with `list_unsubscribe_reserved`.\n\nAddresses on the stream's [suppression list](sendora://docs/suppressions) are dropped, counted in `suppressed`, and never refuse the rest; a message with no address left is not stored, and a list with nothing left is refused whole with `recipient_suppressed`. The whole list counts against the monthly cap at once: a broadcast that would pass it is refused with `monthly_cap_reached` before anything is stored. The per-minute limits do not apply to a broadcast. A transactional stream answers `stream_not_broadcast` and an archived one `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 `substitutions`: strings under keys of letters, digits and underscore, as many and as long as [Limits](sendora://docs/limits#broadcasts) allows. Sendora fills them in per message, escaped in the HTML part, before the unsubscribe link; the subject in the log is the rendered one. A key the content names that a message does not give is refused whole with `substitution_missing`, naming the message's position and the keys. Nothing else is rendered: no conditions, no loops, no filters.\n\n## Follow it\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` is the number of messages stored; `released` and `failed` move as Sendora hands them to the mail server, and `status` goes from `accepted` through `processing` to `completed` once nothing is left, with `completedAt` set. Every message of the broadcast is a message in the [log](sendora://docs/messages) with its own timeline, carrying the `broadcastId`; the search filters by it, and every [webhook](sendora://docs/webhooks) event of such a message names it too, so delivery, bounces, complaints and unsubscribes are followed per message as for any other. The list, newest first, is [list the broadcasts](sendora://reference/get-v1-broadcasts).\n\n## Cancel\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 holds every message that has not been handed to the mail server yet; those count as `failed`, and the broadcast is `cancelled` with `completedAt` set. A message already on its way is delivered. A completed or cancelled broadcast answers `broadcast_not_open`.\n\nWith the TypeScript SDK:\n\n`sendora.broadcasts.send(broadcast)` answers the accepted broadcast, `sendora.broadcasts.get(broadcastId)` its progress, `sendora.broadcasts.list()` the newest first and `sendora.broadcasts.cancel(broadcastId)` the cancelled broadcast; a refused broadcast throws `SendoraError` with the code above.\n\nWith the Python SDK:\n\n`sendora.broadcasts.send(**broadcast)` returns the accepted broadcast, `sendora.broadcasts.get(broadcast_id)` its progress, `sendora.broadcasts.list()` the newest first and `sendora.broadcasts.cancel(broadcast_id)` the cancelled broadcast. A refused broadcast raises `SendoraError` with the code above."},{"uri":"sendora://docs/domains","title":"Domains","description":"Verify a sending domain with two DNS records, and what happens to SPF and DMARC.","text":"# Domains\n\nVerify a sending domain with two DNS records, and what happens to SPF and DMARC.\n\nYour account sends only from domains it has verified, and every server of the account sends from them. Verification is two DNS records, added once, and Sendora checks them for you. You never touch your own SPF record.\n\nDomains belong to the account, so they are managed with an account key (`ak_…`), the one an administrator creates in the dashboard under [Account › API keys](https://app.sendora.se/account/tokens); a server key only sends from them. See [Keys](sendora://docs/keys).\n\n## Add the domain\n\nThe answer carries the two records to add. The DKIM key pair is generated for this domain when it is added; 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. Because the record is a CNAME, the SPF record that authorises Sendora's sending addresses is ours to keep current, and your own SPF record stays as it is.\n- `dkim`: a TXT record under `<selector>._domainkey.<your domain>` holding the public key. Every message is signed with it, 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. Adding a domain the account already has answers `domain_exists` with 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: `ok`, `missing`, `mismatch` when a record exists with another value, or `dns_error` when the resolver could not get a trustworthy answer, which includes a failed DNSSEC validation. The 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, which the next check finds 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## Every domain of the account\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\nRemoving a domain 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":"Two kinds of API key, what each may do, how many you can hold, and how to rotate one.","text":"# Keys\n\nTwo kinds of API key, what each may do, how many you can hold, and how to rotate one.\n\nEvery request carries an API key as a bearer token. There are two kinds, and each does what the other cannot.\n\n## Two kinds of key\n\n- A **server key** starts with `sk_` and belongs to one server. It sends, reads the message log, and manages the server's streams, suppressions, webhooks and its own keys. It is what your application holds, and what an SMTP client signs in with. A [test server](sendora://docs/test-servers)'s keys start with `sk_test_`.\n- An **account key** starts with `ak_` and belongs to the account. It creates, renames and deletes servers, manages every server's keys, and manages the sending domains the servers send from. It never sends and never reads mail. An administrator creates one in the dashboard under [Account › API keys](https://app.sendora.se/account/tokens); the account holds up to two.\n\nA key of the wrong kind answers `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, `SendoraAccount` an account key. Keep both in the environment on the server, as `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN` below, and never ship either to a browser.\n\nWith the Python SDK:\n\nThe SDK has a client for each: `Sendora` takes a server key, `SendoraAccount` an account key. Keep both in the environment on the server, as `SENDORA_API_TOKEN` and `SENDORA_ACCOUNT_TOKEN` below. Each client reads its own variable when you give it no key.\n\n## A server comes with its first key\n\nCreating a server, in the dashboard or with the account key, creates its default transactional stream and its first key, whose value is in the answer 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\": \"Fakturering\"\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: 'Fakturering',\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=\"Fakturering\")\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\": \"Fakturering\",\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. A server is never without a key, so this is the one place a server and a key are made together.\n\n## Up to two live keys\n\nA server holds at most two live keys, and so does the account: the one in use and the one being rotated in. A third answers `409 token_limit`. Every key of a server has the same rights, so the name is for people to tell them apart.\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\": \"Fakturasystemet\"\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: 'Fakturasystemet' },\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=\"Fakturasystemet\",\n)\n\nprint(key.token)\n```\n\nResponse:\n\n```json\n{\n \"tokenId\": \"9f2d7e6c-5b40-4e8b-8a1c-3b0c9e2f6d4a\",\n \"name\": \"Fakturasystemet\",\n \"prefix\": \"sk_c3d4e5f6g7\",\n \"createdAt\": \"2026-09-20T12:05:00.000Z\",\n \"revokedAt\": null,\n \"token\": \"sk_c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x\"\n}\n```\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\": \"Fakturasystemet\",\n \"prefix\": \"sk_c3d4e5f6g7\",\n \"createdAt\": \"2026-09-20T12:05:00.000Z\",\n \"revokedAt\": null\n }\n ]\n}\n```\n\nA server key manages its own server's keys the same way under `/v1/tokens`; the account key does it for any server of the account under `/v1/servers/{serverId}/tokens`.\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\n## If a key leaks\n\nRevoke it. If it is a server's only key, create another first and move your systems to it, then revoke the leaked one; 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 a key you cannot account for is a key to revoke."},{"uri":"sendora://docs/test-servers","title":"Test servers","description":"A server that does everything but deliver, for staging and CI; the simulator's addresses, the test limits, and going live.","text":"# Test servers\n\nA server that does everything but deliver, for staging and CI; the simulator's addresses, the test limits, and going live.\n\nA test server takes mail exactly as a live server does and delivers none of it. The key, the From domain, the suppression list and the limits are checked, the message is stored in the log and the webhooks fire, but the message never reaches a mail server, so nothing can reach a recipient. Use one for your staging environment and your CI, and keep your live server for mail that must arrive.\n\n## Create one\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, give `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 says which kind of server it belongs to wherever it is written down. The mode is fixed when the server is created: a rename that names a mode answers `400 mode_immutable`. The server shows a **Test** label in the dashboard, and every page under it says that nothing is sent from it.\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 carry `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\nSMTP submission works the same way: sign in with the test server's key as the password, and the reply says the message is a test.\n\n## The simulator's addresses\n\nEvery address a test server sends to is marked delivered, unless it is at `simulator.sendora.se`, where the part before the `@` chooses 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 the stream |\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, and a `+label` after the name is kept: `hardbounce+signup-test@simulator.sendora.se` bounces like `hardbounce@`. A suppression is written for the whole address, label included, as on a live server, so give each run its own label and a bounce in one run never stops the next. A live server refuses these addresses before anything is stored:\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 never counts toward your plan or the account's monthly cap. All the account's test servers share their own limits, on [Limits](sendora://docs/limits#test-servers), and a refusal names 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. It has no inbound stream: creating one answers `422 stream_kind_not_allowed`, so receive on a live server.\n\n## Retention\n\nA test server keeps less than a live one: the text and HTML of a message for a short time, and the rest of the message, its events and its webhook deliveries somewhat longer, as [Limits](sendora://docs/limits#retention) gives them. Attachments' content goes as soon as the simulator has the message, as on a live server once the mail server has it.\n\n## Going live\n\nCreate a live server and its key, and move your production configuration to it; a test server never becomes live.\n\nOver HTTP:\n\nA test key starts with `sk_test_`, and any other server key with `sk_`. Check the prefix when your application starts, and refuse a test key where mail must arrive.\n\nWith the TypeScript SDK:\n\n`isTestKey` tells the kinds of key apart, so your application can refuse the wrong one when it starts:\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 kinds of key apart, so your application can refuse the wrong one when it starts:\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\nThe API also tells a key which server it belongs to: its `mode`, every limit a send on it is held to with how much of it this minute and this month have used, and the account's sending domains a From address may use.\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, the timeline of a message, searching the log, and how long things are kept.","text":"# Messages and events\n\nStatuses, the timeline of a message, searching the log, and how long things are kept.\n\nEvery message you send is in the log with its recipients, its status and a timeline of everything the mail server and the receivers said about it. The server's **Messages** tab under [Servers](https://app.sendora.se/servers) in the dashboard shows the same log; the API gives it to you.\n\n## Statuses\n\nA message has a status of its own and one per recipient.\n\n- Message: `accepted` once stored, `injected` once the mail server has taken it, `failed` if that never happened.\n- Recipient: `queued` until the receiver answers, `delivered` when it accepted the mail, `deferred` while it keeps asking us to try later, `bounced` when it refused, `expired` when it kept deferring until Sendora gave up, after three days.\n\n## The timeline\n\nEach recipient's timeline is a list of events with the receiver's SMTP code and text where there is one:\n\n- `Reception`: the mail server took the message from Sendora.\n- `Delivery`: the receiver accepted it.\n- `TransientFailure`: the receiver said try later; Sendora retries.\n- `Bounce`: the receiver refused it for good, with the reason in `details` and, where Sendora can tell, a `classification` such as `InvalidRecipient`.\n- `OOB`: a bounce report that arrived later by mail on the return-path address.\n- `Feedback`: a spam complaint reported by the receiver through a feedback loop.\n- `Expiration`: the retries ran out.\n\nThe other event types are operational and rare. Read a message with its timeline by 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\nA recipient address travels in the request body and never in a URL. Every filter is optional; the page is newest first, in the sizes on [Limits](sendora://docs/limits#pages), with `next` to pass back as `after` for the following 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\n`recipient` matches messages to that address in any of to, cc and bcc; `streamId` matches messages on one [stream](sendora://docs/streams); `status` matches messages with at least one recipient in that state; `from` and `to` bound the submission time. Every message carries the `streamId` it went on.\n\nWith the TypeScript SDK:\n\n`messages.searchAll` takes the same filters and walks every page for you, for `for await`; `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; `from_` and `to` also take an aware `datetime`.\n\n## How long things are kept\n\n- The text and HTML of a message are deleted first, and sooner on a [test server](sendora://docs/test-servers).\n- The content of its attachments is deleted as soon as the mail server has the message, or the simulator on a test server, or once the message has failed; their names, types and sizes stay with the message.\n- The message itself, its recipients and its events are kept longer, so a dispute or an audit a year later still has the record, and on a test server for less.\n\n[Limits](sendora://docs/limits#retention) gives each period.\n\n- When a recipient asks you to erase their data, [erase them](sendora://docs/suppressions#erasure-requests) with an account key: the content of every message to them is deleted and their address is replaced by a pseudonym in the log.\n\nSendora never returns a message's content; what you sent is yours to keep."},{"uri":"sendora://docs/inbound","title":"Inbound","description":"Receiving mail on an inbound stream or on a domain of your own, the webhook event, and reading a received message, its attachments and its raw form.","text":"# Inbound\n\nReceiving mail on an inbound stream or on a domain of your own, the webhook event, and reading a received message, its attachments and its raw form.\n\nA server receives mail on its [inbound stream](sendora://docs/streams). The stream has an address of its own, `<stream id>@inbound.sendora.se`, and can receive on a domain of yours; anything sent there is accepted, parsed, checked for SPF, DKIM, DMARC and ARC, stored under your account's key and handed to your [webhook](sendora://docs/webhooks) as the event `inbound`. Mail the address does not know is refused at the door, so nothing is queued or bounced later.\n\n## The address\n\nCreate the stream with `kind: \"inbound\"` and read `inboundAddress` from the answer. The local part is the stream id, and anything after a plus sign in it is yours: `<stream id>+ticket-42@inbound.sendora.se` is delivered to the same stream with `mailboxHash: \"ticket-42\"` on the message, so one address can route by ticket, by customer or by form.\n\nA message up to the size on [Limits](sendora://docs/limits#inbound-mail) is accepted. Its content, the text, the HTML, the headers, the attachments and the raw message, is kept for the stream's `contentRetentionDays`; what remains after that is the reference: who it was to, when it arrived, its sizes and its verdicts, kept as long as a sent message's record.\n\n## Your own domain\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\nA stream can receive on a domain of yours instead: every address on it, whatever the local part, lands on the stream, and `support+ticket-42@post.example.se` carries `mailboxHash: \"ticket-42\"` as the hosted address does. Claim the domain with the stream id and add the two records the answer gives to the domain's DNS: an `MX` at the domain itself that names `inbound.sendora.se`, at any priority, and a `TXT` record at `_sendora-inbound.<domain>` with the token the answer shows, which proves the domain is yours to claim. Sendora looks the records up on its own within minutes, and on request:\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` for a resolver failure. Mail is accepted once both records are seen, within a minute of the check. A stream holds one domain, a domain that is Sendora's own answers `domain_reserved`, and the first account to verify a name holds it: a claim to a name another account holds verified answers `inbound_domain_exists`. A verified domain whose records disappear keeps receiving through two checks and loses its verification on the third; from then mail to it is deferred for a grace period, so a DNS mistake can be fixed without a bounce, and refused after that until the records are back; [Limits](sendora://docs/limits#inbound-mail) gives the period. Removing the domain refuses mail to it at once and frees the name.\n\n## The event\n\nA webhook with `inbound` among its events gets one delivery per received message. With `inboundContent: 'full'`, the default, the event carries the message: the sender, the parties, the subject, the headers, the text and the HTML, and the attachments described; with `reference` it carries the reference alone and you read the rest through the API. The [webhooks guide](sendora://docs/webhooks) lists the fields.\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 (matched by a keyed hash, so the search stores no address), the recipient address of yours, the `mailboxHash`, and the received window. The page is newest first; pass `next` as `after` for the following page. Each item carries `from`, `subject` and `date` while the content is there, 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\nThe message answers its envelope, `from`, `replyTo`, `to` and `cc` (each as long as [Limits](sendora://docs/limits#inbound-mail) allows, with the whole number in `toCount` and `ccCount`), `subject`, `date`, `messageIdHeader`, `inReplyTo`, `references`, every header, `text`, `html` and the attachments described with an `attachmentId` each. `authentication` holds the verdicts: `spf` for the envelope sender and `spfHelo` for the sending server's name, `dkim`, `dmarc` with the domain's `dmarcPolicy` when DMARC failed, and `arc`; `checkedAt` says when they were computed. `parseIssue` names what the parser met, from a closed set, or is null; a message the parser could not read still answers its envelope, times, sizes and verdicts. Once the content window has passed, `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` answers the message byte for byte as it was received, as `message/rfc822`; `GET /v1/inbound/{id}/attachments/{attachmentId}` answers an attachment's bytes as `application/octet-stream`, whatever the sender declared, with the sanitised name in `Content-Disposition`. Both come with headers that keep a browser from rendering or running them. After the content window both answer `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 arende.eml\n```\n\nWith the TypeScript SDK:\n\n`sendora.inbound.raw(inboundMessageId)` and `sendora.inbound.attachment(inboundMessageId, attachmentId)` answer 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 stays and what goes\n\nThe sender's data lives only in the sealed content and goes with the stream's window; an address a person asks you to [erase](sendora://docs/suppressions#erasure-requests) is erased from received mail too, by the same erasure as for sent mail. The dashboard shows the stream's received mail under the server's **Streams** tab in [Servers](https://app.sendora.se/servers), and every download from there is audited."},{"uri":"sendora://docs/webhooks","title":"Webhooks","description":"Events pushed to your endpoint, how to verify the signature, and how retries, dead letters and replays work.","text":"# Webhooks\n\nEvents pushed to your endpoint, how to verify the signature, and how retries, dead letters and replays work.\n\nA webhook is an HTTPS URL of yours that Sendora posts events to as they happen. Register as many as you need per server; choose the events you want or take them all, and limit one to a single [stream](sendora://docs/streams) with `streamId` when another system should hear about that stream alone.\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 answer carries `secret` once, the webhook's first signing secret; store it beside the token. The URL must use HTTPS and point at a public host; a URL that resolves to a private address is refused at delivery time. A URL the server already has answers `webhook_exists` with the id of the existing webhook, whatever its stream. With `streamId`, message events of that stream alone are delivered; cap events concern the server and reach every webhook that subscribes to them.\n\n## Events\n\nMessage events carry the message id, the `streamId` it went on, the recipient, the tag and metadata you sent, `test`, which is true only for a test server's message whose events were simulated and never sent, and details from the receiver:\n\n- `delivered`: the receiver accepted the message for that recipient. Details: the SMTP `code` and `response`.\n- `bounced`: the receiver refused it for good, or Sendora gave up. Details: `code`, `response`, the `classification` such as `InvalidRecipient`, `hard` when the recipient was added to the stream's [suppression list](sendora://docs/suppressions), and `expired` when the retries ran out.\n- `deferred`: the receiver asked us to try later; Sendora keeps retrying and you may see several of these. Details: `code` and `response`.\n- `spam_complaint`: the recipient reported the message as spam through the receiver's feedback loop. The recipient is suppressed and only Sendora support can lift that. Details: the `feedbackType`.\n- `unsubscribed`: the recipient used the unsubscribe link of a message on a [broadcast stream](sendora://docs/streams), or their mail client's one-click button. The recipient is on that stream's suppression list and only Sendora support can lift that; take them off your own list. The event is sent a minute after the click, since the page lets the recipient undo it for that long. Details: the `source`, `link` or `one_click`.\n\nTwo events concern usage rather than a message: `cap_warning` when 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-ceiling), and `cap_reached` when a send was refused because it was used up. They carry the scope, the cap, the usage and the period.\n\nA message received on an [inbound stream](sendora://docs/streams) is the event `inbound`. It always carries the reference: `inboundMessageId`, `streamId`, `receivedAt`, `envelopeRecipient` (the address of yours it was sent to) and `mailboxHash` (the text after `+` in it), `sizeBytes`, `attachmentCount`, `hasText`, `hasHtml`, the `authentication` verdicts (`spf`, `spfHelo`, `dkim`, `dmarc`, `dmarcPolicy`, `arc` and `checkedAt`) and `parseIssue`. A webhook with `inboundContent: 'full'`, the default, also gets the message: `envelope.sender`, `from`, `replyTo`, `to` and `cc` (each as long as [Limits](sendora://docs/limits#inbound-mail) allows, with `toCount` and `ccCount`), `subject`, `date`, `messageIdHeader`, `inReplyTo`, `references`, `headers`, `text`, `html` and the `attachments` descriptors. `contentOmitted` is `too_large` when `text` or `html` was left out for size and `expired` when the stream's content window has passed and only the reference remains; with `inboundContent: 'reference'` none of these fields is sent. A webhook subscribes to the events it was created with, so one created before this event existed does not receive it.\n\nEvery request body is JSON with `id`, the delivery id, and `attempt`, the attempt number, followed by the event's fields. Answer any 2xx status quickly and do your work afterwards; anything else, or no answer within the timeout, is a failed attempt.\n\n## Verify the signature\n\nEvery request carries `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`: one `v1` per live secret of the webhook, the newest first, each an HMAC-SHA256 with that secret over `<t>.<raw body>`. Your secret matches one of them, and one match is enough. Verify against the raw body before you parse anything, and refuse timestamps older than a few minutes so a captured request cannot be replayed:\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 older than a few minutes, 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\nOver HTTP:\n\nCompare with a constant-time comparison in your language, never with plain string equality, and accept the request when any `v1` matches. The recipe is the whole contract: any language with an HMAC-SHA256 can verify it.\n\nWith the TypeScript SDK:\n\n`verifyWebhook` compares through WebCrypto, constant time by construction, refuses a signature older than five minutes (`stale_signature`) or a header none of whose signatures matches (`invalid_signature`), and answers the event typed by its `event`, so `details` narrows with it. With a client at hand, `sendora.webhooks.receive(request, secret)` does the same from a Fetch `Request` in one call. Sendora's own tests run the verifier against signatures the dispatcher produces.\n\nWith the Python SDK:\n\n`verify_webhook` compares in constant time and returns the event as a class of its own, such as `BouncedEvent`, so its `details` are typed. It raises `WebhookVerificationError` with `stale_signature` for a signature older than five minutes, and with `invalid_signature` for a header none of whose signatures matches. Sendora's own tests run the verifier against signatures the dispatcher produces.\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 holds up to two live secrets, so a new one can be rolled in without a gap: create it, and every delivery is signed with both until you delete the old one.\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\nMove your receiver to the new secret; deliveries verify with either meanwhile. Then delete the one your receiver no longer holds, by its id from the webhook's `secrets`:\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 secret answers `secret_limit`, and the last live secret cannot be deleted (`last_secret`), so every delivery always carries a signature. The webhook's page in the dashboard, under the server's [Webhooks](https://app.sendora.se/servers) tab, does the same.\n\n## Retries, dead letters and replay\n\nA failed attempt is retried with growing delays, about a day in all, on the schedule in [Limits](sendora://docs/limits#webhooks). After that the delivery is `dead` and stays listed until you replay it. Deliveries are attempted in order per webhook but arrive at least once, not exactly once, and not always in order, so key your handling on `id`.\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 with the same payload, whatever its state; the attempt count 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 server no longer sends to, why they got there, and how to lift one.","text":"# Suppressions\n\nAddresses a server no longer sends to, why they got 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. The routes on this page take `streamId` and mean the default transactional stream without it. A send to any of them is refused with `recipient_suppressed`, naming the addresses and the stream, before anything leaves Sendora. The list protects your reputation and ours: mail to an address that bounced or complained is what gets senders blocked.\n\n## How an address gets there\n\n- `hard_bounce`: a receiver refused the address for good, for instance because the mailbox does not exist. After one hard bounce, a send to that recipient on the stream fails with `recipient_suppressed`. The `bounced` event tells you which message.\n- `spam_complaint`: the recipient reported a message as spam and the receiver told us through its feedback loop.\n- `manual`: you added the address yourself, on the stream's **Suppressions** tab in the dashboard, under its server's Streams tab ([Servers](https://app.sendora.se/servers)), which also shows the list and lifts an entry.\n- `unsubscribe`: the recipient used the unsubscribe link of a message on a [broadcast stream](sendora://docs/streams), or their mail client's one-click button. The `unsubscribed` [webhook](sendora://docs/webhooks) tells you.\n\nA 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#pages), with `next` to pass back as `after`. Every entry carries the `streamId` it belongs to; `streamId` on the request reads another stream's list, and a stream that is not the server's answers `stream_not_found`.\n\n## Lift a suppression\n\nA hard bounce or a manual entry may be lifted by you, for instance when the recipient has confirmed a corrected mailbox. The address travels in the request body and never in a URL; `streamId` in the body names the stream, the default one without it.\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\nA spam complaint or a recipient's own unsubscribe cannot be lifted this way: the request fails with `spam_complaint_locked` or `unsubscribe_locked`. To have one lifted, write to support with the recipient's own request and the entry's `streamId` and `messageId` from the list. Leave the address out.\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 server key, rules and log as the API.","text":"# SMTP submission\n\nSend from a system that only speaks SMTP, with the same server key, rules and log as the API.\n\nSystems that cannot call an HTTP API, from an ERP to a printer, can hand Sendora mail over SMTP. The message goes through the same checks as an API send, lands in the same log with the same events, and counts against the same limits.\n\n## Connect\n\n- Host `smtp.sendora.se`, port 465 with TLS from the start or port 587 with STARTTLS. TLS is required; a client that tries to authenticate before it is refused.\n- Authenticate with a server key (`sk_…`) as both user name and password, with `PLAIN` or `LOGIN`.\n- The `From` address must be on a verified domain of the server; `To`, `Cc` and `Bcc` are counted together, and the message is held to the recipients and size on [Limits](sendora://docs/limits#messages), as over the API.\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\n## Send from Django\n\nDjango sends over SMTP out of the box, and checks the server's certificate when it starts TLS. Set these in `settings.py`, with the server key from the environment as user name and password. `DEFAULT_FROM_EMAIL` must be on a verified domain. So must `SERVER_EMAIL`, which 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 deprecates the `EMAIL_*` settings in favour of `MAILERS`, and refuses to start with both. On 6.1, replace them with the same values as `host`, `port`, `use_tls`, `username`, `password` and `timeout` in `MAILERS[\"default\"][\"OPTIONS\"]`, where the SMTP backend is the default. `DEFAULT_FROM_EMAIL` and `SERVER_EMAIL` stay as they are.\n\n## Tags, metadata and headers\n\nSet `X-Sendora-Stream` for the [stream](sendora://docs/streams) the message goes on, `X-Sendora-Tag` for the message's tag and `X-Sendora-Metadata-<key>` for each metadata entry; `X-Sendora-Metadata-Customer: 4711` becomes `{ \"customer\": \"4711\" }`. Attachments are ordinary MIME parts. Of your other headers, `Reply-To`, `List-Unsubscribe`, `List-Unsubscribe-Post` and your own `X-` headers are kept; the rest, such as `Message-ID`, `Received` or a `DKIM-Signature`, are dropped, and Sendora writes its own, where the API would refuse them.\n\n## What you hear back\n\nAn accepted message is answered `250 2.0.0 OK: queued as <messageId>`; the id is the same one the log and the webhooks use. A refusal is answered at once, with a 5xx code when trying again will not help and a 4xx code when it may:\n\n- `550 5.7.1` for a From domain that is not verified, a paused or unapproved account, or an account without a subscription.\n- `550 5.1.1` naming suppressed recipients.\n- `550 5.6.0` for a message the API would refuse as invalid, with the first reasons in the text.\n- `552 5.3.4` for a message over the size limit, `452 4.5.3` for more recipients than a message may have.\n- `451 4.7.0` when the per-minute limit or the monthly cap is reached, with when to try again in the text.\n- `451 4.3.2` while sending is disabled for everyone, and `451 4.3.0` for a temporary failure on our side.\n\nThere is no accept-then-report: what SMTP accepts is what the API would have accepted, and the message is already in the log when the `250` arrives."},{"uri":"sendora://docs/errors","title":"Errors","description":"The error body, every code the API answers, what the per-minute limit and the monthly ceiling answer, and what pauses an account.","text":"# Errors\n\nThe error body, every code the API answers, what the per-minute limit and the monthly ceiling answer, and what pauses an account.\n\nEvery error answers a JSON body with an `error` code for your program and a `message` for a person. A validation error adds `issues`, one per field, with a dotted `path` such as `to.0.email`. A refusal that carries more, such as the suppressed recipients or when a limit resets, adds those fields; the [reference](sendora://index) shows each response.\n\nWith the TypeScript SDK:\n\nThe SDK throws `SendoraError` for every failed call, with the same `code`, the HTTP `status`, and the body's extra fields on the error: `issues`, `retryAfter`, `scope`, `limit`, `suppressed`, `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. Rate limits, a paused sending and server errors are retried on their own within a few seconds; a longer `retryAfter` is thrown at once for you to schedule.\n\nWith the Python SDK:\n\nThe SDK raises a `SendoraError` for every failed call, of a class the status chooses, such as `RateLimitError` for 429. It has the same `code`, the HTTP `status`, and the body's extra fields: `issues`, `retry_after`, `scope`, `limit`, `suppressed`, `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. Rate limits, a paused sending and server errors are retried on their own within a few seconds; a longer `retry_after` is raised at once for you to schedule.\n\n## The codes\n\nThe table is generated from the API's own routes, so it is complete.\n\n| Code | Status | Meaning |\n| --- | --- | --- |\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| `tenant_not_active` | 403 | The account is not approved to send. |\n| `tenant_paused` | 403 | The account is paused by Sendora. |\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. |\n\n## Limits\n\nTwo limits apply to every account, counted per recipient, and a server can be given tighter ones of its own; [Limits](sendora://docs/limits#sending-rates-and-the-monthly-ceiling) gives their numbers and every other limit.\n\n- Over the per-minute limit, a send answers `rate_limited` with the seconds until the window turns; the message is not stored, so send it again then.\n- The monthly cap is the account's ceiling, which an administrator moves under [Account › Usage](https://app.sendora.se/account/usage) in the dashboard. When it is used up, a send answers `monthly_cap_reached` with the cap, the usage and when it resets. Before that a `cap_warning` webhook goes out once per month, and a `cap_reached` webhook once when a send was refused.\n\nThe per-minute limit and a server's own limits are settings Sendora holds; write to support to change them. The [pricing](https://sendora.se/pricing) page states what a month costs.\n\n[Test servers](sendora://docs/test-servers) have limits of their own, shared by all the account's test servers. Their refusals carry the scope `test`, and test mail never counts toward the account's ceiling.\n\n## When an account cannot send\n\n- `payment_required` (402): the account has no active subscription. Activate payment under [Billing](https://app.sendora.se/billing) in the dashboard; sends are refused until then.\n- `tenant_not_active` (403): the account is not approved yet. Every account is reviewed by hand before it may send.\n- `tenant_paused` (403): Sendora has paused the account, or its subscription is unpaid. Support tells you why.\n- `sending_disabled` (503): sending is disabled for everyone for the moment; retry after the seconds given.\n\nOver SMTP the same refusals arrive as SMTP replies; see [SMTP submission](sendora://docs/smtp)."},{"uri":"sendora://docs/limits","title":"Limits","description":"Every limit an account meets and its number, from the sending rates and the monthly ceiling to messages, broadcasts, SMTP, inbound mail, retention, webhooks, pages, sign-in and the proxy.","text":"# Limits\n\nEvery limit an account meets and its number, from the sending rates and the monthly ceiling to messages, broadcasts, SMTP, inbound mail, retention, webhooks, pages, sign-in and the proxy.\n\nEvery number on this page is read from the service's code by a test, so the page and the service say the same. The other guides link here instead of repeating them, and [Errors](sendora://docs/errors) says what each refusal looks like.\n\n## Sending rates and the monthly ceiling\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 ceiling | 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 ceiling | 10 times the ceiling approval set, unless Sendora sets another |\n| Warning | A `cap_warning` webhook at 80 percent of the ceiling, once a month |\n\nAn administrator moves the ceiling under [Account › Usage](https://app.sendora.se/account/usage), anywhere up to the highest; write to support to go past that. An account approved without an application starts at the ceiling of the smallest volume. Sendora can give a server a per-minute limit and a monthly cap of its own, only ever tighter than the account's. A broadcast counts against the ceiling whole when it is accepted, and the per-minute limit does not apply to it.\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 ceiling, and its refusals carry 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: text and HTML as written, attachments encoded |\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` answer 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\nA message the parser cannot hold to these is kept whole, its raw form downloadable for the content window, with `parseIssue` saying why:\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\nPast the header, part, size or time limits the parsed text, HTML and attachments are empty; past the last three the message is parsed and the rest is left out.\n\n## Retention\n\n| What | Kept for |\n| -------------------------------------------------------------------------- | ------------------------------------------------------- |\n| A message's text and HTML | 30 days |\n| Its attachments' content | Until the 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## Pages\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## The proxy\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 documentation and tools. The install for Claude Code, Cursor, VS Code, Codex and Claude Desktop, what each key lets it do, and every tool.","text":"# MCP server\n\nGive a coding agent Sendora's documentation and tools. The install for Claude Code, Cursor, VS Code, Codex and Claude Desktop, what each key lets it do, and every tool.\n\nSendora's MCP server gives a coding agent, such as Claude Code, Cursor or VS Code, Sendora's documentation and tools for your servers while it builds your integration. It reads the guides and the API reference, sends a test mail and reads what happened to it, and sets up streams, webhooks and sending domains. Without a key it offers the documentation alone.\n\nIt runs two ways, with the same tools:\n\n- Locally, as `npx -y @sendora/mcp`, over stdio. It needs Node 20.19 or newer, and the documentation is built into the package, so it answers offline.\n- Hosted, at `https://mcp.sendora.se/mcp`, with the key as a bearer token, and nothing to install. Without a key, `https://mcp.sendora.se/docs` serves the documentation.\n\n## Choose a key\n\nThe server does what its key allows, and lists only the tools the key may use:\n\n- A test server's key, `sk_test_…`, lets it do everything on that server, and nothing is delivered. Start here: a [test server](sendora://docs/test-servers) takes mail as a live one does, and the simulator's addresses act out bounces, deferrals and complaints.\n- A live server's key, `sk_…`, gets the tools that read the message log, received mail and the server's settings, and those that change something once you switch changes on.\n- The account key, `ak_…`, gets the tools that read the account's servers and sending domains, and with changes on those that add a sending domain or delete a test server.\n- With no key, it reads the documentation, so you can build before your account is approved.\n\n[Keys](sendora://docs/keys) says where each key is made. Put the one you choose in your environment as `SENDORA_API_TOKEN`, from your shell's profile or your secret manager: the configurations for Claude Code, Cursor and Codex read it from there, and VS Code and Claude Desktop ask for it and keep it in their own secret storage. Either way, no file you commit holds it.\n\n## Install\n\nOne click, for the local server:\n\n- [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=sendora&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBzZW5kb3JhL21jcCJdLCJlbnYiOnsiU0VORE9SQV9BUElfVE9LRU4iOiIke2VudjpTRU5ET1JBX0FQSV9UT0tFTn0ifX0=) adds the local entry shown under Cursor, with the key from `SENDORA_API_TOKEN`.\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) adds the local entry shown under VS Code to your profile, and asks for the key when the server first starts.\n- [Download the bundle for Claude Desktop](https://sendora.se/downloads/sendora-mcp.mcpb), then open it.\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 its full minute for an outcome:\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\nIn `.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\nIn `.vscode/mcp.json`. 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\nIn `~/.codex/config.toml`. Codex passes a local server a few system variables, such as `PATH` and `HOME`, and of the rest only those you name. It gives a tool a minute unless told otherwise, so both entries set `tool_timeout_sec` to let `send_email` wait its full minute. Locally:\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\nEdit the file rather than use `codex mcp add --env`, which writes the key's value into it.\n\n### Claude Desktop\n\n[Download the bundle](https://sendora.se/downloads/sendora-mcp.mcpb) and open it, and Claude Desktop asks whether to install it; it runs on the Node that Claude Desktop brings. Paste your key into **Sendora key**, which Claude Desktop keeps in your operating system's secure storage, or leave it empty for the documentation alone. Tick **Allow changes** only when you want changes on. Each release comes with a new bundle here, which you install over the old one.\n\nA 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 answers 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\nIf the agent has only `search_docs` and `read_doc`, no key reached the server: set `SENDORA_API_TOKEN` where the client runs, and start the client again. If the client shows the server as failed, the key did not reach it or the API refused it: check that the variable is set there and that the key is still live in the dashboard.\n\n## Changes on\n\nA live server's key and the account key get only the reading tools until you switch changes on 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, and a test key needs no switch. With changes on, a live server's key sends real mail to real people; the server tells the agent to confirm with you first, and your client asks before each call it has not been allowed to make. Changes on never shows the model a new key or signing secret, and never revokes a key, deletes a webhook, archives a stream, or sends or cancels a broadcast on a live server.\n\nThe switch decides which tools the server lists; it does not limit the key. An agent that runs commands or reads files on your machine can reach a key kept in its environment there too, so give an agent a live key only as you would give it the key itself. VS Code's prompt and Claude Desktop's bundle keep the key out of your environment.\n\n## What each key lists\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\nA tool a key may not use is not listed, so the agent cannot call it. `delete_test_server`, the account key's one tool that cannot be undone, refuses a live server; a live server is deleted in the dashboard. A new key or a webhook's signing secret reaches the model only on a test server: with a live key, the agent sends you to the dashboard to make one.\n\n## Tools\n\nThe table is read from the server's own list whenever this page is built, so it names every tool. **Keys** says which keys list each one: `test` a test server's key, `live` a live server's, `account` the account key, and \"with changes on\" where the switch must be on.\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 server from the same sources as these pages, at each release: the guides as `sendora://docs/…`, the API reference as `sendora://reference/…` and the error codes as `sendora://errors`. Each SDK's README and rules for agents are there too: `sendora://sdk/readme` and `sendora://sdk/skill` for TypeScript, and `sendora://sdk/python/readme` and `sendora://sdk/python/skill` for Python. `sendora://index` lists them all. `search_docs` searches every one but the rules, which an agent reads whole, and `read_doc` reads one 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## What to ask\n\nWith a test server's key:\n\n- \"Send a test mail to any address you like, and tell me whether it was delivered.\"\n- \"Show me what a hard bounce looks like on our test server.\"\n- \"One of our webhook deliveries failed; send it again.\"\n- \"How much test mail have we sent this month, and what is the limit?\"\n\nWith a live server's key:\n\n- \"Why did the last message to `anna@example.com` bounce?\"\n- \"Which addresses can't we send to?\"\n- \"Summarise the last mail we received.\"\n\nWith no key at all:\n\n- \"Add a password-reset mail to this app with `@sendora/sdk`.\"\n- \"What does `monthly_cap_reached` mean, and what should we do about it?\"\n\n## Received mail\n\nA live server's key lists the tools for the mail your inbound stream receives. Whoever sent a mail wrote its subject, its names, its headers and its text, and an agent reading them also has your terminal, your files and your other MCP servers. So of what the sender chose, `search_inbound` answers only two addresses, the From address and the address the mail was sent to. `read_inbound_message` gives the rest between two markers that carry a random value in each answer, after a notice that everything between them is data written by the sender and not instructions. It answers in text alone, with no structured copy, since some clients, Codex among them, give the model a tool's structured answer in place of its text: whichever client you use, the model reads the mail inside the markers.\n\nThis lessens prompt injection without preventing it. A model can still do what a mail asks of it: 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 chose both: on Sendora's own address the `+label`, on a domain of yours the whole local part. Read what the agent proposes before you approve it, keep changes off unless the task needs them, and give an agent that reads received mail no more tools than the task needs. If you think a key reached someone it should not have, revoke it in the dashboard.\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. A server under another name is not Sendora's, and it could read every key and message it is given."},{"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| `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| `tenant_not_active` | 403 | The account is not approved to send. |\n| `tenant_paused` | 403 | The account is paused by Sendora. |\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});\n```\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| `tenant_paused` | 403 | Sendora has paused the account. |\n| `tenant_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. |\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 Answer 2xx quickly and do the work afterwards. Deliveries arrive at\n 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 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| `tenant_paused` | 403 | Sendora has paused the account. |\n| `tenant_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\nUntil 1.0.0, a minor release may change what an earlier one answers, and\nthe changelog marks each such change. From 1.0.0 the package follows\nsemantic versioning.\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| `tenant_paused` | 403 | The account is paused by Sendora. |\n| `tenant_not_active` | 403 | The account is not approved to send. |\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` (\"tenant\" | \"server\" | \"test\", required): Whose limit it is: `tenant` 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` (\"tenant\" | \"server\" | \"test\", required): Whose limit it is: `tenant` 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\": \"tenant\",\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| `tenant_paused` | The account is paused by Sendora. | |\n| `tenant_not_active` | The account is not approved to send. | |\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| `tenant_paused` | 403 | The account is paused by Sendora. |\n| `tenant_not_active` | 403 | The account is not approved to send. |\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| `tenant_paused` | 403 | The account is paused by Sendora. |\n| `tenant_not_active` | 403 | The account is not approved to send. |\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` (\"tenant\" | \"server\" | \"test\", required): Whose limit it is: `tenant` 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\": \"tenant\",\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| `tenant_not_active` | 403 | The account is not approved to send. |\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` (\"tenant\" | \"server\" | \"test\", required): Whose limit it is: `tenant` 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\": \"Fakturering\",\n \"mode\": \"live\",\n \"createdAt\": \"2026-09-20T12:00:00.000Z\",\n \"limits\": [\n {\n \"scope\": \"tenant\",\n \"period\": \"minute\",\n \"limit\": 300,\n \"used\": 4,\n \"resetsAt\": \"2026-09-24T10:01:00.000Z\"\n },\n {\n \"scope\": \"tenant\",\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\": \"Fakturering\",\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\": \"Fakturering\"\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\": \"Fakturering\",\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\": \"Fakturering\",\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\": \"Fakturasystemet\"\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\": \"Fakturasystemet\",\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\": \"Fakturasystemet\",\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\": \"Fakturasystemet\"\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\": \"Fakturasystemet\",\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\": \"Fakturasystemet\",\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` (\"tenant\" | \"server\" | \"test\", required): Whose cap it is: `tenant` 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\": \"tenant\",\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\": \"tenant\",\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```"}]}