Skip to main content
mailyoto

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-v2 and reset_password work.
  • Order, 1st-reminder, welcome- and a..b don'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 &amp; 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.