@sendora/mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +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`, and every limit a send on it is held to with how much of it this minute and this month have used.\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}\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://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, and every limit a send on it is held to with how much of it this\nminute and this month have used.\n\n| Call | Answers |\n| ---------------------- | -------------------------------------------------------------------- |\n| `sendora.server.get()` | The server with `mode` and `limits`, each with `used` and `resetsAt` |\n\n```ts\nconst { mode, limits } = await sendora.server.get();\nconst month = limits.find((limit) => limit.period === 'month');\nconsole.log(mode, month?.used, month?.limit);\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. 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, and every limit a send on it is held to with how much of it this minute and this month have used.\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 and its limits with their use.\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\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}\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."}]}