Send a message
Create one email or a batch, schedule or cancel it, read its status back, and know the limits a send is measured against.
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 of your product, and the key goes in the X-Api-Key header.
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:
{ "data": { "id": "9f1f0d9c-1c74-4a2e-9f9f-2b6d8a0b3c11" }, "success": true }An agent calls mail_email_send; over REST it is POST /mail/emails on the Mail API.
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:
{ "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.
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.
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.
[
{ "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:
{ "name": "invalid_recipient", "index": 1,
"message": "invalid recipient address: nope" }Two fields are not allowed on a batch item — scheduled_at and attachments:
{ "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
-
Schedule it. Add
scheduled_atto a single send and the message waits instead of going out. The value must be a full ISO-8601 instant carrying a time zone — eitherZor 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 is422 invalid_scheduled_at. The message is created with statusscheduled.{ "scheduled_at": "2026-08-09T09:00:00Z" } -
Reschedule it.
PATCH .../emails/{id}with a newscheduled_at, validated by the same rule. An agent callsmail_email_update. -
Cancel it.
POST .../emails/{id}/cancelanswers with the id and"status": "canceled". An agent callsmail_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 is409 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
curl "https://public.lessly.dev/$PRODUCT_ID/mail/emails/9f1f0d9c-1c74-4a2e-9f9f-2b6d8a0b3c11" \
-H "X-Api-Key: $LESSLY_MAIL_API_KEY"{
"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.
Polling for this does not scale. Receive delivery events 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.
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. |
{ "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: move the wording out of your sending code.
- Send from TypeScript: the same calls with typed errors and retries.
- Receive delivery events: stop polling for the status.
- Handle bounces and protect your sending: what
suppressedandblockedmean for you.