Send a message

POST/api/v1/messages
Requires anorganization API keywith the scopemessages:send

Submits one message for delivery and returns as soon as it is accepted — delivery itself happens asynchronously, so a 200 here means "queued", not "in their inbox". Watch for the outcome through delivery events or a webhook.

Three things have to be true before a send will work, and they are the cause of almost every first-attempt failure:

  1. The domain in from is verified. See Managing domains and DNS.
  2. from is a verified sender belonging to your organization. An address that merely sits on a verified domain is not enough; it has to exist as a sender.
  3. The recipient is not suppressed. Mailyte refuses sends to addresses that have bounced, complained or unsubscribed, and it refuses them before your own list logic gets a say. A suppressed recipient returns 422 and records an email.dropped event.

Provide content either directly (subject plus html and/or text) or by naming a template_id. If you do both, the fields you pass win over the template's. variables are substituted in either case.

Parameters

NameInTypeDescription
Idempotency-KeyheaderstringSend a unique value — a UUID is ideal — to make this request safe to retry. If we have already answered a request with the same key and the same body, you get that exact response back with `Idempotent-Replayed: true` and nothing is sent a second time. Reusing a key with a DIFFERENT body is a 409, because answering the first response to a second message would silently swallow it. Keys are scoped to your organization and honoured for 24 hours. A 5xx does not record a key: we cannot say whether the message left, so your retry genuinely retries. Omit the header and nothing changes.

Request body

  • toanyrequired

    Recipients. A single address or a list, each either `user@example.com` or `Name <user@example.com>`. At most 50 across to, cc and bcc COMBINED — use /messages/batch beyond that, or when each recipient needs their own values.

  • ccarray<string>

    Carbon-copy recipients. Counts toward the same 50.

  • bccarray<string>

    Blind-copy recipients. Counts toward the same 50. The addresses reach the envelope and are stripped from the message, so no recipient can see them — and they are NOT echoed on the response, because a receipt is the most likely thing to be forwarded or logged.

  • fromstringrequired

    Must be a verified sender in your organization. The display name comes from the sender record.

  • reply_tostring

    Where replies go instead of `from`. Accepts a display name.

  • headersobject

    Custom headers, at most 25 — for example `In-Reply-To` and `References` to thread a conversation. Headers Mailyte sets (From, To, Subject, Message-ID, DKIM-Signature, List-Unsubscribe, Bcc, X-Mailyte-*) are REFUSED with a 422 rather than silently dropped.

  • attachmentsarray<object>

    Files to attach. The whole message may be at most 10 MB, measured AFTER base64 encoding — the encoding inflates by roughly a third, so budget accordingly. Windows executable and script extensions are refused.

    • namestringrequired

      Filename as the recipient sees it. Its extension is checked against the blocked list.

    • contentstringrequired

      The file, base64 encoded.

    • content_typestring

      MIME type. Guessed from the filename when omitted.

    • content_idstring

      Set this to embed the file INLINE instead of attaching it, and reference it as `<img src="cid:THE_VALUE">` in your html. Its presence is the only difference between an inline image and a download. The Content-ID header on the wire is generated; your `cid:` reference is rewritten to match, so use the value you set here and nothing else.

  • subjectstring

    Required unless template_id is given.

  • htmlstring

    HTML body. Required unless template_id or text is given.

  • textstring

    Plain-text body. Always worth sending alongside html.

  • template_idstring

    A stored template to render instead of inline content.

  • variablesobject

    Substituted into subject, html and text. Values are HTML-escaped in html bodies and left raw in subject and text. Write them as `{{ first_name }}`, optionally with `{{ first_name | default('there') }}`. **Mailchimp merge tags work too** — `*|FNAME|*` renders identically, so content migrated from Mailchimp does not have to be rewritten; `FNAME`, `LNAME`, `EMAIL`, `PHONE`, `ADDRESS`, `COMPANY` and `UNSUB` are recognised, and any other `*|TAG|*` matches a variable of that name whatever its capitalisation.

  • metadataobject

    Your own data, returned on every delivery event for this message and NEVER rendered into it. This is what lets you match a webhook to your own record without first storing our message_id against it — put your order id here. Not to be confused with `variables`, which ARE substituted into the subject and bodies. At most 10 fields, keys up to 20 characters and values up to 80.

  • tagsarray<string>

    Your own labels, returned on the delivery events for this message. At most 10.

  • trackingboolean

    Set `false` to send this one message with no open pixel and no rewritten links. Use it for password resets, receipts and anything else a person asked for rather than subscribed to — a tracked transactional message looks like a newsletter to a mailbox provider, and gets filed like one. Defaults to true, so nothing changes unless you ask.

  • streamstringmarketing

    Send over the marketing egress IP instead of the transactional one. Omit for application mail.

Request

POST/api/v1/messages
curl -X POST 'https://app.mailyte.com/api/v1/messages' \
  -H 'Authorization: Bearer mk_live_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "to": [
      "Ada Lovelace <ada@example.com>"
    ],
    "cc": [
      "accounts@acme.com"
    ],
    "from": "hello@yourdomain.com",
    "reply_to": "support@yourdomain.com",
    "subject": "Your receipt from Acme",
    "html": "<h1>Thanks, {{first_name}}</h1><p>Your order is on its way.</p>",
    "text": "Thanks, {{first_name}}\nYour order is on its way.",
    "variables": {
      "first_name": "Ada"
    },
    "tags": [
      "receipt"
    ],
    "headers": {
      "X-Order-Id": "ord_1234"
    },
    "metadata": {
      "order_id": "ord_1234",
      "tier": "pro"
    },
    "attachments": [
      {
        "name": "invoice.pdf",
        "content": "JVBERi0xLjQKJ...",
        "content_type": "application/pdf"
      }
    ]
  }'
The key names its own organization, so no X-Organization-ID header is needed.

Response

The message was accepted for delivery.

  • dataobject
    • objectstringmessage
    • idstring

      The opaque handle for this submission: a BARE ULID, no host and no angle brackets. `GET /email-logs/messages/{id}` resolves it immediately -- you do not have to wait for a webhook. It is NOT the Message-ID. NULL WOULD MEAN UNKNOWN and cannot happen on a 2xx: the send mints this before it records anything, so a response that carries a receipt carries a handle. If you are holding null you are not holding an accepted message.

    • message_idstring

      The RFC 5322 Message-ID as it went out on the wire, ANGLE BRACKETS INCLUDED -- `<01JBT8XQ2M...@example.com>`. It is the header your recipient sees and the id `GET /domains/{domain}/messages/{messageId}` takes; percent-encode it in the path. `id` is the bare ULID and the two are not interchangeable: giving `id` to the archive endpoint returns 404, which is precisely what this field used to publish. Null when no sending host could be determined, never a guess.

    • recipientstring

      The PRIMARY addressee, without any display name. Published as `recipient` rather than `to`, matching every event and message row in this section. It stays a single string now that a message may have several recipients, so code reading it does not change type underneath you -- read `recipients` for the whole set. NULL WOULD MEAN UNKNOWN and cannot happen on a 2xx, since `to` is required.

    • recipientsobject

      Everyone the message was addressed to. **`bcc` is deliberately absent**: blind recipients are recorded in your delivery log but never echoed on a receipt, which is the one response most likely to be forwarded, logged or shown to a customer.

      • toarray<string>

        As accepted, including any display name.

      • ccarray<string>

        Empty when none were given -- NONE, not unknown.

      • countinteger

        To plus Cc. Excludes Bcc, which is not published here; the limit of 50 counts all three together.

    • reply_tostring

      The Reply-To that went out. NULL MEANS NONE was set, in which case replies go to `from`.

    • attachment_countinteger

      How many files were attached. Zero means none were sent, never that we did not look.

    • fromobject

      The sender. An object here, and everywhere else a sender appears, so one piece of caller code reads all of them.

      • emailstring

        The address this was accepted as, echoed back. NULL WOULD MEAN UNKNOWN and cannot happen on a 2xx -- `from` is a required, validated field on the request and the receipt republishes the resolved sender's own address. It is never "sent from nowhere".

      • namestring

        The display name the message was sent with — the same one that went into the `From:` header, not a lookup done afterwards. NULL MEANS NONE: this sender has no display name set, so the message went out as a bare address. It is not "unknown" and it is not withheld — set a name on the sender and it appears here.

    • subjectstring

      The subject AS RENDERED -- `{{ }}` substituted, the same string that went into the header -- which is why it can differ from what you posted and why it is echoed at all. NULL WOULD MEAN UNKNOWN and cannot happen on a 2xx: the renderer falls back to the empty string, so a subjectless send is published as `""`, never as null. Test for `""`, not for null.

    • submitted_atstring

      When we ACCEPTED the message, not when it was delivered. A 200 here means queued; the outcome arrives as a delivery event or a webhook. NULL WOULD MEAN UNKNOWN -- we could not read the timestamp off the recorded submission -- and never that the message is not submitted yet. A 2xx has already written the row, and its timestamp column is `NOT NULL DEFAULT current_timestamp()`, so this is not a case to code for.

    • modestringlive | test

      Whether a real message was sent. `test` means this request was made with a TEST API KEY: it was validated exactly as a live one would be — the sender had to be verified, the recipients had to not be suppressed, the rate limit applied — and then nothing was handed to the mail server and nothing was recorded. `id`, `message_id` and `submitted_at` are null for that reason: there is no message to look up. Test mode is a property of the key, not of the request, so it cannot be switched on by a stray parameter in production code.

200application/json
{
  "message": "Message submitted successfully",
  "code": 200,
  "success": true,
  "data": {
    "message_id": "<01JBT8XQ2M@yourdomain.com>",
    "event_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
    "to": "ada@example.com",
    "from": "hello@yourdomain.com",
    "subject": "Your receipt from Acme",
    "submitted_at": "2026-09-17T10:04:11.000000Z"
  }
}

Returned inside the standard envelope.

Errors

StatusWhen
401The API key is missing, unknown, revoked or expired. All four answer identically, on purpose: distinguishing them would confirm which keys exist.
403The key is valid but may not do this: it lacks the required scope, its IP allowlist does not include you, or this endpoint does not accept API keys.
404No such resource in this organization.
422The request was understood but the values were not acceptable.
429Too many requests, or the organization has spent its sending allowance. `Retry-After` says how long to wait.

Every status, with what causes it and what to do, is on the error reference.