# POST /v1/email

Send one message

Accepts 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.

Takes a server key (`sk_…`, or `sk_test_…` on a test server) as a bearer token. TypeScript: `sendora.email.send(message)`. Python: `sendora.email.send(**message)`.

## Parameters

- `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.

## Request body

- `from` (one of 2, required): The sender. Its domain must be a verified sending domain of the server.
- `streamId` (string (uuid)): The stream of this server the message goes on; the default transactional stream when absent.
- `to` (array of one of 2, at least 1, required): Recipients; at most 50 across to, cc and bcc.
- `cc` (array of one of 2): Copied recipients, shown in the message.
- `bcc` (array of one of 2): Blind-copied recipients.
- `subject` (string (at least 1, at most 998 characters), required): The subject line.
- `text` (string (at least 1 character)): The plain-text part. At least one of text and html is required.
- `html` (string (at least 1 character)): The HTML part.
- `headers` (map of string to string): Custom headers by name, at most 20. Sendora writes these itself, so a message may not set them: From, Sender, To, Cc, Bcc, Subject, Date, Message-ID, MIME-Version, Return-Path, Received, Delivered-To, DKIM-Signature, DomainKey-Signature, Authentication-Results, Received-SPF, and any name that starts with Content-, Resent-, ARC- or X-Kumo.
- `attachments` (array of object, at most 20): At most 20. A message with its attachments encoded may be at most 10 MB.
  - `name` (string, required)
  - `content` (string (base64), required)
  - `contentType` (string)
  - `contentId` (string or null)
- `tag` (string or null): A label of up to 100 characters, returned with the message and its events.
- `metadata` (map of string to string): Up to 20 key-value pairs of your own, returned with the message and its events.

The forms of `from`, and of each item of `to`, `cc` and `bcc`:

- Form 1: string (email)
- Form 2
  - `email` (string (email), required)
  - `name` (string (at least 1, at most 200 characters) or null): A display name of up to 200 characters.

Example:

```json
{
  "from": {
    "email": "no-reply@example.se",
    "name": "Example AB"
  },
  "to": [
    "anna@example.com"
  ],
  "subject": "Your invoice for September",
  "text": "Hi Anna, your invoice is attached.",
  "attachments": [
    {
      "name": "invoice.pdf",
      "content": "JVBERi0xLjcK",
      "contentType": "application/pdf"
    }
  ],
  "tag": "invoice",
  "metadata": {
    "invoiceId": "2026-0912"
  }
}
```

## Responses

### 200 Accepted for delivery.

- `messageId` (string (uuid), required): The id the message log, the events and the webhooks refer to.
- `status` ("accepted", required)
- `submittedAt` (string (date-time), required): When the message was accepted.
- `test` (boolean, required): True when the server is a test server: the message goes through everything but delivery, and nobody receives it.

Headers:

- `Idempotent-Replayed` ("true"): true when this is the first answer again, to a request sent before under the same Idempotency-Key; absent otherwise.

Example:

```json
{
  "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
  "status": "accepted",
  "submittedAt": "2026-09-15T12:00:00.000Z",
  "test": false
}
```

## Errors

Every error answers `error`, the code, and `message`, a sentence for a person. A code that adds fields is shown in full below the table.

| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | 400 | The body, the query or a header does not match what the route takes. |
| `unauthorized` | 401 | The key is missing, malformed or revoked. |
| `payment_required` | 402 | The account has no active subscription. |
| `wrong_token_kind` | 403 | The key is of the other kind: a server key (sk_) where an account key (ak_) is needed, or the reverse. The message names the kind the operation takes. |
| `account_paused` | 403 | The account is paused by Sendora. |
| `account_not_active` | 403 | The account is closed, or not yet approved for a live server's sends or an inbound stream. |
| `request_too_large` | 413 | The body exceeds 10 MB. |
| `from_domain_not_verified` | 422 | The From domain is not a verified sending domain of this server. |
| `stream_not_found` | 422 | The streamId names no stream of this server. |
| `stream_archived` | 422 | The stream is archived and takes no new messages. |
| `stream_paused` | 422 | Sendora has paused the stream after complaints; it takes no messages until support has resumed it. |
| `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no suppression list. Name a transactional or broadcast stream. |
| `unsubscribe_placeholder_missing` | 422 | A message on a broadcast stream must carry {{ unsubscribe_url }} in every part it has. |
| `list_unsubscribe_reserved` | 422 | On a broadcast stream Sendora writes the List-Unsubscribe pair itself, so a message may not carry one. |
| `recipient_suppressed` | 422 | One or more recipients are on the suppression list of the stream. |
| `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. |
| `idempotency_key_mismatch` | 422 | The Idempotency-Key was already used for a different request. |
| `rate_limited` | 429 | More was sent within a minute than the limit allows. |
| `monthly_cap_reached` | 429 | The monthly cap of the account, the server or the test servers is used up. |
| `sending_disabled` | 503 | Sending is disabled for everyone for the moment; retry after the seconds in Retry-After. |

Headers sent with an error:

- `Retry-After` (integer) on 429: With rate_limited, the seconds until a send is accepted; absent with monthly_cap_reached.
- `Retry-After` (integer) on 503: Seconds to wait before trying again.

### `invalid_request`

- `issues` (array of object): One entry per invalid field; absent when a header is wrong.
  - `path` (string, required): The field, dotted, such as to.0.email; empty when the whole body is wrong.
  - `message` (string, required)

Example:

```json
{
  "error": "invalid_request",
  "message": "The request is invalid: to.0: Invalid email address",
  "issues": [
    {
      "path": "to.0",
      "message": "Invalid email address"
    }
  ]
}
```

### `recipient_suppressed`

- `streamId` (string (uuid), required): The stream whose suppression list refused the send.
- `suppressed` (array of object, required): Each refused address, lower-cased, with the reason it is on the list.
  - `address` (string, required)
  - `reason` ("hard_bounce" | "spam_complaint" | "manual" | "unsubscribe", required)

Example:

```json
{
  "error": "recipient_suppressed",
  "message": "One or more recipients are on the suppression list of this stream; suppressed says which and why.",
  "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "suppressed": [
    {
      "address": "anna@example.com",
      "reason": "hard_bounce"
    }
  ]
}
```

### `test_address_on_live_server`

- `addresses` (array of string, required): The recipients at simulator.sendora.se, each once.

Example:

```json
{
  "error": "test_address_on_live_server",
  "message": "Addresses at simulator.sendora.se act out an outcome on a test server and are never sent to from a live one. Send them from a test server.",
  "addresses": [
    "hardbounce@simulator.sendora.se"
  ]
}
```

### `rate_limited`

- `scope` ("account" | "server" | "test", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.
- `limit` (integer, required): Emails per minute allowed for that scope.
- `retryAfter` (integer, required): Seconds until the next send is accepted; also sent as the Retry-After header.

Example:

```json
{
  "error": "rate_limited",
  "message": "This server may send 60 emails per minute. Try again in 12 seconds.",
  "scope": "server",
  "limit": 60,
  "retryAfter": 12
}
```

### `monthly_cap_reached`

- `scope` ("account" | "server" | "test", required): Whose limit it is: `account` for the account's, `server` for the server's own, `test` for the cap the account's test servers share.
- `cap` (integer, required): Emails the scope may send in a calendar month, in UTC.
- `used` (integer, required): Emails sent this month, the refused send not counted.
- `resetsAt` (string (date-time), required): When the month counter resets.

Example:

```json
{
  "error": "monthly_cap_reached",
  "message": "This account has used 50000 of 50000 emails this month. The cap resets at 2026-11-01T00:00:00.000Z.",
  "scope": "account",
  "cap": 50000,
  "used": 50000,
  "resetsAt": "2026-11-01T00:00:00.000Z"
}
```
