Skip to content

Mail

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

SurfaceAuthenticates withCarries
Public sending endpoint (Recommended) for application codeAn lmk_ key in X-Api-KeyPOST /emails and GET /emails/{id}
Mail API, for provisioning and operationsYour product’s authenticated sessionEverything, 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

FieldRequiredAccepts
fromYesyou@your-domain.com or Name <you@your-domain.com>
toSee belowOne address, or an array of addresses
ccNoOne address, or an array of addresses
bccNoOne address, or an array of addresses
reply_toNoOne address, or an array of addresses
subjectYes, for an inline bodyAny non-empty string
htmlOne of html/textThe HTML body
textOne of html/textThe plain-text body
headersNoAn object of header name to value
tagsNoAn array of { "name": ..., "value": ... }
templateNo{ "id": ..., "variables": { ... } }
scheduled_atNoAn 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

  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.

    { "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.

  3. Cancel it. POST .../emails/{id}/cancel answers with the id and "status": "canceled". An agent calls 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

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

StatusMeaning
scheduledAccepted and waiting for its send time. Can still be rescheduled or cancelled.
queuedAccepted and about to be handed to the receiving mail servers.
sentHanded over. The receiving server has not reported an outcome yet.
deliveredThe receiving server accepted the message for its recipient.
bouncedThe receiving server rejected it.
complainedThe recipient marked it as spam.
suppressedEvery recipient was on your suppression list, so nothing was sent.
blockedA sending limit or your sender reputation stopped it before it went out.
failedThe message could not be handed over.
canceledYou 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

LimitValue
Body sizeA POST /emails request body may be up to 5 MiB.
Send rate5 requests per second per product, over a sliding one-second window. Reading a message and cancelling one do not consume this budget.
Public endpoint rateAn additional 100 requests per minute per client IP address.
RecipientsAt most 50 across to, cc and bcc per message.
Batch size1 to 100 messages.
Schedule horizonAt 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:

CodeMeaning
quota_exceededYour plan’s volume allowance is used up.
reputation_throttledYour bounce or complaint rate has crossed its threshold.
billing_limit_exceededYour 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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect