# Sending email

> POST /v1/emails in detail. Fields, recipients as a string or a list, idempotency, batch sending, scheduling, tags, metadata, attachments and the pre-send check.

Source: https://couryo.com/en/docs/sending

`POST /v1/emails` sends an email (`send` scope). The `202 Accepted` response has the `id` and the `status`, and it only comes back **after** the email is in the queue. If something prevents the send, you get an [error](https://couryo.com/en/docs/errors.md), never a fake success.

```json title="Response 202"
{ "id": "msg_9w2k7c1x0d4e5f6g", "status": "queued" }
```

With a `ck_test_` key the status is `captured` and nothing is delivered (see [Test mode](https://couryo.com/en/docs/test-mode.md)).

## Fields

| Field | Type | Required | Description |
|---|---|---|---|
| `from` | string | yes | sender on one of the project's verified domains: `"hi@example.com"` or `"Shop <orders@example.com>"`. A subdomain of a verified domain works too |
| `to` | string or string[] | yes | one address (`"ana@example.com"`) or a list |
| `cc`, `bcc` | string or string[] | no | carbon copy and blind carbon copy, also a string or a list |
| `reply_to` | string or string[] | no | where replies go |
| `subject` | string | yes | subject, 1 to 998 characters |
| `html` | string | one of the two | HTML body (up to 5 MB) |
| `text` | string | one of the two | plain text body (up to 5 MB) |
| `attachments` | object[] | no | up to 20 attachments (see below) |
| `headers` | object | no | extra headers, such as `{ "X-Order": "1042" }` |
| `tags` | object | no | string key and value pairs to filter and group by |
| `metadata` | object | no | your own string data, returned in webhooks |
| `scheduled_at` | ISO 8601 string | no | send later, up to 30 days ahead |
| `stream` | `transactional` or `marketing` | no | stream; defaults to `transactional` |
| `template` + `variables` | string + object | no | saved templates: coming soon (today the API answers `invalid_field`; send `html` or `text`) |

- **At most 50 recipients** per email, across `to`, `cc` and `bcc`. For more, use a [batch](#batch-sending).
- The request body can be up to 30 MB, counting Base64 attachments.
- Always send `text` along with `html`. Providers trust emails with both parts more, and plain-text readers will thank you.

```json title="Body with recipients as a string and as a list"
{
  "from": "Example Shop <orders@example.com>",
  "to": "ana@example.com",
  "cc": ["finance@example.com", "shipping@example.com"],
  "reply_to": "support@example.com",
  "subject": "Order 1042 confirmed",
  "html": "<p>Your order 1042 is confirmed.</p>",
  "text": "Your order 1042 is confirmed.",
  "tags": { "type": "order" },
  "metadata": { "order_id": "1042" }
}
```

## Idempotency

Networks drop, functions restart, queues retry. To avoid sending the same email twice, send the `Idempotency-Key` header (up to 255 characters) with a value unique to the business operation:

```http title="Header"
Idempotency-Key: order-1042-confirmation
```

- For **24 hours**, repeating the call with the same key and the **same body** returns the **same response**, with the `Idempotent-Replayed: true` header, without sending again.
- The same key with a **different body** returns `409 idempotency_conflict`.
- If the first request is still running, the retry gets `409 idempotency_in_progress` with `Retry-After: 1`.
- `5xx`, `409` and `429` responses are not stored: you can retry with the same key.
- It works on every `POST`, including batches.

Use something tied to the event, such as `order-{id}-confirmation` or `password-{user}-{timestamp}`. A fresh UUID on every attempt does not protect against retries.

## Suppressed recipients

If **some** recipients are on the [suppression list](https://couryo.com/en/docs/suppressions.md), the email goes to the others and the timeline gets a `suppressed` event for each skipped address. If **every** recipient is suppressed, the response is `422 recipient_suppressed`, with `param` pointing to the first one (`to[0]`).

## Batch sending

`POST /v1/emails/batch` takes up to **100 emails** per call, in the `emails` field. Each item has the same fields as `POST /v1/emails` and is validated on its own: one bad item does not take down the others.

```bash title="cURL"
curl https://api.couryo.com/v1/emails/batch \
  -H "Authorization: Bearer $COURYO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reminders-2026-10-07" \
  -d '{
    "emails": [
      { "from": "hi@example.com", "to": "ana@example.com", "subject": "Reminder", "text": "Your class starts at 7 pm." },
      { "from": "hi@example.com", "to": ["bruno@example.com"], "subject": "Reminder", "text": "Your class starts at 7 pm." }
    ]
  }'
```

The response is `200` with one result per item, in the same order. Error `param`s include the item index:

```json title="Response 200"
{
  "data": [
    { "id": "msg_4h8s1m2p0q7r6t5u", "status": "queued" },
    {
      "error": {
        "type": "invalid_request",
        "code": "recipient_suppressed",
        "message": "The address bruno@example.com is on the suppression list.",
        "param": "emails[1].to[0]",
        "doc_url": "https://couryo.com/docs/errors#recipient_suppressed",
        "request_id": "req_2m9x4k7c1v0b8n3q"
      }
    }
  ]
}
```

- More than 100 items: `400 batch_too_large`, and nothing is sent.
- A limit error (`daily_limit_reached`, `monthly_limit_reached`, `spend_limit_reached` or `sending_paused`) stops the rest of the batch: the item that hit the limit and every item after it come back with the same error.

## Scheduling

Pass `scheduled_at` as ISO 8601 with a time zone (`Z` or `-03:00`), up to 30 days ahead. The email stays `queued` until then. A time in the past (by more than 1 minute) or beyond 30 days returns `invalid_field`.

```json title="Body"
{
  "from": "hi@example.com",
  "to": "ana@example.com",
  "subject": "Your class starts in 1 hour",
  "text": "See you soon!",
  "scheduled_at": "2026-10-08T21:00:00Z"
}
```

## Tags and metadata

- `tags` let you filter the list (`GET /v1/emails?tag=type:order`) and group in the dashboard: `{ "type": "order", "campaign": "october" }`. Keys use letters, numbers, `_` and `-` (up to 64); values up to 256 characters.
- `metadata` is yours: it comes back in every webhook, handy to tie the email to a record in your system: `{ "order_id": "1042" }`. Keys up to 64 characters; values up to 1,024.

Both accept string values only.

## Attachments

Each attachment has `filename` (up to 255 characters), `content` (the file in Base64) and, optionally, `content_type`. Up to 20 per email.

```json title="Attachment"
{ "filename": "receipt.pdf", "content": "JVBERi0xLjcK...", "content_type": "application/pdf" }
```

> Bills and invoices: send a **link**, not an attachment. Bill attachments are a classic scam pattern, and spam filters know it. The pre-send check warns about it (`billing_attachment`).

## Headers

Headers in `headers` go out as you sent them, except the ones that belong to the platform or break authentication, which are ignored: `From`, `To`, `Cc`, `Bcc`, `Subject`, `Message-ID`, `Date`, `Return-Path`, `DKIM-Signature`, `List-Unsubscribe`, `List-Unsubscribe-Post`, `Content-Type`, `Content-Transfer-Encoding` and `MIME-Version`.

## Transactional and marketing

The `stream` field separates the streams. A marketing unsubscribe never blocks transactional email (password, login, billing). On `marketing`, Couryo adds the one-click unsubscribe (`List-Unsubscribe` and `List-Unsubscribe-Post`, RFC 8058) that Gmail and Yahoo require, and whoever clicks it is suppressed on the `marketing` stream. Opens and clicks are tracked on the `marketing` stream only.

## Pre-send check

`POST /v1/emails/check` (`send` scope) takes the same body as a send and returns a score from 0 to 100 and a list of issues, without sending anything. Good for CI.

```json title="Response 200"
{ "score": 90, "issues": [{ "code": "missing_text_part", "severity": "warning", "message": "The email has no text part." }] }
```

| `code` | Severity | What it flags |
|---|---|---|
| `domain_not_verified` | error | the `from` domain is not verified: the send would be refused |
| `link_shortener` | error | a shortened link; use the full link on your own domain |
| `missing_text_part` | warning | `html` without `text` |
| `insecure_link` | warning | links without HTTPS |
| `html_too_large` | warning | HTML over 100 KB (Gmail clips the message) |
| `subject_all_caps` | warning | an all-caps subject |
| `billing_attachment` | warning | a bill, invoice or tax receipt as an attachment |
| `image_without_alt` | info | an image without alternative text |

## List and fetch

| Method and path | Scope | What it does |
|---|---|---|
| `GET /v1/emails` | `read` | newest first; filters `status`, `recipient` (part of the address), `tag` (`key:value` or a free term) and `period` (`24h`, `7d`, `30d`), with `limit` and `starting_after` |
| `GET /v1/emails/{id}` | `read` | one email with its [timeline](https://couryo.com/en/docs/events.md) |
