Send email

POST /email/messages accepts raw content or a template, never both, and returns 202 with a message you can poll.

Send a message

POST /email/messages accepts either raw content or a template, never both. With raw content, subject is required alongside html or text. With template_id, the body comes from the active version of the template and variables fills the placeholders.

curl -X POST https://api.mailer.quathos.com/api/v1/email/messages \
  -H "Authorization: Bearer $QUATHOS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from":    { "email": "you@yourcompany.com", "name": "Your Company" },
    "to":      [{ "email": "user@example.com" }],
    "subject": "Welcome aboard",
    "html":    "<h1>You are in.</h1>"
  }'
json
{
  "from":        { "email": "you@yourcompany.com" },
  "to":          [{ "email": "user@example.com" }],
  "template_id": "019f70c2-2b41-7a5e-9c3e-2f1a8d55b310",
  "variables":   { "name": "Ana", "order_id": "A-1042" }
}
Template mode: no html, no text.

Request fields

FieldNotes
fromObject with email and optional name. Must be a verified domain or sender.
toArray with exactly one recipient. More than one is refused with 400: send one request per recipient, or use cc/bcc for copies of the same message.
cc / bccCopies of the SAME message, up to 50 addresses combined. One id, one status, one charge. Cc becomes a visible header; Bcc only ever exists on the envelope. Every copy goes through the suppression list, and a suppressed copy refuses the whole message naming which address (role: cc or bcc).
headersCustom headers, up to 10, by allowlist: X-* (except X-Quathos-*), In-Reply-To, References, List-Id, Precedence, Auto-Submitted. Envelope headers (From, To, Subject, Reply-To, List-Unsubscribe) are refused with 400 and the message names the proper field. No line breaks.
unsubscribe_groupMarketing only. The unsubscribe link and the one-click header of this message opt the recipient out of THIS group, not of everything. Create groups at POST /email/unsubscribe-groups.
reply_toObject with email and optional name. Sets the Reply-To header: where answers go.
reply_to_listArray of the same objects, up to 1000. Mutually exclusive with reply_to.
subjectRequired unless template_id is present, where it overrides the template.
html / textRaw content. Mutually exclusive with template_id.
template_id / variablesTemplate mode. Templates are sandboxed: they never execute code.
typetransactional (default) or marketing. Separate identities, separate reputation.
send_atWhen to send it (ISO 8601, timezone REQUIRED: without an offset the moment is ambiguous and we refuse it). Up to 30 days ahead, or less when the message carries attachments, which expire. A time in the past sends immediately. The cost is reserved on acceptance and released if you cancel.
tags / metadataYours. Returned on events, useful for correlation.
trackingPer-message opens and clicks toggles. Omitted (or null) inherits the default of the sending domain, which you set per domain; an explicit true/false always wins.
attachmentsArray of files: content (base64), filename, optional type, disposition (attachment or inline) and content_id for inline parts, referenced from the HTML as cid:VALUE. Up to 10 files, 10 MB each and 15 MB combined, measured on the DECODED content, not on the base64 payload, which is a third larger and would not pass the receiving side. Extensions the large receivers discard on arrival (.exe, .js, .bat, …) are refused with 422. Files are discarded 7 days after the send.

Where replies go

reply_to sets the Reply-To header of the delivered message: the address the recipient reaches when they hit Reply. It is independent of from: the message can be signed by no-reply and still be answerable. Send an object with email and an optional name, the same shape as from; the name travels into the header as the display name.

json
{
  "from":     { "email": "no-reply@yourcompany.com", "name": "Your Company" },
  "to":       [{ "email": "support@yourcompany.com" }],
  "reply_to": { "email": "ana@customer.com", "name": "Ana Ribeiro" },
  "subject":  "New contact form submission",
  "text":     "Ana wrote: my invoice has the wrong address."
}
A no-reply From with a Reply-To that reaches a person.

This matters most for the two halves of a contact form. The copy that goes to your team should reply to whoever wrote in; the confirmation that goes back to them should reply to your monitored inbox. Without it, both ends answer into a no-reply address and the message is lost: people reply to "do not reply" messages anyway, to add a detail or attach a document.

That monitored inbox has to exist somewhere, and it is not something this API provides: Quathos Mailer sends, it does not host mailboxes. Point Reply-To at an address your team already reads: Reply-To routes the answer; a mailbox is what receives it.

Reply-To is an address list in RFC 5322, so more than one address is legal. Use reply_to_list for that, with up to 1000 entries of the same shape. The two fields are mutually exclusive: sending both is a 422, never a silent pick: choosing for you would send a reply somewhere you did not ask for.

json
{
  "from": { "email": "no-reply@yourcompany.com", "name": "Your Company" },
  "to":   [{ "email": "ana@customer.com" }],
  "reply_to_list": [
    { "email": "support@yourcompany.com", "name": "Support" },
    { "email": "billing@yourcompany.com" }
  ],
  "subject": "We received your request",
  "text":    "Reply to this message and both teams will see it."
}
One reply reaches support and billing at once.
Omit both fields and no header is invented: replies fall back to from, which is the right behaviour for an anonymous submission with no address to answer. Reply-To is a header, not an identity: unlike from, it is not checked against your verified domains and senders, so it can point at any address you control, on any domain. It does have to be a well-formed address, though: every entry is validated, and a malformed one is a 422 rather than a header nobody can reply to. The reply_to stored on a registered sender is not applied automatically; only what the request carries is used.

Send in batches

POST /email/messages/batch takes up to 50 messages in one request and answers 207 with one outcome per item: accepted items carry the same object as a single send, rejected items carry the error code and message. A suppressed recipient in item 3 does not take items 1, 2 and 4 down. Idempotency-Key covers the whole batch: the same key returns the same 207 with the same ids.

text
POST https://api.mailer.quathos.com/api/v1/email/messages/batch
{
  "messages": [
    { "from": { "email": "you@yourcompany.com" }, "to": [{ "email": "a@example.com" }],
      "cc": [{ "email": "boss@example.com", "name": "Boss" }],
      "headers": { "X-Ticket-Id": "4711" },
      "subject": "Ticket 4711", "text": "..." },
    { "from": { "email": "you@yourcompany.com" }, "to": [{ "email": "b@example.com" }],
      "subject": "Hello", "text": "..." },
    { "from": { "email": "you@yourcompany.com" }, "to": [{ "email": "unsubscribed@example.com" }],
      "subject": "Hello", "text": "..." }
  ]
}

207 Multi-Status
{
  "accepted_count": 2, "rejected_count": 1, "dry_run": false,
  "results": [
    { "index": 0, "status": "accepted", "message": { "id": "01J...", "status": "QUEUED", ... } },
    { "index": 1, "status": "accepted", "message": { "id": "01J...", "status": "QUEUED", ... } },
    { "index": 2, "status": "rejected",
      "error": { "code": "suppressed_recipient", "message": "This recipient (to) is on the suppression list.",
                 "details": { "reason": "UNSUBSCRIBE", "scope": "suppression", "role": "to" } } }
  ]
}
Two accepted, one refused, in one round trip.
dry_run: true walks every check (suppression, verified sender, quota, credits) and undoes everything, returning the same 207. It is the diff you get before sending, with the same limits, so what it shows is what will happen.

Send before you verify a domain

While your own domain is not verified, you can send transactional email from the shared sending domain. Your address on it is derived from your account and frozen: the first request from another address on that domain is refused with 403 and the message tells you the right one. reply_to is required (the From is ours, replies need somewhere of yours to go), marketing is refused, attachments are capped at 1 MB and there is a daily cap per account. Authenticate your domain to remove all of that.

Response and status

json
{
  "id": "019f7141-46ac-76c3-8276-042f38c68cd8",
  "status": "queued",
  "channel": "email",
  "to": "user@example.com",
  "from": "you@yourcompany.com",
  "type": "transactional",
  "created_at": "2026-07-17T18:05:31Z",
  "estimated_cost_micros": 0,
  "currency": "USD"
}
202 Accepted. The message is queued, not yet delivered.

Poll GET /email/messages/{id} for the current state, or, better, subscribe to webhooks and let the status come to you.

cURL
curl https://api.mailer.quathos.com/api/v1/email/messages/019f7141-46ac-76c3-8276-042f38c68cd8 \
  -H "Authorization: Bearer $QUATHOS_TOKEN"
Money is in micro-dollars: 1 USD = 1,000,000 micros, always an integer. An email costs on the order of US$ 0.0002, which whole cents would round to zero. estimated_cost_micros is 0 when the send falls inside your plan quota: it was already paid by the subscription. Above the quota it carries your plan overage rate, or your credits absorb it.