Single Email
Send a single email via the API
Send a single email with HTML/text content, optional attachments, custom headers, and scheduled delivery.
The From domain must be verified by this project
A send is refused with a 400 unless the domain in from has been verified under
Domains
by the project making the request. That holds for every surface —
this endpoint, templates, batches, campaigns, and the SMTP relay — and it is checked again at delivery, so a scheduled
message will not go out on a domain that was unverified while it waited.
The project comparison is the point. Domain names are globally unique across an install, so without it any project could
put a neighbour’s domain in from. A domain nobody has verified and a domain another project owns give the same
message, which is deliberate: the answer must not reveal which.
A verified domain covers its subdomains: verify example.com and
news@mail.example.com sends. Whoever controls a zone controls every name under it. Matching is by whole labels, so
evilexample.com is not covered.
Subdomain mail is DKIM-signed with the verified ancestor’s key, meaning
d=example.com. That aligns under DMARC’s default relaxed policy. If you publish
adkim=s or aspf=s, verify the subdomain separately so it gets a key of its own.
Machine API schema
The /api/v1 surface describes itself: run
mailyard export-api-spec
and feed the document to Swagger UI, Postman,
or a client generator.
Example
curl -X POST http://localhost:3000/api/v1/emails/send \
-H "Authorization: Bearer myk_..." \
-H "Content-Type: application/json" \
-d '{
"from": "billing@example.com",
"to": ["jane@customer.example"],
"subject": "Receipt for order 4471",
"html": "<p>Thanks, Jane. Your receipt is below.</p>",
"text": "Thanks, Jane. Your receipt is below.",
"headers": {
"X-Order-Ref": "4471"
},
"list_unsubscribe_url": "https://example.com/mail/stop",
"list_unsubscribe_post": true
}'
What is required
Four things, and the request is refused with a 400 naming the one that is wrong:
from, a parseable address whose domain this project has verifiedto, at least one parseable address, within the installation’s recipient ceilingsubject, non-empty- a body —
htmlortext, or both
Who sees whom
Every address in to and cc is written into the message headers, so every recipient sees every other one. A
bcc address receives the message and appears in no header at all. All three lists count towards the recipient
ceiling, and the same mailbox named twice is delivered once.
{
"to": [
"jane@customer.example"
],
"cc": [
"account-manager@yourapp.example"
],
"bcc": [
"archive@yourapp.example"
]
}
to is required even when bcc carries the real audience, because a message with an empty To header scores badly
with spam filters. Put your own address in to and the audience in bcc, or send one message per recipient - that
is what /emails/batch is for.
Sending both parts is worth the extra field. Clients that cannot render HTML fall back to the text part, and a message with no text alternative scores worse with spam filters than one that has it.
Optional fields
| Field | Does |
|---|---|
cc, bcc | The other two recipient lists - see Who sees whom |
reply_to | Where a reply lands when it should not go back to from. Any parseable address, verified or not |
headers | Up to 20 custom headers, written over the project’s defaults - see below |
tags | Up to 10 labels of 64 characters. The log
filters by ?tag= |
metadata | Up to 20 string pairs - your order id, your tenant - read back on the record and never interpreted |
attachments | Base64 files — see Attachments |
send_at | Hold until an RFC 3339 time — see Scheduled Email |
dry_run | Run every validation and persist nothing |
disable_tracking | Opt this message out of open and click tracking |
unsubscribe_list_id | Send under a transactional opt-out scope
, which is also what {{ mailyard_unsubscribe_url }} links to |
list_unsubscribe_url, list_unsubscribe_mailto, list_unsubscribe_post | Carry your own opt-out targets. One of the two arrangements is part of what Gmail and Yahoo require of a domain sending at volume |
smtp_group, smtp_server_id | Pin the route out |
sandbox, sandbox_retention_days | Capture instead of delivering — see Sandbox |
The subject, html and text are sent as written with one exception: the reserved
system variables
{{ mailyard_web_view_url }}, {{ mailyard_mail_web_link }} and
{{ mailyard_unsubscribe_url }} become this message’s links, or are removed when the message has none. Any other
{{ ... }} is left alone.
dry_run is the cheapest way to check an integration: it validates the sender, the recipients, the headers, the
attachment sizes and the routing, checks the plan’s hourly and daily limits and the suppression list, then returns
without writing a row or spending quota. A send the real request would refuse is refused by the dry run too, with the
same status - 429 when the plan’s limit is reached.
Reserved headers
Anything the message builder owns is refused rather than merged, so a caller cannot forge the envelope or break the
MIME structure: From, To, Cc, Bcc, Reply-To, Subject, Date, MIME-Version, List-Unsubscribe,
List-Unsubscribe-Post, Return-Path, Message-ID, Received, DKIM-Signature, every Content-* header, and
everything under the X-Mailyard- prefix, which is Mailyard’s own control namespace. More are refused because they
let a sender speak for somebody else or redirect mail: Sender, every Resent-* header, Errors-To, and the two
read-receipt requests Disposition-Notification-To and Return-Receipt-To, which ask the recipient’s client to mail
an address of the caller’s choosing.
The refusal names the header and, where there is one, the field to use instead — List-Unsubscribe points you at
list_unsubscribe_url, Reply-To at reply_to, Cc and Bcc at cc and bcc. Matching is case-insensitive. A
name has to be a plain RFC 5322 field name (printable ASCII, no spaces, no colon, at most 128 characters), and a value
carrying a line break or any other control character is refused outright, which is what stops header injection through
a value you interpolated. A value is capped at 4096 characters, room enough for a References chain. The same name
twice in one set, in two spellings such as X-Tag and x-tag, is refused too. A long value is folded onto
continuation lines and a non-ASCII one is written as RFC 2047 encoded words, so what leaves is a valid RFC 5322
header.
Three layers of headers
A message’s headers are laid over two other sets, and the nearest one wins, compared by name without regard to case:
- The project’s default headers, set under project settings . They go on every message the project sends - API, console, SMTP submission and campaigns alike.
- A campaign’s headers, on every message of that campaign.
- The message’s own
headers.
So a project default of X-Env: staging is what goes out until a message says x-env: prod, and then only the
message’s spelling is written. Each layer may hold twenty headers of its own, and each is checked against the reserved
set when it is saved.
Sending once
A client that times out waiting for the 201 does not know whether the message was queued. Retrying blindly sends it
twice. Send an Idempotency-Key header instead, any string of up to 255 characters you mint per message, and a
retry carrying the same key is answered with the message the first request queued:
curl -X POST https://mail.example.com/api/v1/emails/send \
-H "Authorization: Bearer myk_..." \
-H "Idempotency-Key: order-10457-receipt" \
-H "Content-Type: application/json" \
-d '{ ... }'
| Outcome | Status | Body |
|---|---|---|
| First request | 201 | The queued message |
| A retry after the first completed | 200 | The same message, with replayed: true and an empty suppressed_recipients |
| A duplicate while the first is still running | 409 | An error naming the key |
| A retry from a caller that may not read it | 409 | An error naming the key |
| A retry after the first was refused | Whatever the corrected request earns | The key is released by a failure |
A key whose first request never finished - the process stopped mid-send - is held for five minutes at most, after
which the next request carrying it is treated as the first. A replay hands the message back only to a caller holding
emails:read, and never to a sandbox credential, since the key is shared by the whole project. The key is scoped to the project and forgotten after a
day. It applies to /emails/send and /emails/send-template.
A batch is its own unit and does not take one.
What comes back
201, with the stored record and anything that was dropped:
{
"email": {
"id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
"status": "queued",
"recipients": [
"jane@customer.example"
],
"subject": "Receipt for order 4471"
},
"suppressed_recipients": []
}
Read suppressed_recipients. Addresses on the project’s
suppression list
are removed before sending and named here. The call still succeeds —
suppression is a standing instruction, not an error — so an address silently missing from a delivery is usually here.
If every recipient was suppressed, the row is still created, with status suppressed and an error message saying
so. Nothing leaves the building.
After it is accepted
The message is queued and a worker picks it up, normally within a second. Follow it with
the status route
, which also explains what each state means and why sent is not
the same as delivered.