# Enviar un correo transaccional

Admite un correo transaccional para entrega con POST /v1/email, compuesto por ti o desde una plantilla guardada — cuerpo, respuesta y los errores que puede devolver.

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

Admite un correo transaccional para entrega. Nunca llama al proveedor antes de responder: cada dirección queda claimed o suppressed en el momento.

Status: `202`

### Parámetros de ruta

Ninguno.

### Parámetros de consulta

Ninguno.

### Encabezados

- `Authorization` `string` — Obligatorio. `Bearer ek_live_<clave>`.

### Atributos de la solicitud

- `idempotencyKey` `string` — Obligatorio. El mismo valor con el mismo cuerpo responde lo que ya se resolvió, sin reenviar; con un cuerpo distinto responde conflict.
- `to` `string[]` — Opcional. Hasta 50 direcciones de correo. Al menos una dirección tiene que aparecer entre to, cc y bcc.
- `cc` `string[]` — Opcional. Hasta 50 direcciones de correo, la misma regla que to.
- `bcc` `string[]` — Opcional. Hasta 50 direcciones de correo, la misma regla que to.
- `from` `string` — Obligatorio. Una dirección de un remitente ya registrado en esta organización — su propio nombre es lo que sale en el encabezado From, nunca un valor por llamada.
- `replyTo` `string` — Opcional. Una dirección de correo. Si se envía, gana; si se omite, se llena con la dirección real del remitente cuando el envío sale sustituido, y queda vacía en caso contrario.
- `content` `object` — Obligatorio. El cuerpo del mensaje. Se envía en crudo o se compila desde una plantilla guardada, y `type` es lo que distingue las dos — nunca las dos a la vez.
  - `type: "raw"`
    - `subject` `string` — Obligatorio. El asunto del mensaje. No puede quedar vacío.
    - `html` `string` — Obligatorio. El cuerpo en HTML, ya compuesto. No se sustituye nada en él.
    - `text` `string` — Obligatorio. El cuerpo alternativo en texto plano, para el cliente que no pinte HTML.
  - `type: "template"`
    - `templateId` `string` — Obligatorio. El id de una plantilla activa de esta organización. Cópialo desde el encabezado de la plantilla o desde el menú de su fila — mira Enviar con una plantilla, más abajo en esta página.
    - `variables` `mapa de cadenas a cadenas` — Obligatorio. Tu propio catálogo para esta llamada — no hay una lista detrás de un envío transaccional, así que las claves que pongas aquí son las únicas que la plantilla tiene para resolver sus etiquetas de fusión.

### Atributos de la respuesta

- `results` `object[]` — Obligatorio. Una entrada por destinatario — to, cc y bcc por igual.
  - `address` `string` — Obligatorio. La dirección de ese destinatario.
  - `messageId` `string` — Obligatorio. El id que también acepta GET /v1/email/:messageId.
  - `status` `string` — Obligatorio. Solo claimed o suppressed: el enum compartido publica cinco valores, pero el proveedor todavía no fue llamado cuando se arma esta respuesta.

## Enviar con una plantilla

En vez de `{ type: "raw", subject, html, text }`, el `content` puede llevar una plantilla ya
guardada en **Templates**, con `type: "template"`:

```bash
curl -X POST https://api.e-comms.dev/v1/email \
  -H "Authorization: Bearer ek_live_<tu-clave>" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotencyKey": "bienvenida-plantilla-ana-2026-09-19",
    "to": ["ana@example.com"],
    "from": "hola@tuempresa.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_<tu-clave>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    idempotencyKey: "bienvenida-plantilla-ana-2026-09-19",
    to: ["ana@example.com"],
    from: "hola@tuempresa.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_<tu-clave>"},
    json={
        "idempotencyKey": "bienvenida-plantilla-ana-2026-09-19",
        "to": ["ana@example.com"],
        "from": "hola@tuempresa.com",
        "content": {
            "type": "template",
            "templateId": "01a0ba31-6b2c-7a9f-8c3d-2e5f7a1b9c60",
            "variables": {"firstName": "Ana", "planName": "Pro"},
        },
    },
)

body = response.json()
```

La respuesta tiene la misma forma que la de cualquier otro envío.

### De dónde sale el `templateId`

Cópialo desde la propia plantilla. Abre la plantilla en **Templates → editar** y usa el botón de
copiar junto al id, bajo su nombre, o abre el menú de acciones de su fila en la lista de plantillas
y elige **Copiar ID**. Cualquier rol que pueda ver las plantillas puede copiarlo.

Solo una plantilla **activa** puede usarse para enviar. Una inactiva responde `conflict`, el
mismo código que una `idempotencyKey` repetida con otro cuerpo.

### Qué responde una variable que falta

Si el documento de la plantilla usa una etiqueta que no está entre las claves de `variables`,
la llamada responde `bad_request`, con el nombre de la etiqueta en `details`:

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

No hay una sustitución parcial ni un valor por defecto silencioso: la plantilla compila entera
o no envía nada.

## Notas

- **`from` tiene que coincidir exactamente con un remitente**, no solo con un dominio:
  regístralo antes en la configuración de Envío de la app. Un remitente probado con su propio
  código de un solo uso envía tal cual está escrito; uno cuyo dominio todavía no está
  autenticado ahí igual envía, conservando la parte local y sustituyendo el dominio, con
  `replyTo` llevando la dirección real salvo que la llamada ya haya enviado una.
- **Límite de solicitudes:** 100 por segundo por clave, en todo `/v1`. Por encima responde
  `429 rate_limited`, sin encabezado `Retry-After` y sin ningún encabezado de cuota — el límite
  no se anuncia por adelantado.
- **`/v1` no responde ningún encabezado CORS.** Esta superficie no es invocable desde un
  navegador, solo desde un servidor a servidor.
- **El cuerpo es estricto:** un campo que no está en esta lista responde `validation_failed`,
  nunca se ignora en silencio.
- **La `idempotencyKey` es del cuerpo, no un encabezado.** La misma clave con el mismo cuerpo
  devuelve exactamente la respuesta ya resuelta, sin volver a encolar el envío. La misma clave
  con un cuerpo distinto responde `409 conflict`. Dos solicitudes concurrentes con la misma
  clave: la que pierde la carrera también responde `409`, nunca un envío duplicado.

La [referencia de errores](/docs/reference/errors) trae el resto de los códigos que esta
superficie puede responder, incluidos los que ninguna ruta declara por sí sola.

## Errores

| Estado | Código | Causa |
| --- | --- | --- |
| `403` | `forbidden` | La clave no tiene el permiso `messages:send`. |
| `404` | `not_found` | `templateId` no existe para esta organización, o `from` no nombra ningún remitente de esta organización. |
| `409` | `conflict` | La `idempotencyKey` ya se usó con otro cuerpo, la plantilla referenciada no está activa, o el remitente nombrado por `from` todavía no probó esa dirección. |
| `400` | `bad_request` | Ninguna dirección entre `to`, `cc` y `bcc`, o el documento de la plantilla usa una etiqueta que no está entre las claves de `variables`. |
| `402` | `payment_required` | La organización superó el límite de envíos de su plan. |

## Solicitud

```bash
curl -X POST https://api.e-comms.dev/v1/email \
  -H "Authorization: Bearer ek_live_<tu-clave>" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotencyKey": "bienvenida-ana-2026-09-19",
    "to": ["ana@example.com"],
    "from": "hola@tuempresa.com",
    "content": {
      "type": "raw",
      "subject": "Bienvenida",
      "html": "<p>¡Hola, Ana!</p>",
      "text": "¡Hola, Ana!"
    }
  }'
```

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

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

```python
import requests

response = requests.post(
    "https://api.e-comms.dev/v1/email",
    headers={"Authorization": "Bearer ek_live_<tu-clave>"},
    json={
        "idempotencyKey": "bienvenida-ana-2026-09-19",
        "to": ["ana@example.com"],
        "from": "hola@tuempresa.com",
        "content": {
            "type": "raw",
            "subject": "Bienvenida",
            "html": "<p>¡Hola, Ana!</p>",
            "text": "¡Hola, Ana!",
        },
    },
)

body = response.json()
```

## Respuesta `202` — Admitido

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

## Respuesta `402` — Pago requerido

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