API reference
Everything your dashboard does with domains, aliases, rules, sending and logs, you can also do over a REST API.
Your first request
Create an API key on your dashboard's Account page, under
API keys. Send it with every request as a Bearer token in
the Authorization header. Every path starts with
https://api.mailyoto.com/v1/, and requests and responses are
JSON.
curl https://api.mailyoto.com/v1/domains \
-H "Authorization: Bearer $API_KEY"
The response is the first page of your domains (some fields left out):
{
"data": [
{
"domain": "lunavera.com",
"receiving_ready": true,
"sending_ready": false,
"notification_email": null,
"created_at": "2026-10-02T08:14:51Z"
}
],
"has_more": false,
"next_cursor": null
}
A missing, wrong, revoked or expired key returns 401. Your
password, two-step verification and the API keys themselves change only
from the dashboard, so a key sent to those endpoints returns
403. Keep keys out of code that runs in a browser, and revoke a
key from the dashboard as soon as you think it leaked.
The last 3 sections, Requests and responses, Request limits and Errors, apply to every endpoint.
Domains
POST /v1/domains adds a domain and GET /v1/domains
lists them. GET, PUT and DELETE on
/v1/domains/<domain> read, change and remove one. A
delete returns 204.
curl -X POST https://api.mailyoto.com/v1/domains \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "lunavera.com", "catch_all": false}'
| Parameter | In | Notes |
|---|---|---|
domain |
body | Required to add a domain. The name alone, with no https://. |
catch_all |
body |
When you add a domain. true gives it a catch-all to your
account email. false starts it without one, so it takes
email only at the aliases you create. Left out, the domain gets a
catch-all.
|
notification_email |
body | When you add or change a domain. Alerts about the domain go here. Left out, they go to your account email. |
mx_strict |
body |
When you change a domain. false lets other MX records
stay beside Mailyoto's (the dashboard's Allow backup MX servers).
|
role_mail_to_owner |
body |
When you change a domain. true (the default) forwards
postmaster@ and abuse@ to your account email when no alias takes
them. false leaves them to your aliases.
|
query |
query | List only. Matches part of a domain name. |
receiving_ready / sending_ready |
query |
List only. true or false, by whether the
domain's DNS is set up for that. Left out, the list holds both.
|
order |
query |
List only. name_asc (the default), name_desc,
newest or oldest.
|
The list pages by cursor, like every list (see
Requests and responses). A page carries no total:
GET /v1/domains/count takes the same filters and returns
{"total": 12}.
Check the DNS records
POST /v1/domains/<domain>/dns-check looks up the domain's
DNS now and compares it with each record Mailyoto needs. It returns
200 with dns_check whether or not the records are
in place. Once they are, the domain starts forwarding.
| Field | What it holds |
|---|---|
receiving_ready |
true when every record needed to receive mail is in place. |
records |
One item per record: record (mx,
spf, dkim_1, dkim_2 or
dmarc), its type and host, the
values to publish in expected, the values DNS holds now in
found, and ok.
|
records[].problem |
Why a record isn't right, when we can tell. mx_priority_mismatch:
our MX hosts are there at other priorities (the first takes 10, the
second 20). spf_several_records: the domain has 2 SPF
records. Merge them into one. spf_invalid: the SPF record
can't be read. spf_lookup_failed and
spf_check_failed: the SPF check didn't finish; check
again in a few minutes. Otherwise null.
|
spf_lookups |
used is how many DNS lookups the domain's SPF record
needs, and limit is the 10 that RFC 7208 allows. Past the
limit, receiving servers treat the record as broken. null
when the domain has no single SPF record, or a lookup timed out.
|
dmarc_policy |
What the domain's DMARC record asks receiving servers to do with email
that fails DMARC: policy (none,
quarantine or reject),
subdomain_policy (its sp, else the policy) and
percent (its pct, else 100). null
without a record that sets a policy.
|
lookup_error |
Why DNS gave nothing to compare, such as a domain with no MX records. Otherwise null. |
dns_host / dns_host_guide |
The company that runs the domain's DNS, and a link to our setup guide
for it when we have one. Either can be null.
|
checked_at |
When the lookup ran. |
A domain with a white-label host has CNAME records of its own.
GET /v1/domains/<domain>/whitelabel-dns lists them in
whitelabel_dns.records, each with expected,
found and ok. The list is empty for a domain
without one.
Domains another account holds
When another account holds the domain, POST /v1/domains
returns 409 with the code domain_in_use. To move it
to your account, send the domain to
POST /v1/domains/claims. The response names the TXT record to
publish at the domain's DNS provider, and when the claim expires, 7 days later.
The holder gets an email with no link. Asking again returns the same claim.
{
"claim": {
"domain": "lunavera.com",
"expires": "2026-10-31T09:30:00Z",
"record": {
"name": "_mailyoto-claim.lunavera.com",
"type": "TXT",
"value": "mailyoto-claim=k3v9q2wmxf7tpa4hd5ny6bzr"
},
"status": "waiting"
}
}
POST /v1/domains/claims/<domain>/check looks up the
record and moves the domain once it's published: status
changes from waiting to moved. We also keep
checking until the claim expires. The moved domain has a catch-all and none
of the holder's aliases, rules or logs. A domain accepts 3 new claims a
day. An account can have 5 claims waiting. Above either limit, a new claim returns
409.
Blocked destinations
When a destination refuses mail, we stop forwarding to it for a while, so
its server doesn't start refusing all our mail.
GET /v1/domains/<domain>/blocked-destinations lists the
domain's blocks that still hold, newest first:
{
"data": [
{
"id": 5307,
"destination": "diego.ramos@harborpine.net",
"reply": "452 4.2.2 Mailbox full",
"source": "reply",
"blocked_at": "2026-10-24T16:02:11Z",
"lifts_at": "2026-10-27T16:02:11Z",
"alias_ids": [8412],
"rule_ids": []
}
],
"has_more": false,
"next_cursor": null
}
reply is what the destination's server responded.
lifts_at is when a temporary block ends by itself. It is
null for a permanent one, which holds until you remove it.
alias_ids and rule_ids name what forwards to the
destination. source is reply, or
support for a block our support team added, which only they can
lift.
POST .../blocked-destinations/<id>/delivery-test sends a
temporarily blocked destination a test message now and returns
202. The block lifts once the destination accepts it, usually
within a minute. It returns 409 for a permanent or support
block, and while a test of the same address is on its way.
DELETE .../blocked-destinations/<id> lifts one block
(204), and DELETE .../blocked-destinations lifts
all of them except support's and returns how many, as
{"removed": 3}.
Webhook signing secret
GET /v1/domains/<domain>/webhook-secret returns the
secret that signs the domain's webhook requests, and
POST /v1/domains/<domain>/webhook-secret/rotate replaces
it. Both need a Pro or Max plan, and return 402 on Free. The
webhooks guide shows
how to verify a signature.
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/webhook-secret/rotate \
-H "Authorization: Bearer $API_KEY"
{
"secret": "882ada31f2978261c6bf3c774e9bb0de62bd7f00f59cb7bae766d04b503c4fcf",
"previous_secret_valid_until": "2026-10-15T20:00:00Z"
}
secret is the key your endpoint verifies signatures with.
previous_secret_valid_until is set for the 24 hours after a
rotation: until then, each request carries a second signature from the
secret it replaced. Outside a rotation it is null.
Aliases and rules
An alias sends the mail for one address, or for a pattern of addresses, to
a list of destinations. Rules match on the message itself and
run before the aliases. Both live under the domain.
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/aliases \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"alias": "hello", "destinations": [{"type": "email", "address": "amina.kone@gmail.com"}]}'
The response, shortened:
{
"alias": {
"alias": "hello",
"destinations": [{"type": "email", "address": "amina.kone@gmail.com"}],
"enabled": true,
"id": 8412
}
}
Read, change or delete one alias by its numeric id at
/v1/domains/<domain>/aliases/<id>. A delete returns
204.
| Parameter | In | Notes |
|---|---|---|
alias / destinations |
body |
Both required on create; send either one on update.
alias is the part before the @, or a pattern
where each * stands for one or more characters, up to 3.
* alone is the catch-all.
destinations lists
{"type": "email" | "webhook", "address": ...}
objects, up to your plan's limit. A webhook address is an
http(s) URL. An address can name {1} to
{3}, which take the text each * stood for.
How patterns match.
|
enabled / paused_handling |
body |
Update only. Both are returned on every alias. Send
enabled: false to pause the alias and true to
resume it. paused_handling sets what happens to its email while
it is paused: bounce (the default) refuses it,
defer asks the sender to retry later, and
drop accepts and discards it.
|
important_only |
body |
Update only. Returned on every alias. Send true
to drop bulk mail (mailing-list headers, or Precedence: bulk
or list) unless its subject names a receipt, an order, an
invoice, a password, a code or a verification. Each dropped message
is logged as filtered. Defaults to false.
How it decides.
|
reply_from_alias |
body |
Update only. Returned on every alias. Defaults to
false. Send true on Pro and Max, and
forwarded mail carries a reply address, so a response leaves from the
alias and counts toward the monthly sending allowance. A reply address
reaches only the person who emailed the alias. Always
false on Free, where sending true returns
400. How replies work.
|
query |
query | List only. Matches part of an alias or of one of its destinations. |
order |
query |
List only. name_asc (the default), name_desc,
newest or oldest.
GET .../aliases/count takes the same query.
|
Many aliases at once
3 requests change a whole set of a domain's aliases. Each accepts as many aliases as your plan allows on one domain. One request covers a whole domain:
| Request | Body | Response |
|---|---|---|
POST .../aliases/import |
{"aliases": [...]}, each as a single create takes it |
imported and failed |
PATCH .../aliases |
{"aliases": [{"id": 8412, ...}]}, with the fields to change |
updated and failed |
DELETE .../aliases |
{"ids": [8412, 8413]} |
deleted (the ids) and failed |
We take or refuse each entry on its own. A refused entry in
failed names the alias (on import) or the
id, with a detail and the same
errors list a 400 carries:
{
"failed": [
{
"alias": "sales",
"detail": "This alias already exists.",
"errors": [{"field": "alias", "detail": "This alias already exists."}]
}
],
"imported": [ ... ]
}
An import that would take the domain past your plan's aliases adds none,
and returns 402 with plan_limit_reached. There is
no request that deletes every alias: list them, then delete them by id.
Rules
/v1/domains/<domain>/rules lists, adds, changes and
deletes rules. Each rule has a position: the rule at 1 runs
first, and positions have no gaps. A new rule goes last unless you send a
position. A PUT with a position moves
the rule in one request. The rules in between shift by one, and a number
past the end puts it last.
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/rules \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"type": "condition", "config": {"field": "subject", "operator": "contains", "value": "invoice"}, "action": "forward", "destinations": [{"type": "email", "address": "mei.lin@tasteofmira.shop"}], "position": 1}'
| Field | Notes |
|---|---|
type / config |
A condition takes field (subject,
body, from, or to: the address at
your domain), operator (contains,
starts_with, ends_with, is or
matches) and value. The response adds
pattern, what our servers search the field for. An
expression takes expression, which reads
subject, body, from,
to, cc, reply_to and
list_id and gives true or false.
|
action / destinations |
forward sends a match to destinations,
drop discards it without a reply, and
reject refuses it permanently. Only forward
takes destinations.
|
active |
false keeps the rule in its place without running it. |
order |
A query parameter of the list: position_asc (the
default) or position_desc.
|
A rule's id is a string: rule_ and 16 letters and
digits, or your own id in letters, digits, - and _
when you send one on create. A delete returns 204.
How rules and aliases work together:
Routing rules.
Blocked senders
You block one sender from one alias with the unsubscribe link on
the email the alias forwards. List an alias's blocked senders, newest
first, at GET /v1/domains/<domain>/aliases/<id>/blocked-senders:
{"blocked_senders": [{"id": 31, "sender": "deals@tasteofmira.shop", "created_at": "2026-10-14T09:30:05Z"}]}
sender is the address in the From line of the blocked email.
DELETE .../blocked-senders/<id> unblocks it and returns
204. The sender's email to the alias arrives again from then on.
How to block a sender: Block one sender.
Send
POST /v1/domains/<domain>/outbound sends one email
from an address on that domain. The whole request is a JSON body; the
domain is the only path parameter, and there are no query parameters.
2 things must be true first. The account is on Pro or Max, and the
domain's sending DNS is verified. A Free account gets 403 with
"Sending through the API is available on the Pro and Max plans. Upgrade your account to use it."
and an unverified domain gets 403 with a link to its DNS page.
Request body
Every address is an object: email, plus an optional
name that mail apps show beside it.
| Name | Type | Required | Notes |
|---|---|---|---|
from |
address | Yes |
An address on the domain in the URL, such as
{"email": "hello@lunavera.com", "name": "Luna Vera"}.
An address on any other domain is a 400.
|
to |
array of addresses | At least 1 recipient across to, cc and bcc |
Goes into the To header. |
cc |
array of addresses | No |
Goes into the Cc header, so everyone on the message
sees these addresses.
|
bcc |
array of addresses | No | Delivered without a header. See below. |
reply_to |
address | No | Set as the Reply-To header. |
subject |
string | Yes, unless the template has one | Sent with a template, it replaces the template's own subject. |
text_body / html_body |
string | One of the two, unless template is set |
Send both and the message goes out as multipart/alternative.
|
template |
object | No |
name picks a template saved on this domain, and
data holds the values for its Mustache placeholders.
A template and a body in one request are a 400; an
unknown name is a 404. See
Templates.
|
attachments |
array of objects | No | See Attachments below. |
metadata |
object | No |
Your own references, such as an order id, as string values. Keys are
1 to 40 letters, digits, _ or -. The log
entry keeps them. A wrong key or value is a 400 that
names it, such as metadata.order_id.
|
A field the request doesn't define is a 400 that names it,
at any depth, for example to[0].mail.
Attachments
Each entry in attachments is an object with these fields:
| Name | Type | Required | Notes |
|---|---|---|---|
filename |
string | Yes |
The name the recipient sees. We refuse every type our mail server
refuses (programs, scripts, disk images such as .iso,
OneNote files) plus scripts such as .py. A trailing dot
doesn't help: report.exe. counts as .exe.
|
content_base64 |
string | Yes |
The file, base64-encoded. Strictly validated: padding and stray
characters are a 400.
|
mime_type |
string | No |
Omit it and we guess from the filename, falling back to application/octet-stream.
It must look like a MIME type.
|
inline_id |
string | No |
Shows the file inside the message instead of beside it, where your
HTML says cid: followed by this id. Use it for images.
|
Limits
Four limits come from the account's plan. An email over one of them
gets 402 with plan_limit_reached, and the
limit member names the one it crossed:
| Limit | Default | limit.key |
|---|---|---|
Recipients per email, across to, cc and bcc |
50 | max_send_recipients |
| Files per email | 20 | max_send_attachments |
| Size of the built email, in bytes | 20 MB | max_send_message_bytes |
Size of metadata as compact JSON, in bytes |
2048 | max_send_metadata_bytes |
The size counts the email as it leaves us: headers, bodies and every attachment after base64, which makes a file about a third larger. A 12 MB file fits. A 16 MB file doesn't.
{
"code": "plan_limit_reached",
"detail": "This email comes to 21.9 MB once encoded. Your account sends up to 20 MB per email.",
"errors": [],
"limit": {"key": "max_send_message_bytes", "max": 20971520, "used": 22959020},
"status": 402,
"title": "Plan limit reached",
"type": "https://mailyoto.com/api/#error-plan_limit_reached"
}
Recipients, BCC, and headers
All three lists are delivered. The difference is visibility:
to and cc become headers on the message, and
bcc never does, so a blind copy stays blind for every other
recipient. An email can go to bcc alone; it then has no
To header.
An address listed more than once, in to and in
bcc for example, gets one copy. The response's
recipients lists each address that gets one.
We build the message's headers for you: From with its
display name, To, Cc, Subject,
Date, Message-ID, and Reply-To
when you ask for it. There is no field for adding your own headers, so a
headers field is a 400. reply_to is
the one header you control.
Retrying safely
Send an Idempotency-Key header with your own id for the email,
such as a UUID: 1 to 200 visible ASCII characters. Keys belong to your
account.
For 48 hours, the same request with the same key returns the first send's
202 and sends nothing. That response carries
Idempotency-Replayed: true.
A repeat while we still handle the first request gets 409
with conflict: retry a few seconds later. The same key with a
different email gets 422 with unprocessable. A
key outside the format gets 400, with
Idempotency-Key in errors[0].field.
If we refuse a send or fail to queue it, we don't store its key. A corrected retry with the same key is sent.
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/outbound \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: 3f0c9a8e-5d2b-4c71-9e40-7a1b2c3d4e5f" \
-H "Content-Type: application/json" \
-d '{
"from": {"email": "orders@lunavera.com", "name": "Luna Vera"},
"to": [{"email": "amina.kone@gmail.com"}],
"subject": "Your order A-1042 is ready",
"text_body": "Hello Amina, your order is ready to collect.",
"metadata": {"order_id": "A-1042"}
}'
Examples
A plain-text message:
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/outbound \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": {"email": "hello@lunavera.com", "name": "Luna Vera"},
"to": [{"email": "amina.kone@gmail.com", "name": "Amina Koné"}],
"subject": "Your order is on its way",
"text_body": "Hello Amina, your parcel left our workshop this morning."
}'
A saved template instead of a body. This template carries its own
subject, so the request leaves subject out, and each key in
data fills the placeholder that shares its name:
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/outbound \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": {"email": "hello@lunavera.com", "name": "Luna Vera"},
"to": [{"email": "amina.kone@gmail.com"}],
"template": {
"name": "shipment-update",
"data": {"customer": "Amina", "tracking_code": "LV-88214"}
}
}'
A message with a blind copy, a reply address, and one PDF attached:
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/outbound \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": {"email": "billing@lunavera.com", "name": "Luna Vera billing"},
"to": [{"email": "diego.ramos@harborpine.net", "name": "Diego Ramos"}],
"bcc": [{"email": "records@lunavera.com"}],
"reply_to": {"email": "billing@lunavera.com"},
"subject": "Invoice 2026-114",
"html_body": "<p>Your invoice is attached.</p>",
"attachments": [
{
"filename": "invoice-2026-114.pdf",
"content_base64": "JVBERi0xLjQKJcfsj6IKNSAwIG9iago8PC9MZW5",
"mime_type": "application/pdf"
}
]
}'
An image that appears inside the message rather than beside it. Give the
attachment an inline_id, then point the HTML at that same id
with cid:. Put real base64 in content_base64; the
short string below is a one-pixel PNG, so this request runs as written:
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/outbound \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": {"email": "billing@lunavera.com"},
"to": [{"email": "diego.ramos@harborpine.net"}],
"subject": "Your March statement",
"html_body": "<p>Spend by month:</p><img src=\"cid:summary-chart\" alt=\"Bar chart of monthly spend\">",
"attachments": [
{
"filename": "summary-chart.png",
"content_base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"mime_type": "image/png",
"inline_id": "summary-chart"
}
]
}'
The part goes out with Content-Disposition: inline instead of
attachment, so a mail client that understands
cid: draws the image in place. Always set an
alt on the image: clients that block remote and embedded
images show that text instead.
Responses
A queued email returns 202 Accepted. Treat both identifiers as
opaque strings:
{
"logs": [
{"recipient": "diego.ramos@harborpine.net", "log_id": "log_01m5pmb2zr7k4w9e6t3x8q1v0d"},
{"recipient": "records@lunavera.com", "log_id": "log_01m5pmb2zs3h6n0c4w8e2r5t7y"}
],
"message_id": "<176248359512.42.9182734650@lunavera.com>",
"status": "accepted",
"recipients": ["diego.ramos@harborpine.net", "records@lunavera.com"]
}
logs names each recipient's own log entry: look a recipient up
in the logs by its log_id to see whether the email reached it.
status is each entry's first status, accepted, and moves on to
delivered, deferred or failed in the
logs. message_id is the RFC 5322 header the recipient's mail
server sees, and matches the message_id field in the logs.
recipients lists every address that gets a copy.
Failures on this endpoint use the same problem-details body as every
other endpoint. A field at fault is named in errors:
{
"code": "validation_failed",
"detail": "We block .exe attachments. Remove report.exe.",
"errors": [{"field": "attachments[0].filename", "detail": "We block .exe attachments. Remove report.exe."}],
"status": 400,
"title": "Some fields need changes",
"type": "https://mailyoto.com/api/#error-validation_failed"
}
| Status | When |
|---|---|
400 |
A field is missing, unusable or unknown: no from, an
address on another domain or not valid, no recipient, no subject, no
body, a body sent with a template, or an attachment of a blocked type
or with bad base64. errors names the field.
|
402 |
The email is over one of the limits above. limit names
it, with its max and what this email used.
|
403 |
The plan cannot send, or the domain's sending DNS is not verified yet. |
404 |
The domain is not on this account, or template.name names a
template this domain does not have.
|
409 |
We are still handling the first request with this
Idempotency-Key. Retry a few seconds later for its response.
|
422 |
You already used this Idempotency-Key for a different
email.
|
429 |
Rate limited. Wait the number of seconds in Retry-After, then send again.
|
500 |
We could not queue the message. Nothing went out, so you can retry
the same request, with the same Idempotency-Key.
|
Templates
A template is a saved subject and body with Mustache placeholders, such as
{{customer}}. A send names it in template.name and
fills its placeholders from template.data. Each domain keeps its
own templates.
Saving, changing and previewing a template need the Pro or Max plan. On the
Free plan they return 402 with paid_plan_required.
Listing, reading and deleting work on every plan.
| Request | What it does |
|---|---|
GET /v1/domains/<domain>/templates |
Lists the templates without their bodies, paged by cursor.
order is name (the default) or
updated, last changed first.
|
POST /v1/domains/<domain>/templates |
Saves a template. Returns 201 with the template, and its
URL in Location.
|
GET /v1/domains/<domain>/templates/<name> |
Returns one template with its bodies. |
PATCH /v1/domains/<domain>/templates/<name> |
Changes the fields you send, name included. Fields you
leave out stay as they are.
|
DELETE /v1/domains/<domain>/templates/<name> |
Deletes the template and returns 204. A send that names it
afterwards gets 404.
|
POST /v1/domains/<domain>/templates/<name>/preview |
Renders the template with your data, and saves and sends nothing. |
Fields
| Name | Type | Required | Notes |
|---|---|---|---|
name |
string | Yes | Unique on the domain. See the name rule below. |
subject |
string | No | Up to 200 characters. A send's own subject replaces it. |
text_body / html_body |
string | At least one of the two |
Only html_body escapes HTML in the values it fills in.
{{{value}}} keeps a value as it is.
|
note |
string | No | Up to 250 characters, for yourself. Sends never use it. |
A field the endpoint doesn't define is a 400 that names it.
In a PATCH, null clears note,
subject or a body. One body must remain.
The template comes back with two more fields. data_keys lists
the data keys it reads. Tags inside a {{#section}}
aren't listed, because each item can fill them. size_bytes is
its size as the limit below counts it.
Names
A name has up to 64 characters: lowercase letters and digits, joined by single dots, hyphens or underscores. It starts with a letter and ends with a letter or digit.
order.shipped,welcome-v2andreset_passwordwork.Order,1st-reminder,welcome-anda..bdon't.
Limits
Both limits come from the account's plan. A template over one of them gets
402 with plan_limit_reached, and the
limit member names the one it crossed:
| Limit | Default | limit.key |
|---|---|---|
| Templates per domain | 250 | max_templates |
| Size of one template: the subject and both bodies, in bytes | 256 KB | max_template_bytes |
Preview
Send data as a send would carry it in
template.data. To try a change before you save it, add
subject, text_body or html_body; the
saved template stays as it is.
curl -X POST https://api.mailyoto.com/v1/domains/lunavera.com/templates/booking.confirmed/preview \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"data": {"customer": "Amina & Co"}}'
{
"preview": {
"subject": "Booking is confirmed",
"text_body": "Hello Amina & Co, booking is confirmed.",
"html_body": "<p>Hello Amina & Co, booking <b></b> is confirmed.</p>",
"data_keys": ["code", "customer"],
"missing_data_keys": ["code"]
}
}
missing_data_keys lists the keys a tag read that
data lacks. Each one rendered empty, as it would in a send.
The subject comes out on one line, as a send puts it in its header.
Responses
| Status | When |
|---|---|
400 |
A field is missing, unusable or unknown: no name, a name outside the
rule, no body, or broken Mustache. errors names the field.
|
402 |
The template is over one of the limits above, or the plan can't save templates. |
404 |
The domain is not on this account, or it has no template with this name. |
409 |
The domain already has a template with this name. |
SMTP credentials
To send from a mail app or a server instead of this API, create an SMTP
login with POST /v1/domains/<domain>/credentials. Each
credential has a numeric id:
GET, PATCH and DELETE on
/v1/domains/<domain>/credentials/<id> read, change
and remove one. The list pages by cursor. We never return the password, so
keep the one you send.
Creating or reading one credential also returns smtp_settings:
the host, port, security and
login a mail app asks for.
{
"credential": {
"id": 412,
"username": "amina",
"sends_as": ["amina@lunavera.com"],
"expires_at": null,
"note": "Laptop",
"sent_this_month": 0,
"created_at": "2026-10-28T09:14:02Z"
},
"smtp_settings": {
"host": "smtp.mailyoto.com",
"port": 587,
"security": "starttls",
"login": "amina@lunavera.com"
}
}
sent_this_month counts the emails sent with the credential
since your allowance month began. On the Free plan, creating a credential
returns 402 with paid_plan_required.
| Parameter | In | Notes |
|---|---|---|
id |
path |
Names the credential for GET, PATCH and
DELETE.
|
username / password |
body | Both required on create. A password is 15 characters to 72 bytes. |
sends_as |
body | Required on create. The addresses on this domain the credential may put in From: at least 1, up to the plan's aliases per domain. The credential sends as no other address. |
expires_at |
body |
Optional. An ISO 8601 time in the future, from which SMTP sign-in
refuses the credential. null removes it.
|
note |
body | Optional. A label for your own use, up to 250 characters. |
Logs
GET /v1/domains/<domain>/logs lists the domain's logs,
newest first. Each log is one message to one destination. A forward to an
alias with three destinations is three logs, and a send to two recipients
is two. Add
query to search the subjects and addresses; results then come
most relevant first. Filters combine:
curl "https://api.mailyoto.com/v1/domains/lunavera.com/logs?query=invoice&status=delivered,deferred" \
-H "Authorization: Bearer $API_KEY"
GET /v1/domains/<domain>/logs/<id> returns one log,
by the id a send or a list gave you. Log ids look like
log_01m5pjxq9a7f2r9x0c5v8b1n6h.
| Parameter | In | Notes |
|---|---|---|
query |
query | Text to search for in the subject, addresses and metadata values. It tolerates typos, and a whole log id finds that log. |
sender / recipient / destination
|
query | A whole address, not case-sensitive: who sent it, the address on your domain it was sent to, and where it was forwarded. |
from / alias / to
|
query |
Part of an address, not case-sensitive: the sender, the address on your domain
it was sent to, and either that address or the inbox it was forwarded
to. With query, they narrow the first 1,000 matches.
|
metadata.<key> |
query |
Logs of emails sent with this value under this key in their
metadata, such as metadata.order_id=A-1042.
The whole value must match, in the same case. Add one per key to
narrow further. A key a send couldn't carry returns 400
and names it.
|
status |
query | Logs with an event in any of these statuses, separated by commas. |
latest_status |
query |
Logs whose last event has one of these statuses, so a message that was
deferred and then delivered counts only as delivered. Not with
query.
|
kind |
query |
Logs of these kinds, separated by commas: forward,
smtp_send or api_send.
|
after / before |
query | ISO 8601 times that bound the window. |
order |
query |
newest or oldest. Without
query, newest is the default; with one,
results come by relevance unless you choose.
|
Each log carries its events in order, and each event has a
status. Responses and filters use the same status names. Any other name
returns 400, and the response lists the valid ones.
| Status | Stage | Meaning |
|---|---|---|
accepted |
Arrival | We took the message in, and it is on its way to the destination. |
rejected |
Arrival |
We turned the message away on arrival, for example an unknown alias, a
failed authentication check, or a used forwarding allowance. The
event's message says which.
|
filtered |
Arrival | The alias keeps only important email, so we took the message in and dropped it. It was bulk mail whose subject names no receipt, order, invoice, password, code or verification. |
delivered |
Delivery | The destination server accepted the message. |
deferred |
Delivery | The destination turned it away for now. We retry, so expect a later event on the same log. |
failed |
Delivery | Delivery stopped permanently. There is no retry. |
returned |
Delivery |
The destination accepted the message, then sent it back with a bounce
report. The event's message carries the report's reason.
|
What a log tells you
| Field | What it holds |
|---|---|
kind |
forward for an email we forwarded,
smtp_send for one sent through an SMTP credential, and
api_send for one sent through this API.
|
events |
What happened to the message, oldest first. Each event has an
id (the log id, a dot and the event's number from 1),
created_at, status, and the server's
code and message. our_host is
our server. remote_host is the server on the other
side: the sender's when the message arrived, the destination's when
we delivered it. A webhook delivery we will try again also has
attempt (from 1) and next_try_at.
|
authentication |
What the checks found when the message reached us: one
{method, result, domain} item per check, in the order
DKIM, SPF, DMARC, ARC, with one item per DKIM signature.
method is dkim, spf,
dmarc or arc, and result a word
such as pass, fail or none.
null for mail we didn't check, such as mail you sent.
|
stored_message |
null, or {link, link_expires_at} when we kept a
copy of the message. The link opens it without an API key
for 24 hours.
|
metadata |
The object a send carried in its metadata field. Forwarded mail has {}. Find logs by one of its values with metadata.<key>. |
self_test |
true when the email came from the Gmail or Google
Workspace inbox we forwarded it to. Gmail keeps that copy out of the
inbox, so the log reads delivered while the inbox shows nothing.
|
resend_of / resent_as |
A resend names the log it resends. The original lists its resends, and the list is empty on a log that was never resent. |
Totals, export and the stored message
GET /v1/domains/log-totals counts the messages in the logs of
the domains in domains=lunavera.com,harborpine.net, or of every
domain on the account. It returns total and
by_outcome: how many ended delivered,
retrying or failed, each message counted once by
its last event. It takes the list's filters apart from
status and latest_status.
GET /v1/domains/<domain>/logs/export returns a CSV file,
one row per message, newest first, up to the newest 10,000. The list's
filters choose the rows; query isn't one of them. A file
covers at most 30 days, ending at before (now without it): an
earlier after moves up to that day.
GET /v1/domains/<domain>/logs/<id>/message returns
the copy we kept, as the file <id>.eml. With
format=json, it returns the decoded message beside its
envelope (mail_from, rcpt_to,
helo). It returns 404 when the log kept no copy or
the copy has expired.
Resend a failed forward
POST /v1/domains/<domain>/logs/<id>/resend sends a
forward that failed permanently (its last event is failed or
returned) again, to the destination of that log, while the
alias that received it still forwards there. A destination that already has
the message gets a 409. It
returns 202 with the new log id in resent_as, on
a paid plan; the Free plan gets a 402.
You can resend each log once: a second request gets a 409, as
does a resend we can't make, with the reason in detail. A
410 means we no longer keep the message. The
Privacy Policy says how long we
keep a failed forward.
curl -X POST "https://api.mailyoto.com/v1/domains/lunavera.com/logs/log_01m5pk3d7wq2e8n4t6y9b0c1zs/resend" \
-H "Authorization: Bearer $API_KEY"
Your account and receipts
GET /v1/account returns the account the key belongs to.
| Field | What it holds |
|---|---|
email |
The address you sign in with. |
two_factor_enabled |
true when sign-in also asks for an authenticator code. |
status |
{state, reason}. state is
active or locked. On a locked account,
reason is email_unconfirmed,
email_bouncing, over_domain_limit,
suspended or other.
|
billing_details |
What your receipts are made out to: name,
address, country, tax_id, and
email, where receipts go. A null email sends
them to the account email.
|
plan |
tier, title, price_usd,
cycle and the account's limits.
|
subscription |
renews_at on a paid plan. After you cancel,
ends_at is when the plan ends. Both are
null on Free.
|
GET /v1/billing/receipts lists your receipts, newest first, a
page at a time. Each receipt has a reference,
paid_at, amount_usd and a
description. A refund has a negative amount.
Its refund_of names the receipt it pays back.
pdf.link opens the receipt as a PDF without an API key until
pdf.link_expires_at, 24 hours later.
curl https://api.mailyoto.com/v1/billing/receipts \
-H "Authorization: Bearer $API_KEY"
Requests and responses
Where parameters go
Each parameter sits in one of 3 places, and the tables above name it:
| Place | How you send it |
|---|---|
| path |
Inside the URL: what is written between angle brackets, such as the
<domain> in /v1/domains/<domain>/aliases.
|
| query | After the ? in the URL: filters, order and paging. |
| body |
A JSON object, with Content-Type: application/json:
everything you create or change, and the ids of a bulk delete.
|
We ignore a query parameter we don't know. So a misspelled filter returns the list without that filter, not an error.
Ids
Aliases, SMTP credentials, blocked destinations and blocked senders have
numeric ids, sent as JSON numbers. Rules and logs have string ids; a rule id starts with
rule_ unless you chose it, and a log id starts with
log_. A log event's id is its log's id, a dot and a number. A domain goes by its name and a template by its name.
Pages
Every list returns one page: the items in data, with
has_more and next_cursor. To read the next page,
send the same request with cursor set to
next_cursor. It is null on the last page.
limit sets the page size, from 1 to 250 (50 when left out);
another number returns 400. A cursor works only with the list
and order it came from; any other returns 400 with
invalid_cursor.
curl "https://api.mailyoto.com/v1/domains/lunavera.com/aliases?limit=100&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $API_KEY"
Statuses that succeed
| Status | Meaning |
|---|---|
200 |
The response holds what you asked for, or what you changed. |
201 |
You created a domain, an alias, a rule, a credential or a template. The
response holds it, and the Location header gives its URL.
|
202 |
We took the request and finish it shortly: a send, a resend or a delivery test. Look for the result in the logs. |
204 |
Done, with no response body: deleting one item. |
Request limits
We count requests in buckets. A bucket holds a number of requests, and each request takes one out. Spent requests come back one at a time, spread evenly over the bucket's window: a bucket of 240 a minute gets one back every 1/4 of a second. So you can send a burst of the whole number at once, and then keep going at the steady rate.
| Bucket | Counts | Size |
|---|---|---|
key |
Every request an API key makes, across all endpoints | Your plan's requests per minute (below) |
endpoint |
Requests to one endpoint that has a limit of its own (below) | That limit |
account |
Sign-in attempts on one account from one address | 5 a minute |
credentials |
Requests from one IP address with a key we refused (401) |
30 per 5 minutes |
When an address runs out of credentials, we refuse its requests
before we look at their keys. Requests with a valid key never count there.
The headers
Every response names the buckets the request used, in the two headers of the
IETF draft on rate-limit headers. RateLimit-Policy gives each
bucket's size (q) and window in seconds (w).
RateLimit gives what is left (r) and the seconds
until one more request comes back (t):
RateLimit-Policy: "key";q=240;w=60, "endpoint";q=10;w=60
RateLimit: "key";r=341;t=1, "endpoint";r=0;t=4
Retry-After: 4
These headers come from a domain delete that was refused: the key still had 341
requests, but the delete endpoint had none, and its next one comes back in
4 seconds. A refused request returns 429 with
Retry-After, and costs nothing from any bucket:
{
"code": "rate_limited",
"detail": "Request limit reached. Retry after the delay in the Retry-After header.",
"errors": [],
"status": 429,
"title": "Request limit reached",
"type": "https://mailyoto.com/api/#error-rate_limited"
}
Wait the number of seconds in Retry-After, then send the request again.
To keep a long job from being refused at all, slow down when
r gets low.
Endpoints with their own limit
| Endpoint | Limit | Counted per |
|---|---|---|
DELETE /v1/domains/<domain> |
10 a minute | key |
POST .../logs/<id>/resend |
20 a minute | key |
POST .../webhook-secret/rotate |
10 a minute | key |
POST /v1/domains/claims |
5 a minute | key |
POST /v1/domains/claims/<domain>/check |
12 a minute | key |
| Creating an account | 10 per 10 minutes | IP address |
| Each sign-in step, and asking for a password reset | 25 per 5 minutes | IP address |
What your plan allows
Sending has its own allowance on top of the request limit, and it is what your plan pays for:
| Plan | API requests per minute | Emails sent a month (API, SMTP and replies) |
|---|---|---|
| Free | 240 | 0 |
| Pro | 240 | 5,000 |
| Max | 240 | 15,000 |
Free accounts can't send: creating an SMTP credential returns
402 with paid_plan_required, and calling the send
endpoint returns 403. Forwarding limits are counted
separately and are listed on the pricing page.
Allowances count per month from the day the plan renews, or on Free from
the day of the month the account was created. They start again at 00:00 UTC
on that day. A send that would pass the month's allowance returns 402
with the code plan_limit_reached. One day also has a limit:
past it, a send returns 429, and Retry-After says
how many seconds remain until 00:00 UTC.
Errors
A failure returns a matching HTTP status and a problem-details body
(RFC 9457), with the content type application/problem+json.
Branch on code, which stays the same. detail is a
message for people, and its words can change. type links to
the code's row in the table below.
{
"code": "not_found",
"detail": "This domain isn't on your account.",
"errors": [],
"status": 404,
"title": "Not found",
"type": "https://mailyoto.com/api/#error-not_found"
}
When fields fail validation, errors lists each
field with its detail, so you can show the message
next to the input that caused it:
{
"code": "validation_failed",
"detail": "An alias can't forward to its own address.",
"errors": [
{"detail": "An alias can't forward to its own address.", "field": "destinations"}
],
"status": 400,
"title": "Some fields need changes",
"type": "https://mailyoto.com/api/#error-validation_failed"
}
Every code
| Code | Status | When |
|---|---|---|
invalid_request |
400 | We can't read the request: a body that isn't JSON, or a query value out of range. |
invalid_cursor |
400 | The cursor came from another list or order, or was changed. Start again from the first page. |
validation_failed |
400 | Some fields need changes. errors names each one. |
unsupported_api_version |
400 | The path asks for a version other than v1. |
unauthenticated |
401 | No key, or a key we don't know or that was revoked. |
api_key_expired |
401 | The key is past the expiry date you gave it. Create a new one. |
second_factor_required |
401 | A dashboard sign-in still needs the authenticator code. |
paid_plan_required |
402 | The feature needs Pro or Max. |
plan_limit_reached |
402 |
The request would pass one of your plan's limits. limit
names it: {"key", "max", "used"}.
|
forbidden |
403 | The key can't do this: the endpoint works only from the dashboard, or the plan or domain doesn't allow it. |
not_found |
404 | The path, or the item it names, isn't on your account. |
method_not_allowed |
405 | The path exists, but not with this HTTP method. |
conflict |
409 |
The request clashes with what is there now: a name already taken, a
log already resent, or a send with the same Idempotency-Key
still running.
|
domain_in_use |
409 | Another account holds the domain. Start a claim to move it. |
gone |
410 | We no longer keep what the request needs, such as the message of an old log. |
payload_too_large |
413 | The request body is too large. |
unsupported_media_type |
415 | The body isn't sent as application/json. |
unprocessable |
422 | We understood the request but can't act on it, such as an Idempotency-Key used before for a different email. |
rate_limited |
429 | A bucket ran out. Wait the number of seconds in Retry-After. See Request limits. |
internal_error |
500 | Something broke on our side. We get an alert, and you can retry. |
not_implemented |
501 | This server can't do that yet. |
service_unavailable |
503 | A service the request needs is down for a moment. Retry shortly. |
Anything missing?
The API covers what the dashboard does, except your password, two-step verification and API keys. If an endpoint you need isn't described here, or a response surprises you, write to us. API questions go to the same inbox as everything else, and we answer them the same day where we can.