API Reference
API Reference
GitHub ↗
Email Sending

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 verified
  • to, at least one parseable address, within the installation’s recipient ceiling
  • subject, non-empty
  • a body — html or text, 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

FieldDoes
cc, bccThe other two recipient lists - see Who sees whom
reply_toWhere a reply lands when it should not go back to from. Any parseable address, verified or not
headersUp to 20 custom headers, written over the project’s defaults - see below
tagsUp to 10 labels of 64 characters. The log filters by ?tag=
metadataUp to 20 string pairs - your order id, your tenant - read back on the record and never interpreted
attachmentsBase64 files — see Attachments
send_atHold until an RFC 3339 time — see Scheduled Email
dry_runRun every validation and persist nothing
disable_trackingOpt this message out of open and click tracking
unsubscribe_list_idSend under a transactional opt-out scope , which is also what {{ mailyard_unsubscribe_url }} links to
list_unsubscribe_url, list_unsubscribe_mailto, list_unsubscribe_postCarry 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_idPin the route out
sandbox, sandbox_retention_daysCapture 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:

  1. The project’s default headers, set under project settings . They go on every message the project sends - API, console, SMTP submission and campaigns alike.
  2. A campaign’s headers, on every message of that campaign.
  3. 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 '{ ... }'
OutcomeStatusBody
First request201The queued message
A retry after the first completed200The same message, with replayed: true and an empty suppressed_recipients
A duplicate while the first is still running409An error naming the key
A retry from a caller that may not read it409An error naming the key
A retry after the first was refusedWhatever the corrected request earnsThe 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.