# Send a message

An email is created by one request. Lessly Mail validates it, accepts it and gives you back an identifier; delivery happens after the response. Everything you learn about the message afterwards you learn through that identifier.

## Send one email

The `from` address must be at a [verified domain](/ship/mail/domains) of your product, and the key goes in the `X-Api-Key` header.

```bash
curl -X POST "https://public.lessly.dev/$PRODUCT_ID/mail/emails" \
  -H "X-Api-Key: $LESSLY_MAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <hello@mail.acme.com>",
    "to": "customer@example.com",
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p>"
  }'
```

The response is `201` with the new message identifier. Every response is wrapped in the same envelope, a success carrying its result under `data`:

```json
{ "data": { "id": "9f1f0d9c-1c74-4a2e-9f9f-2b6d8a0b3c11" }, "success": true }
```

An agent calls [`mail_email_send`](/reference/mcp-tools/mail_email_send); over REST it is `POST /mail/emails` on the [Mail API](/reference/openapi/mail).

## Choose where you send from

| Surface | Authenticates with | Carries |
|---|---|---|
| Public sending endpoint **(Recommended)** for application code | An `lmk_` key in `X-Api-Key` | `POST /emails` and `GET /emails/{id}` |
| Mail API, for provisioning and operations | Your product's authenticated session | Everything, including batch, reschedule and cancel |

The public sending endpoint is `https://public.lessly.dev/{product_id}/mail`. Batch send, rescheduling and cancelling are not exposed on it. In the examples for those operations below, `...` stands for the Mail API base URL your product's authenticated session uses.

### The request fields

| Field | Required | Accepts |
|---|---|---|
| `from` | Yes | `you@your-domain.com` or `Name <you@your-domain.com>` |
| `to` | See below | One address, or an array of addresses |
| `cc` | No | One address, or an array of addresses |
| `bcc` | No | One address, or an array of addresses |
| `reply_to` | No | One address, or an array of addresses |
| `subject` | Yes, for an inline body | Any non-empty string |
| `html` | One of `html`/`text` | The HTML body |
| `text` | One of `html`/`text` | The plain-text body |
| `headers` | No | An object of header name to value |
| `tags` | No | An array of `{ "name": ..., "value": ... }` |
| `template` | No | `{ "id": ..., "variables": { ... } }` |
| `scheduled_at` | No | An ISO-8601 instant with a time zone |

`from` must be an address at a verified domain of your product. If the domain is unknown to the product the request fails with `404`; if the value carries no usable address, with `422 invalid_from_address`.

At least one recipient is required across `to`, `cc` and `bcc`, and the three together must not exceed **50 addresses** in one message. Every address in `to`, `cc`, `bcc` and `reply_to` is checked for shape before the message is accepted; the first malformed one fails the request with `422`:

```json
{ "name": "invalid_recipient", "message": "invalid recipient address: not-an-address" }
```

`bcc` recipients receive the message but are not named in its headers.

**Bodies.** Send `html`, `text`, or both. If you send only `html`, the plain-text part is derived from it — tags are stripped, `<br>` and closing block tags become line breaks, and the common HTML entities are decoded. If you send only `text`, the message is plain text and carries no HTML part.

**Custom headers.** `headers` is a flat object. A header name must consist of printable ASCII characters other than a colon, and no header value — nor any address or the subject — may contain a carriage return or a line feed. A violation is rejected before the message is accepted.

**Tags** are your own name/value pairs for grouping messages. They travel with the message as `X-Tag-<name>` headers and follow the same naming rule as custom headers.

**Templates.** `template` sends a published hosted template instead of an inline body, and is mutually exclusive with `subject`, `html` and `text` — supplying both arms fails validation. See [send from a template](/ship/mail/templates).

## Do not send the same receipt twice

Send an `Idempotency-Key` header with any value of your own and the request becomes safe to retry.

```bash
curl -X POST "https://public.lessly.dev/$PRODUCT_ID/mail/emails" \
  -H "X-Api-Key: $LESSLY_MAIL_API_KEY" \
  -H "Idempotency-Key: order-4711-receipt" \
  -H "Content-Type: application/json" \
  -d '{ "from": "hello@mail.acme.com", "to": "customer@example.com",
        "subject": "Your receipt", "html": "<p>Thanks.</p>" }'
```

A repeat of the same request with the same key returns the original response and creates nothing new. The key is remembered for **24 hours**. Reusing it with a different body is a `409 invalid_idempotent_request`, and reusing it while the first request is still running is a `409 concurrent_idempotent_requests`. A request that failed releases its key, so you can retry with the same one.

Key it on the business event, as `order-4711-receipt` does, rather than on a fresh random value — that is what stops a retried job or a redelivered webhook from sending one customer two receipts.

## Send a batch

`POST /emails/batch` takes a JSON array of messages shaped exactly like a single send, and returns the identifiers in the order the messages were given. An agent calls [`mail_email_send_batch`](/reference/mcp-tools/mail_email_send_batch).

```json
[
  { "from": "hello@mail.acme.com", "to": "a@example.com",
    "subject": "Welcome", "html": "<p>Hi A.</p>" },
  { "from": "hello@mail.acme.com", "to": "b@example.com",
    "subject": "Welcome", "html": "<p>Hi B.</p>" }
]
```

A batch holds **between 1 and 100 messages**; anything else is a `422 invalid_batch`.

Validation is all-or-nothing. Every item is checked, its sending domain resolved and its template rendered before any message is created, so one bad item leaves the whole batch uncreated. Errors that name a single item carry its position:

```json
{ "name": "invalid_recipient", "index": 1,
  "message": "invalid recipient address: nope" }
```

Two fields are not allowed on a batch item — `scheduled_at` and `attachments`:

```json
{ "name": "invalid_batch_item", "index": 0,
  "message": "scheduled_at and attachments are not allowed in batch send" }
```

Schedule messages one at a time instead.

## Send later, reschedule or cancel

1. **Schedule it.** Add `scheduled_at` to a single send and the message waits instead of going out. The value must be a full ISO-8601 instant carrying a time zone — either `Z` or an offset like `+02:00` — that lies in the future and **no more than 30 days ahead**. Loose or relative formats are not accepted, and anything outside that is `422 invalid_scheduled_at`. The message is created with status `scheduled`.

   ```json
   { "scheduled_at": "2026-08-09T09:00:00Z" }
   ```

2. **Reschedule it.** `PATCH .../emails/{id}` with a new `scheduled_at`, validated by the same rule. An agent calls [`mail_email_update`](/reference/mcp-tools/mail_email_update).

3. **Cancel it.** `POST .../emails/{id}/cancel` answers with the id and `"status": "canceled"`. An agent calls [`mail_email_cancel`](/reference/mcp-tools/mail_email_cancel).

> Rescheduling and cancelling work only while the message is still `scheduled`. Once it has gone out — or has already been cancelled — the answer is `409 not_scheduled`.

A scheduled message's content is fixed at the moment you create it: a scheduled template send renders the template as part of that request, so later changes to the template do not reach it.

## Read a message back

```bash
curl "https://public.lessly.dev/$PRODUCT_ID/mail/emails/9f1f0d9c-1c74-4a2e-9f9f-2b6d8a0b3c11" \
  -H "X-Api-Key: $LESSLY_MAIL_API_KEY"
```

```json
{
  "data": {
    "id": "9f1f0d9c-1c74-4a2e-9f9f-2b6d8a0b3c11",
    "from": "Acme <hello@mail.acme.com>",
    "to": ["customer@example.com"],
    "cc": [],
    "bcc": [],
    "subject": "Your receipt",
    "status": "delivered",
    "scheduled_at": null,
    "provider_message_id": "0100019a2f...",
    "created_at": "2026-08-02T10:15:04.221Z",
    "last_event": { "type": "email.delivered", "created_at": "2026-08-02T10:15:09.880Z" }
  },
  "success": true
}
```

`last_event` restates the current status as the event that produced it, with the time it happened. An identifier that is not a valid UUID is a `400`; one that belongs to another product — or to a domain your key is not allowed to use — is a `404`, so a read reveals nothing you may not see. An agent calls [`mail_email_get`](/reference/mcp-tools/mail_email_get).

Polling for this does not scale. [Receive delivery events](/ship/mail/webhooks) instead and be told as each one happens.

## What the statuses mean

| Status | Meaning |
|---|---|
| `scheduled` | Accepted and waiting for its send time. Can still be rescheduled or cancelled. |
| `queued` | Accepted and about to be handed to the receiving mail servers. |
| `sent` | Handed over. The receiving server has not reported an outcome yet. |
| `delivered` | The receiving server accepted the message for its recipient. |
| `bounced` | The receiving server rejected it. |
| `complained` | The recipient marked it as spam. |
| `suppressed` | Every recipient was on your suppression list, so nothing was sent. |
| `blocked` | A sending limit or your sender reputation stopped it before it went out. |
| `failed` | The message could not be handed over. |
| `canceled` | You cancelled it before it went out. |

Recipients on the suppression list are dropped from a message when it is accepted, and checked again just before it goes out — the list may have grown in between, which matters most for a message scheduled days ahead. If some recipients survive, the message goes to them; if none do, it ends as `suppressed`. See [handle bounces and protect your sending](/ship/mail/deliverability).

## Limits

| Limit | Value |
|---|---|
| Body size | A `POST /emails` request body may be up to **5 MiB**. |
| Send rate | **5 requests per second** per product, over a sliding one-second window. Reading a message and cancelling one do not consume this budget. |
| Public endpoint rate | An additional **100 requests per minute** per client IP address. |
| Recipients | At most **50** across `to`, `cc` and `bcc` per message. |
| Batch size | **1 to 100** messages. |
| Schedule horizon | At most **30 days** ahead. |

Every send response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`; a rejected one adds `Retry-After` and returns `429 rate_limit_exceeded`.

Beyond validation, a send can be refused with `429` for three further reasons, each with its own code in the error envelope:

| Code | Meaning |
|---|---|
| `quota_exceeded` | Your plan's volume allowance is used up. |
| `reputation_throttled` | Your bounce or complaint rate has crossed its threshold. |
| `billing_limit_exceeded` | Your billing allowance would not cover the request. |

```json
{ "data": null, "success": false,
  "error": { "code": "reputation_throttled", "message": "..." } }
```

A `401 restricted_api_key` means the key you used is bound to a single domain and the message was addressed from a different one.

## Next steps

- [Send from a template](/ship/mail/templates): move the wording out of your sending code.
- [Send from TypeScript](/ship/mail/sdk): the same calls with typed errors and retries.
- [Receive delivery events](/ship/mail/webhooks): stop polling for the status.
- [Handle bounces and protect your sending](/ship/mail/deliverability): what `suppressed` and `blocked` mean for you.
