# Send a transactional email

Admit a transactional email for delivery with POST /v1/email, composed by you or from a saved template — its body, its response and the errors it can answer with.

**POST https://api.e-comms.dev/v1/email**

Admits a transactional email for delivery. Never calls the provider before answering: every address is claimed or suppressed on the spot.

Status: `202`

### Path parameters

None.

### Query parameters

None.

### Headers

- `Authorization` `string` — Required. `Bearer ek_live_<key>`.

### Request attributes

- `idempotencyKey` `string` — Required. The same value with the same body answers what was already resolved, without resending; with a different body it answers conflict.
- `to` `string[]` — Optional. Up to 50 email addresses. At least one address has to appear across to, cc and bcc.
- `cc` `string[]` — Optional. Up to 50 email addresses, the same rule as to.
- `bcc` `string[]` — Optional. Up to 50 email addresses, the same rule as to.
- `from` `string` — Required. An email address of a sender already registered in this organization — its own name is what leaves in the From header, never a per-call override.
- `replyTo` `string` — Optional. An email address. Supplied, it wins; left out, it is filled with the sender's own real address whenever the send leaves substituted, and stays empty otherwise.
- `content` `object` — Required. The message body. Either sent raw or compiled from a saved template, and `type` is what tells the two apart — never both at once.
  - `type: "raw"`
    - `subject` `string` — Required. The subject line. It cannot be empty.
    - `html` `string` — Required. The HTML body, already composed. Nothing is substituted into it.
    - `text` `string` — Required. The plain-text alternative, for a client that renders no HTML.
  - `type: "template"`
    - `templateId` `string` — Required. The id of an active template this organization owns. Copy it from the template's header or its row menu — see Send with a template, further down this page.
    - `variables` `map from strings to strings` — Required. Your own catalog for this call — there is no list behind a transactional send, so the keys you put here are the only ones the template has to resolve its merge tags against.

### Response attributes

- `results` `object[]` — Required. One entry per addressee — to, cc and bcc alike.
  - `address` `string` — Required. That addressee's own address.
  - `messageId` `string` — Required. The id GET /v1/email/:messageId also accepts.
  - `status` `string` — Required. claimed or suppressed only: the shared enum publishes five values, but the provider has not been called yet when this response is built.

## Send with a template

Instead of `{ type: "raw", subject, html, text }`, `content` can carry a template already saved
in **Templates**, with `type: "template"`:

```bash
curl -X POST https://api.e-comms.dev/v1/email \
  -H "Authorization: Bearer ek_live_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotencyKey": "welcome-template-ana-2026-09-19",
    "to": ["ana@example.com"],
    "from": "hello@yourcompany.com",
    "content": {
      "type": "template",
      "templateId": "01a0ba31-6b2c-7a9f-8c3d-2e5f7a1b9c60",
      "variables": { "firstName": "Ana", "planName": "Pro" }
    }
  }'
```

```js
const response = await fetch("https://api.e-comms.dev/v1/email", {
  method: "POST",
  headers: {
    Authorization: "Bearer ek_live_<your-key>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    idempotencyKey: "welcome-template-ana-2026-09-19",
    to: ["ana@example.com"],
    from: "hello@yourcompany.com",
    content: {
      type: "template",
      templateId: "01a0ba31-6b2c-7a9f-8c3d-2e5f7a1b9c60",
      variables: { firstName: "Ana", planName: "Pro" },
    },
  }),
})

const body = await response.json()
```

```python
import requests

response = requests.post(
    "https://api.e-comms.dev/v1/email",
    headers={"Authorization": "Bearer ek_live_<your-key>"},
    json={
        "idempotencyKey": "welcome-template-ana-2026-09-19",
        "to": ["ana@example.com"],
        "from": "hello@yourcompany.com",
        "content": {
            "type": "template",
            "templateId": "01a0ba31-6b2c-7a9f-8c3d-2e5f7a1b9c60",
            "variables": {"firstName": "Ana", "planName": "Pro"},
        },
    },
)

body = response.json()
```

The response has the same shape as any other send's.

### Where the `templateId` comes from

Copy it from the template itself. Open the template in **Templates → edit** and use the copy
button beside the id under its name, or open the actions menu of its row in the templates list and
choose **Copy ID**. Any role that can see templates can copy it.

Only an **active** template can be used to send. An inactive one answers `conflict` — the same
code a repeated `idempotencyKey` with a different body answers.

### What a missing variable answers

If the template's document uses a merge tag that is not among `variables`'s own keys, the call
answers `bad_request`, naming the tag in `details`:

```json
{
  "code": "bad_request",
  "message": "Unknown merge tag key: planName",
  "details": {
    "keys": ["planName"]
  },
  "requestId": "01a0ba31-3fe4-7cc4-a1c6-64bdaaa5d99c"
}
```

There is no partial substitution and no silent default: the template compiles whole, or
nothing is sent at all.

## Notes

- **`from` has to match a sender exactly**, not just a domain: register one in the app's own
  Sending settings before calling this endpoint. A sender proved with its own one-time code
  sends as written; one whose domain is not yet authenticated there still sends, with the
  local part kept and the domain substituted, `replyTo` carrying the real address unless the
  call already supplied one.
- **Rate limit:** 100 requests per second per key, across all of `/v1`. Over that it answers
  `429 rate_limited`, with no `Retry-After` header and no quota header at all — the limit is
  never announced ahead of time.
- **`/v1` answers no CORS header at all.** This surface is not callable from a browser, only
  server to server.
- **The body is strict:** a field outside this list answers `validation_failed`, never
  silently ignored.
- **`idempotencyKey` is a body attribute, not a header.** The same key with the same body
  returns exactly the response already resolved, without re-queuing the send. The same key
  with a different body answers `409 conflict`. Two concurrent requests with the same key: the
  one that loses the race also answers `409`, never a duplicate send.

The [error reference](/en/docs/reference/errors) carries the rest of the codes this surface
can answer with, including the ones no single route declares on its own.

## Errors

| Status | Code | Cause |
| --- | --- | --- |
| `403` | `forbidden` | The key was not issued `messages:send`. |
| `404` | `not_found` | `templateId` names no template this organization owns, or `from` names no sender of this organization. |
| `409` | `conflict` | The `idempotencyKey` was already used with a different body, the referenced template is not active, or the sender named by `from` has not proved that address yet. |
| `400` | `bad_request` | No address across `to`, `cc` and `bcc`, or the template's document uses a merge tag that is not among `variables`'s own keys. |
| `402` | `payment_required` | The organization is over its plan's sending limit. |

## Request

```bash
curl -X POST https://api.e-comms.dev/v1/email \
  -H "Authorization: Bearer ek_live_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotencyKey": "welcome-ana-2026-09-19",
    "to": ["ana@example.com"],
    "from": "hello@yourcompany.com",
    "content": {
      "type": "raw",
      "subject": "Welcome",
      "html": "<p>Hi, Ana!</p>",
      "text": "Hi, Ana!"
    }
  }'
```

```js
const response = await fetch("https://api.e-comms.dev/v1/email", {
  method: "POST",
  headers: {
    Authorization: "Bearer ek_live_<your-key>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    idempotencyKey: "welcome-ana-2026-09-19",
    to: ["ana@example.com"],
    from: "hello@yourcompany.com",
    content: {
      type: "raw",
      subject: "Welcome",
      html: "<p>Hi, Ana!</p>",
      text: "Hi, Ana!",
    },
  }),
})

const body = await response.json()
```

```python
import requests

response = requests.post(
    "https://api.e-comms.dev/v1/email",
    headers={"Authorization": "Bearer ek_live_<your-key>"},
    json={
        "idempotencyKey": "welcome-ana-2026-09-19",
        "to": ["ana@example.com"],
        "from": "hello@yourcompany.com",
        "content": {
            "type": "raw",
            "subject": "Welcome",
            "html": "<p>Hi, Ana!</p>",
            "text": "Hi, Ana!",
        },
    },
)

body = response.json()
```

## Response `202` — Accepted

```json
{
  "results": [
    {
      "address": "ana@example.com",
      "messageId": "01a0ba31-3fe4-7cc4-a1c6-64bdaaa5d99c",
      "status": "claimed"
    }
  ]
}
```

## Response `402` — Payment required

```json
{
  "code": "payment_required",
  "message": "The organization is over its plan's sending limit.",
  "details": null,
  "requestId": "01a0ba31-3fe4-7cc4-a1c6-64bdaaa5d99c"
}
```
