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 bodyhtml 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
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
list_unsubscribe_url, list_unsubscribe_mailto, list_unsubscribe_postCarry your own opt-out targets
smtp_group, smtp_server_idPin the route out
sandbox, sandbox_retention_daysCapture instead of delivering — see Sandbox

dry_run is the cheapest way to check an integration: it validates the sender, the recipients, the headers, the attachment sizes and the routing, then returns without writing a row or spending quota.

Sixteen headers are reserved

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, Content-Type, Content-Transfer-Encoding, List-Unsubscribe, List-Unsubscribe-Post, Return-Path, Message-ID, Received and DKIM-Signature.

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, and a header name or value containing a newline is refused outright, which is what stops header injection through a value you interpolated.

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.